이벤트 루프의 6개 페이즈 — 콜백은 어떤 순서로 실행되는가 (4편)
3편에서 이벤트 루프의 실체가 libuv uv_run() 안의 while 루프이고, 6개의 C 함수가 순서대로 호출되는 구조라는 것을 확인했습니다. 6개 페이즈가 있다는 건 알겠는데, 각 페이즈가 구체적으로 무슨 일을 하는지는 아직 다루지 않았습니다.
이번 글에서는 6개 페이즈를 하나씩 살펴보겠습니다. 어떤 콜백이 어디서 실행되는지, Poll 페이즈가 왜 가장 중요한지, setTimeout(0)과 setImmediate의 순서가 왜 달라지는지, 그리고 process.nextTick은 어디에서 끼어드는지까지 정리합니다.
1. Timers — setTimeout, setInterval
Timers 페이즈는 setTimeout()과 setInterval()로 예약한 콜백을 실행합니다. "정확히 N ms 후"가 아니라 "최소 N ms 이후 가장 빠른 시점"에 실행된다는 점이 중요합니다.
타이머는 누가 관리하는가
흥미롭게도, 타이머는 스레드 풀도, OS 비동기 API도 사용하지 않습니다. 이벤트 루프(메인 스레드) 자체에서 관리합니다.
3편에서 libuv가 작업을 처리하는 두 가지 경로(스레드 풀, OS 비동기)를 살펴봤는데, 타이머는 여기에 속하지 않는 세 번째 경로입니다.
| 경로 | 처리 주체 | 예시 |
|---|---|---|
| 스레드 풀 | libuv 워커 스레드 | fs.*, dns.lookup(), crypto |
| OS 비동기 API | OS 커널 | TCP/UDP, 파이프, 시그널 |
| 이벤트 루프 자체 | 메인 스레드 | setTimeout, setInterval |
libuv는 메모리에 min-heap(최소 힙)이라는 자료구조를 두고 타이머 정보를 저장합니다. 시간을 재는 건 OS의 시계를 읽는 것이라 디스크나 네트워크 작업이 필요 없기 때문에, 스레드 풀에 위임할 이유가 없습니다.
타이머의 동작 흐름
setTimeout(cb, 1000) 호출 → 타이머 힙에 등록: "1000ms 후 cb 실행"
↓
이벤트 루프 순회 중...
↓
Timers 페이즈 도달 → 힙 확인 → 아직 안 됐네 → 다음 페이즈로
↓
... (루프 반복) ...
↓
Timers 페이즈 도달 → 힙 확인 → 만료됐다 → 콜백 실행
setTimeout(cb, 1000)을 호출한 순간 콜백이 Timers 큐에 들어가는 게 아닙니다. 타이머 힙에 등록만 해두고, 이벤트 루프가 Timers 페이즈에 도달할 때마다 "만료된 게 있나?" 확인합니다. 만료된 타이머가 있을 때만 콜백이 실행됩니다.
2. Pending Callbacks — 미뤄진 시스템 I/O 콜백
이전 루프의 Poll 페이즈에서 감지했지만 즉시 처리하지 않고 미뤄둔 시스템 레벨 I/O 콜백을 실행하는 곳입니다.
여기서 "시스템 레벨 I/O"란 OS 커널을 통해 처리되는 네트워크 관련 I/O를 말합니다. 주로 네트워크 소켓 에러 콜백이 대상입니다.
네트워크 소켓이란
잠깐 소켓 개념을 짚고 넘어가겠습니다. 소켓은 프로그램 간 네트워크 통신을 위한 연결 통로이고, IP 주소 + 포트 번호로 식별됩니다.
내 컴퓨터 상대 컴퓨터
┌──────────┐ ┌──────────┐
│ Node.js │ │ 서버 │
│ 소켓 ●──┼──── 네트워크 ────┼──● 소켓 │
└──────────┘ └──────────┘
Node.js는 서버 역할(소켓을 열고 기다림)과 클라이언트 역할(다른 소켓에 접속) 둘 다 할 수 있습니다. 이 블로그의 백엔드가 좋은 예시입니다. 브라우저 요청을 받을 때는 서버이고, MongoDB에 쿼리할 때는 클라이언트입니다.
Pending에 들어가는 에러들
이 소켓 연결이 실패하면 에러 콜백이 발생하는데, 그중 일부가 Pending 페이즈로 미뤄집니다.
| 에러 | 의미 | Pending 대상 |
|---|---|---|
ECONNREFUSED | 상대 서버가 연결을 거부 | O |
ETIMEDOUT | 응답을 기다리다 시간 초과 | O |
EPIPE | 상대방이 이미 연결을 끊음 | O |
EADDRINUSE | 포트를 다른 프로세스가 점유 | X (동기 에러) |
공통점은 네트워크를 거쳐서 결과가 돌아오는 실패라는 것입니다. EADDRINUSE처럼 로컬에서 바로 알 수 있는 에러는 동기적으로 처리되고, 상대방에게 갔다가 실패가 돌아오는 경우만 Pending으로 미뤄집니다.
이 에러 코드들은 POSIX(Portable Operating System Interface)라는 표준에 정의되어 있습니다. Unix 계열 OS(Linux, macOS 등)가 "이 에러 코드, 이 시스템 콜 이름은 다 같은 걸 쓰자"고 약속한 것입니다. 1편에서 추적했던 gethostname()도 POSIX에 정의된 함수입니다.
개발자 입장에서는 Pending 페이즈를 의식할 일이 거의 없습니다. 에러 콜백으로 받을 뿐이고, 그게 Poll에서 실행됐는지 Pending에서 실행됐는지는 동작에 차이가 없습니다.
3. Idle, Prepare — libuv 내부 전용
6개 페이즈 중 유일하게 개발자가 관여할 수 없는 페이즈입니다. API가 없습니다.
Idle — V8에 GC 타이밍을 알려줌
V8의 가비지 컬렉터(GC)는 쓸모없는 객체를 메모리에서 정리하는데, GC가 작동하는 동안 JavaScript 실행이 멈춥니다. JS 코드 실행 중에 GC가 갑자기 끼어들면 성능에 영향을 주기 때문에, Idle 페이즈에서 V8에게 "지금이 GC하기 적절한 시점"이라는 힌트를 줍니다.
Prepare — Poll 대기 시간 계산
다음 단계인 Poll 페이즈에서 I/O를 감시하려면, 얼마나 기다릴지 미리 정해야 합니다.
Prepare에서 확인하는 것들:
├─ 만료 예정인 타이머가 있는가? → 있으면 그 시간만큼만 Poll에서 대기
├─ setImmediate가 등록되어 있는가? → 있으면 Poll에서 대기하지 않고 바로 넘어감
└─ 처리할 I/O가 남아있는가? → 없으면 Poll에서 오래 대기해도 됨
예를 들어 setTimeout(cb, 100)이 50ms 후에 만료된다면, Poll에서 50ms 이상 기다리면 안 됩니다. 타이머를 놓치니까요. 이런 계산이 Prepare에서 이루어집니다.
두 페이즈 모두 Poll이 잘 동작하기 위한 사전 작업이라고 이해하면 됩니다.
4. Poll — 메인 무대
이벤트 루프에서 가장 중요하고 가장 오래 머무는 페이즈입니다. Node.js 서버의 실제 일 대부분이 여기서 일어납니다.
하는 일
- 완료된 I/O 콜백 실행 — 파일 읽기 완료, HTTP 응답 도착, DB 쿼리 결과 등
- 새 I/O 이벤트 대기 — 할 일이 없으면 여기서 기다림
server.listen(3000)을 해놓고 아무 요청이 없으면, 이벤트 루프는 6개 페이즈를 쉬지 않고 빙빙 도는 게 아니라 Poll 페이즈에 머물면서 새 연결을 기다립니다.
어떤 콜백이 Poll에서 실행되는가
Timers, Check, Close, Pending에 해당하지 않는 거의 모든 비동기 콜백이 Poll입니다.
| 경로 | Poll에서 실행되는 콜백 |
|---|---|
| 스레드 풀 경로 | fs.readFile(), fs.stat(), dns.lookup(), crypto.pbkdf2(), zlib.gzip() |
| OS 비동기 경로 | server.on('request'), http.get() 콜백, socket.on('data'), DB 쿼리 응답 |
Poll 내부 동작 순서
Poll 페이즈에 진입하면 먼저 이미 준비된 I/O 콜백을 실행합니다. 다 처리한 뒤에 빠져나갈 조건을 확인합니다. 콜백보다 빠져나가는 조건이 우선이 아니라, 콜백이 먼저입니다.
Poll 페이즈 진입
↓
1단계: 이미 준비된 I/O 콜백 먼저 실행
↓
2단계: 빠져나갈 조건 확인
├─ 타이머 만료? → Timers로
├─ setImmediate 등록됨? → Check로
└─ 둘 다 없음 → 새 I/O 이벤트가 올 때까지 대기
OS에 소켓 연결을 요청한 뒤 응답을 기다리는 동안에도 이벤트 루프는 멈추지 않습니다. 메인 스레드가 기다리는 게 아니라 OS가 기다리는 것이고, 그 사이에 이벤트 루프가 수십~수백 바퀴 돌 수 있습니다. 응답이 도착하면 그때 Poll 페이즈에서 콜백이 실행됩니다.
5. Check — setImmediate 전용
setImmediate() 콜백만 실행하는 전용 페이즈입니다. Poll 페이즈 직후에 위치해 있어서, I/O 콜백 처리가 끝난 뒤 바로 실행하고 싶은 것을 등록할 때 씁니다.
const fs = require('fs');
fs.readFile('/tmp/data.json', (err, data) => {
// 이 콜백은 Poll에서 실행
setImmediate(() => {
// 파일 읽기 처리 직후, Check에서 바로 실행
processData(data);
});
});
setTimeout(0) vs setImmediate
이 둘이 자주 비교됩니다. I/O 콜백 안에서 둘을 같이 쓰면, 선언 순서와 무관하게 항상 setImmediate가 먼저 실행됩니다.
fs.readFile('/tmp/test.txt', () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// 항상: immediate → timeout
// 지금 Poll에 있으니까 → Check(immediate) → ... → Timers(timeout)
페이즈 순서가 Poll → Check → ... → Timers이기 때문입니다. 코드에서 setTimeout을 먼저 썼든 setImmediate를 먼저 썼든, 페이즈 순서가 결정합니다.
하지만 I/O 콜백 바깥에서 쓰면 순서가 보장되지 않습니다.
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// timeout → immediate 일 수도 있고
// immediate → timeout 일 수도 있음
Node.js가 시작할 때 uv_run() 진입 시점에 따라, 타이머가 이미 만료됐을 수도 아닐 수도 있기 때문입니다.
6. Close Callbacks — 뒷정리
리소스가 닫힐 때 발생하는 정리(cleanup) 콜백을 실행합니다.
const net = require('net');
const server = net.createServer();
server.on('close', () => {
console.log('서버 종료됨'); // Close 페이즈에서 실행
});
const socket = new net.Socket();
socket.on('close', () => {
console.log('소켓 닫힘'); // Close 페이즈에서 실행
});
서버 종료, 소켓 종료, 프로세스 종료 등 "무언가가 닫힐 때" 실행되는 콜백들이 여기에 해당합니다. 종료 콜백은 다른 모든 작업이 끝난 뒤에 실행되어야 하므로, uv_run()의 while 루프에서 마지막에 배치되어 있습니다.
페이즈 사이의 끼어들기 — Microtask 큐
6개 페이즈가 정규 순서라면, 여기에 속하지 않고 매 페이즈 사이마다 끼어들어서 실행되는 것이 있습니다. Microtask 큐입니다.
Microtask (매 페이즈 사이에 실행)
├─ 1순위: process.nextTick 큐
└─ 2순위: Promise 큐 (Promise.then, async/await)
[Timers] → nextTick 큐 비우기 → Promise 큐 비우기 →
[Pending] → nextTick 큐 비우기 → Promise 큐 비우기 →
[Idle/Prepare] → nextTick 큐 비우기 → Promise 큐 비우기 →
[Poll] → nextTick 큐 비우기 → Promise 큐 비우기 →
[Check] → nextTick 큐 비우기 → Promise 큐 비우기 →
[Close] → nextTick 큐 비우기 → Promise 큐 비우기 → 다음 루프
process.nextTick — "지금 동기 코드 끝나면 바로 실행해줘"
process.nextTick은 현재 동기 코드가 끝난 직후, 어떤 I/O 콜백보다 먼저 실행되어야 할 때 사용합니다.
가장 대표적인 실전 사용이 EventEmitter 패턴입니다.
const EventEmitter = require('events');
class MyServer extends EventEmitter {
constructor() {
super();
// this.emit('ready'); ← 여기서 바로 하면 리스너가 아직 등록 안 됨
process.nextTick(() => {
this.emit('ready'); // 동기 코드가 다 끝난 직후 실행
});
}
}
const server = new MyServer(); // ① constructor 실행 → nextTick에 emit 등록
server.on('ready', () => { // ② 리스너 등록
console.log('준비 완료');
});
// ③ 동기 코드 끝 → nextTick 실행 → emit('ready') → 리스너 실행
// 출력: 준비 완료
process.nextTick이 없었다면, emit('ready')가 리스너 등록보다 먼저 실행되어서 아무 일도 일어나지 않았을 것입니다.
Promise.then / async-await — 비동기 체인이 빠르게 이어져야 합니다
먼저 Promise.then과 async/await의 관계를 짚겠습니다. async/await는 Promise.then의 문법 설탕(syntactic sugar)입니다. 내부적으로 같은 것입니다.
// async/await
const user = await fetchUser(1);
console.log(user.name);
// 내부적으로 이렇게 동작
fetchUser(1).then((user) => {
console.log(user.name);
});
await 뒤의 코드가 .then() 콜백과 같습니다. 그래서 "Promise 큐에서 실행된다"고 하면 async/await 코드도 포함됩니다.
그러면 Promise가 왜 매 페이즈 사이에서 실행되어야 할까요? async/await 체인이 빠르게 이어지려면 필요하기 때문입니다.
async function handleRequest() {
const raw = await readFromDB(); // ① I/O 완료 → Poll에서 실행
const parsed = await parseJSON(raw); // ② 바로 resolve
const result = await validate(parsed); // ③ 바로 resolve
return result;
}
②와 ③은 이미 데이터가 있으니 바로 resolve됩니다. 만약 Promise가 특정 페이즈를 기다려야 한다면, await 하나마다 이벤트 루프 한 바퀴를 돌아야 합니다. 매 페이즈 사이에서 Promise를 처리해주기 때문에 체인이 끊기지 않고 바로 이어질 수 있습니다.
실행 우선순위 정리
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 항상: nextTick → promise → timeout 또는 immediate (이 둘은 상황에 따라)
| 순위 | 대상 | 실행 시점 |
|---|---|---|
| 1 | 콜 스택 (동기 코드) | 즉시 |
| 2 | process.nextTick | 현재 페이즈 끝난 직후 |
| 3 | Promise.then / await | nextTick 이후 |
| 4 | 이벤트 루프 페이즈 | Timers, Poll, Check 등 |
전체 그림
6개 페이즈와 Microtask를 하나로 정리하면 이렇습니다.
| 페이즈 | 역할 | 대표 콜백 |
|---|---|---|
| Timers | 만료된 타이머 콜백 실행 | setTimeout, setInterval |
| Pending | 미뤄진 시스템 I/O 에러 콜백 | ECONNREFUSED 등 네트워크 에러 |
| Idle, Prepare | 내부 전용 (GC 힌트, Poll 준비) | 개발자 API 없음 |
| Poll | I/O 콜백 실행 + 새 I/O 대기 | fs.*, http.*, DB 쿼리 등 대부분 |
| Check | setImmediate 전용 | setImmediate |
| Close | 종료 콜백 | socket.on('close') |
그리고 이 6개 페이즈 매 사이마다:
| 순위 | Microtask | 용도 |
|---|---|---|
| 1순위 | process.nextTick | 동기 코드 직후 즉시 실행 (EventEmitter 패턴 등) |
| 2순위 | Promise.then / await | async/await 체인이 빠르게 이어지도록 |
3편에서 이벤트 루프의 존재와 구조를 확인했고, 이번 편에서 각 페이즈가 구체적으로 무슨 일을 하는지 살펴봤습니다. 이제 "Node.js의 이벤트 루프가 어떻게 동작하는가"라는 질문에 페이즈 단위로 답할 수 있게 되었습니다.
관련 글
Node.js는 싱글 스레드인데 어떻게 동시에 처리할까 — 콜 스택과 이벤트 루프 (3편)
1편에서 Node.js의 내부 구조를, 2편에서 프로세스와 스레드의 기본 개념을 확인했습니다. 이번에는 "싱글 스레드인데 어떻게 동시 처리가 가능한가"라는 질문에 답하기 위해, 콜 스택과 이벤트 루프의 관계, libuv가 작업을 처리하는 두 가지 방식, 그리고 이벤트 루프 6개 페이즈의 실체를 소스코드로 확인해봤습니다.
NestJS 서버로 추적하는 이벤트 루프 — 이론에서 실전으로 (5편)
이벤트 루프 이론을 실제 NestJS 서버 코드에 대입합니다. 서버 시작부터 HTTP 요청 처리까지, 6개 페이즈가 실제로 어떤 역할을 하는지 추적합니다.
프로세스, 스레드, 메모리 — Node.js를 이해하기 위한 OS 기초 (2편)
Node.js의 libuv 스레드 풀을 이해하려면 OS 기초가 필요합니다. 프로세스와 스레드의 차이, CPU 코어와 소프트웨어 스레드의 관계, 메모리 동적 할당까지 — Node.js 동시성의 전제 지식을 정리합니다.