Node.js 이벤트 루프는 브라우저와 다르다: 6단계와 새치기하는 process.nextTick
브라우저의 이벤트 루프는 스펙, Node.js의 이벤트 루프는 라이브러리다
지금까지 다룬 콜 스택·태스크 큐·마이크로태스크 큐는 브라우저(정확히는 HTML 스펙)의 이야기였습니다. Node.js 는 브라우저가 아닙니다. DOM 도 없고, 이벤트 루프 구현체도 완전히 다른 프로젝트입니다 — libuv 라는 C 라이브러리가 Node.js 의 이벤트 루프를 돌립니다. 마이크로태스크(Promise) 규칙은 브라우저와 동일하게 따르지만, 매크로태스크 쪽은 6개의 고정된 단계(phase)로 나뉘어 있고 각 단계마다 처리하는 콜백의 종류가 정해져 있습니다.
flowchart TD
T["timers\nsetTimeout, setInterval 콜백"] --> P["pending callbacks\n일부 시스템 레벨 콜백"]
P --> I["idle, prepare\nNode 내부용"]
I --> Poll["poll\nI/O 이벤트 대기 및 처리 (핵심 단계)"]
Poll --> C["check\nsetImmediate 콜백"]
C --> Close["close callbacks\nsocket.on('close') 등"]
Close --> T각 단계 사이사이, 그리고 각 콜백을 하나 실행한 직후마다 process.nextTick 큐와 Promise 마이크로태스크 큐를 비웁니다. 이 부분이 Node 11 이전과 이후로 나뉘는 지점인데, 뒤에서 다시 짚습니다.
process.nextTick은 Promise보다도 새치기한다
Node.js 에는 브라우저에 없는 process.nextTick 이라는 API 가 있습니다. 이름 때문에 setTimeout(fn, 0) 과 비슷한 걸로 오해하기 쉬운데, 실제로는 Promise 마이크로태스크보다도 먼저 처리되는, 별도의 더 높은 우선순위 큐입니다.
Promise.resolve().then(() => console.log("2: Promise 마이크로태스크"));
process.nextTick(() => console.log("1: nextTick"));
// 출력: 1, 2Node.js 가 "다음에 처리할 게 있나?"를 확인할 때마다 순서는 이렇습니다. process.nextTick 큐를 완전히 비운다 → Promise 마이크로태스크 큐를 완전히 비운다 → 그다음에야 다음 단계(phase)로 넘어간다. process.nextTick 안에서 또 process.nextTick 을 예약하면 그것도 이번 라운드에 포함되므로, 무분별하게 재귀 호출하면 poll 단계로 영영 못 넘어가는 것도 가능합니다 — 브라우저의 무한 마이크로태스크 문제와 같은 종류의 함정입니다.
setImmediate와 setTimeout(fn, 0)의 순서는 문맥에 따라 달라진다
Node.js 초심자용 자료에 자주 나오는 질문입니다. "최상위 스코프에서 setTimeout(fn, 0) 과 setImmediate(fn) 을 같이 호출하면 뭐가 먼저 실행되나요?" 답은 정해져 있지 않습니다.
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// 최상위 스코프에서 실행하면 순서가 매번 바뀔 수 있다이유는 timers 단계 진입 시점이 프로세스 시작 후 몇 ms 가 지났는지, 타이머 등록에 걸린 시간이 1ms 미만인지에 따라 미묘하게 갈리기 때문입니다. 반면 I/O 콜백 안에서 호출하면 순서가 항상 고정됩니다.
const fs = require("fs");
fs.readFile(__filename, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
});
// 항상: immediate, timeoutfs.readFile 콜백은 poll 단계에서 실행됩니다. poll 단계 다음은 항상 check 단계(setImmediate 담당)이고, timers 단계로 돌아가려면 루프를 한 바퀴 더 돌아야 합니다. 그래서 I/O 콜백 내부에서는 setImmediate 가 setTimeout(fn, 0) 을 이길 수밖에 없는 구조입니다. 순서를 예측 가능하게 만들고 싶다면 최상위 스코프에서 둘을 경쟁시키지 말고, 항상 I/O 콜백 안에서 예약하는 습관을 들이는 편이 낫습니다.
Node 11 이전에는 단계 사이에서만 마이크로태스크를 비웠다
버전 이력을 하나 짚고 넘어가야 합니다. Node.js 10 이하에서는 마이크로태스크 큐를 단계와 단계 사이에서만 비웠습니다. 즉 timers 단계 안에서 타이머 콜백 100개를 연달아 처리하는 동안에는, 그 사이사이에 예약된 Promise 콜백들이 전부 미뤄졌다가 timers 단계가 통째로 끝난 뒤에야 한꺼번에 실행됐습니다. 브라우저에서는 콜백 하나 끝날 때마다 마이크로태스크를 비우는 것과 눈에 띄게 달랐습니다.
Node 11 부터는 이 동작이 브라우저와 맞춰졌습니다 — 개별 콜백을 하나 실행할 때마다 그 직후에 마이크로태스크 큐를 비웁니다. 지금 LTS 버전을 쓰고 있다면 이 차이를 신경 쓸 일은 거의 없지만, "Node 이벤트 루프 그림"으로 검색해서 나오는 오래된 자료(특히 2018년 이전 블로그 글)를 보고 있다면 이 변경 이전 동작을 설명하고 있을 가능성이 있습니다. 버전 표기가 없는 이벤트 루프 자료는 일단 의심하는 게 안전합니다.