이벤트 루프 완전 정리 — Call Stack·Queue·libuv
Node 가 "단일 스레드인데 동시에 많은 요청을 처리한다" 의 진짜 정체.
면접 단골 질문 — "Node 는 단일 스레드인데 어떻게 동시에 많은 요청을 처리하나?" 답은 한 줄 — 이벤트 루프 + 논블로킹 I/O. 이 한 줄 뒤에 숨은 메커니즘을 풀어보면 Node 의 본질이 보인다.
이번 편은 가장 추상적이지만 가장 중요. Call Stack·Macrotask Queue·Microtask Queue·libuv 가 어떻게 협업하는지, 그래서 setTimeout·Promise·fs.readFile 의 실행 순서가 왜 그렇게 나오는지.
1. 큰 그림 — 단일 스레드, 다중 큐
Node 의 자바스크립트 코드는 단 하나의 스레드에서만 실행된다. 이 스레드 위에 두 구조가 있다.
- Call Stack — 함수 호출이 쌓이는 곳. 동기 코드는 여기서 끝까지 처리.
- 여러 종류의 큐 — 비동기 작업 결과가 줄을 서는 곳. Stack 이 비면 큐에서 하나씩 꺼내 Stack 으로 옮긴다.
"단일 스레드" 의 의미는 — 자바스크립트 코드가 한 번에 하나만 실행. 그러나 fs.readFile 같은 I/O 작업은 자바스크립트가 아닌 C++ 레이어 (libuv) 에서 백그라운드로 돈다. 자바스크립트 입장에선 "등록만 해두고 결과는 큐로 받는" 구조.
2. 이벤트 루프의 6단계 (Phase)
이벤트 루프는 6개 phase 를 순환한다. Node 공식 문서에 그림이 있는 그것.
| 단계 | 처리 | 예 |
|---|---|---|
| ① timers | setTimeout·setInterval 콜백 | delay 만료된 타이머 |
| ② pending callbacks | 옛 OS 에러 콜백 | TCP 에러 등 (잘 안 보임) |
| ③ idle, prepare | Node 내부용 | 사용자 코드 무관 |
| ④ poll | 새 I/O 이벤트 처리 | fs/네트워크 응답 도착 |
| ⑤ check | setImmediate 콜백 | poll 직후 실행할 것 |
| ⑥ close callbacks | socket.close 같은 close 이벤트 | 연결 정리 |
한 phase 가 끝날 때마다 Microtask Queue 가 통째로 비워진다 (Promise·queueMicrotask). 즉 setTimeout 사이사이에 Promise 가 끼어든다. 이게 setTimeout(fn, 0) 보다 Promise.resolve().then(fn) 이 먼저 실행되는 이유.
3. 실행 순서 예제 — 한 번에 보기
아래 코드의 출력을 맞춰보자.
출력 순서:
규칙 — 동기 → Microtask → Macrotask. Microtask 가 다 빠진 뒤에야 다음 phase 로 간다.
setImmediate 는 항상 같은 루프의 check phase 에서, setTimeout(fn, 0) 은 timers phase 에서 (최소 1ms 지연). I/O 콜백 안에서 호출하면 setImmediate 가 먼저, 그 외엔 OS 스케줄링에 따라 갈린다. I/O 콜백 안에선 setImmediate 가 안전한 선택.
4. libuv — I/O 의 진짜 일꾼
이벤트 루프만으론 부족하다. fs.readFile 처럼 진짜 시간이 걸리는 I/O 는 어딘가에서 실제로 처리돼야 한다. 그 일꾼이 libuv.
libuv 는 Node 안의 C++ 라이브러리로, 두 가지를 한다.
- OS 비동기 API 활용 — 네트워크 I/O 는 Linux 의 epoll·macOS 의 kqueue·Windows 의 IOCP 를 직접 호출. OS 가 알아서 비동기로 처리.
- 스레드 풀로 위임 — OS 가 비동기 API 를 제공 안 하는 작업 (fs·crypto·zlib·DNS 의 일부) 은 기본 4 스레드 풀에 던진다.
그래서 "Node 는 단일 스레드" 가 100% 맞는 말은 아니다. 자바스크립트 코드는 단일 스레드, libuv 의 백엔드는 멀티 스레드. 자바스크립트 개발자는 그 차이를 의식할 필요 없이 콜백·Promise 로 받기만 한다.
5. 실전 — 이벤트 루프 막지 마라
이론을 알면 실수가 줄어든다. 이벤트 루프 블로킹 = Node 서버 죽이기.
JSON.parse(거대문자열)·bcrypt.hashSync·readFileSync 같이 시간 오래 걸리는 동기 함수. 한 요청이 1초 동안 큐를 막으면 다른 1000개 요청도 1초 늦어진다. ② 무한 루프·재귀: 명백한 사고. ③ CPU 빡센 계산을 메인 스레드에서: 이미지 변환·암호화 대량·머신러닝 추론은 worker_threads 로 별도 스레드에.
점검 방법 — Node 22 부터 --cpu-prof 플래그로 프로파일을 뜰 수 있다. 30초 운영 후 결과를 Chrome DevTools 의 Performance 탭에 끌어다 두면 어디서 시간이 새는지 시각화.
요약 — 8편 좌표
여기까지 정리. Node 의 심장은 이벤트 루프 + libuv. 이벤트 루프는 6 phase 를 순환, 각 phase 사이에 Microtask Queue (Promise) 가 통째로 비워짐. 자바스크립트는 단일 스레드지만 libuv 가 OS 비동기 API · 스레드 풀로 실제 I/O 를 처리. 이벤트 루프를 막는 큰 동기 작업은 절대 금지 — 그 한 줄에 서버 전체가 느려진다. 다음 편에서 비동기 다루는 문법 — 콜백 → Promise → async/await 진화사를 본다.
다음 편 예고 — 비동기 패턴 정리
콜백 → Promise → async/await 자바스크립트 비동기 진화사. 9편.