Node.js 이벤트 루프와 setTimeout 0ms의 실행 순서

문제 상황

레거시 코드를 리팩토링하던 중 Promise와 setTimeout이 섞인 코드에서 실행 순서가 예상과 달랐다.

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');

처음엔 1 → 4 → 2 → 3 순서일 거라 예상했지만, 실제로는 1 → 4 → 3 → 2였다.

이벤트 루프의 동작 방식

Node.js의 이벤트 루프는 여러 페이즈로 나뉜다.

  1. timers: setTimeout, setInterval 콜백 실행
  2. pending callbacks: I/O 콜백
  3. poll: 새로운 I/O 이벤트 처리
  4. check: setImmediate 콜백
  5. close callbacks: close 이벤트

각 페이즈 사이에 microtask queue(Promise, process.nextTick)가 먼저 실행된다. 이게 핵심이었다.

실행 순서 분석

// 동기 코드 실행
console.log('1'); // 1
console.log('4'); // 4

// microtask queue 처리
// Promise.then() → '3' 출력

// timers 페이즈
// setTimeout 콜백 → '2' 출력

setTimeout의 딜레이가 0ms여도 timers 페이즈에서 실행되므로, microtask인 Promise보다 나중에 실행된다.

process.nextTick

process.nextTick은 microtask보다도 우선순위가 높다.

Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
// 출력: nextTick → promise

하지만 과도한 nextTick 사용은 I/O를 블로킹할 수 있어 주의가 필요하다.

결론

비동기 코드를 작성할 때 단순히 "나중에 실행된다"가 아니라, 어떤 큐에 들어가는지 명확히 알아야 했다. 특히 Promise 기반 코드와 콜백 기반 코드가 섞일 때는 더욱 그렇다. 가능하면 async/await로 통일하는 게 실수를 줄이는 방법이다.

Node.js 이벤트 루프와 setTimeout 0ms의 실행 순서