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의 이벤트 루프는 여러 페이즈로 나뉜다.
- timers: setTimeout, setInterval 콜백 실행
- pending callbacks: I/O 콜백
- poll: 새로운 I/O 이벤트 처리
- check: setImmediate 콜백
- 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로 통일하는 게 실수를 줄이는 방법이다.