setTimeout setImmediate process.nextTick 실행 순서 정리
setTimeout setImmediate process.nextTick 실행 순서 정리
Node.js의 비동기 API는 모두 다음 줄에서 실행되지 않지만 같은 큐를 사용하지도 않는다. 실행 순서를 외운 숫자로만 이해하면 I/O 콜백 안팎에서 결과가 달라질 때 혼란스럽다.
- process.nextTick은 현재 작업 직후, 이벤트 루프 단계가 넘어가기 전에 처리된다.
- setImmediate는 check 단계에서 실행된다.
- setTimeout은 지정 시간이 지난 뒤 timers 단계의 후보가 된다.
목차
- #세 API가 예약하는 위치
- #실행 순서가 항상 같지 않은 이유
- #선택 기준
- #같은 코드도 실행 문맥에 따라 달라진다
- #Promise와 queueMicrotask까지 함께 보면
- #API별 선택 기준
- #실행 순서 문제를 디버깅하는 방법
- #결론
- #관련 노트
세 API가 예약하는 위치
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
process.nextTick(() => console.log("nextTick"));
nextTick은 일반적으로 먼저 보이지만 timeout과 immediate의 상대 순서는 실행 문맥과 I/O 상태에 따라 달라질 수 있다.
실행 순서가 항상 같지 않은 이유
0ms timeout은 즉시 실행 명령이 아니다. 최소 시간이 지난 뒤 timers 단계에서 실행할 수 있다는 뜻이다. setImmediate는 I/O poll 이후의 check 단계에 예약되므로 I/O 콜백 안에서는 의도가 더 명확하다.
nextTick 안에서 계속 nextTick을 등록하면 이벤트 루프가 다음 단계로 넘어가지 못해 I/O가 굶을 수 있다.
function unsafeLoop() {
process.nextTick(unsafeLoop);
}
// 다른 I/O 콜백이 실행될 기회를 잃을 수 있다.
선택 기준
- 현재 API 동작을 마친 직후 짧은 후처리: queueMicrotask 또는 제한적인 nextTick
- I/O 처리를 한 번 양보한 뒤 실행: setImmediate
- 실제 시간 지연과 재시도: setTimeout
같은 코드도 실행 문맥에 따라 달라진다
메인 모듈에서 setTimeout(..., 0)과 setImmediate를 함께 등록했을 때 둘의 상대 순서를 항상 보장할 수는 없다. 타이머 임계 시간이 지났는지와 이벤트 루프가 어떤 상태로 진입했는지에 따라 달라질 수 있기 때문이다.
반면 I/O 콜백 안에서 등록하면 의도가 조금 더 분명해진다.
import { readFile } from "node:fs";
readFile(new URL("./sample.txt", import.meta.url), () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
});
I/O poll 단계의 콜백을 실행한 뒤에는 check 단계로 이동하므로 이 문맥에서는 setImmediate가 먼저 실행되는 모습을 기대할 수 있다. 다만 애플리케이션 로직이 이 미세한 순서에 의존하도록 만드는 것은 좋지 않다.
Promise와 queueMicrotask까지 함께 보면
Node.js에서는 process.nextTick 큐와 Promise가 사용하는 마이크로태스크 큐를 구분해서 이해할 필요가 있다.
console.log("sync:start");
setTimeout(() => console.log("timer"), 0);
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("promise"));
queueMicrotask(() => console.log("microtask"));
process.nextTick(() => console.log("nextTick"));
console.log("sync:end");
한 번의 실행을 개념적으로 정리하면 다음과 같다.
flowchart LR
A[현재 JavaScript stack] --> B[nextTick queue]
B --> C[Microtask queue]
C --> D[Event loop의 다음 phase]
D --> E[timers / poll / check]nextTick은 현재 작업 직후 매우 높은 우선순위로 처리된다. 그래서 라이브러리가 콜백을 비동기적으로 보장하기 위해 제한적으로 사용할 수 있지만, 재귀적으로 계속 등록하면 I/O 단계로 넘어가지 못한다.
let remaining = 100_000;
function drainWithNextTick() {
if (remaining-- <= 0) return;
process.nextTick(drainWithNextTick);
}
drainWithNextTick();
위 코드는 각 함수가 짧아도 큐가 계속 채워져 다른 작업을 굶길 수 있다. 많은 작업을 나누려는 목적이라면 setImmediate나 작업 큐가 더 적합할 수 있다.
API별 선택 기준
| API | 적합한 의도 | 주의점 |
|---|---|---|
queueMicrotask |
현재 동기 작업 뒤의 짧은 후처리 | 계속 등록하면 렌더·I/O 지연 |
process.nextTick |
Node API의 제한적인 호환 처리 | 과용 시 event loop starvation |
setImmediate |
I/O에 실행 기회를 준 뒤 처리 | 브라우저 표준 API가 아님 |
setTimeout |
실제 시간 지연, backoff | 지정 시간은 최소 대기 시간일 뿐 |
비동기 테스트가 불안정하다고 setTimeout(..., 100)을 추가하면 느리고 간헐적인 테스트가 된다. 완료 Promise, 이벤트, 가짜 타이머처럼 실제 완료 조건을 기다려야 한다.
실행 순서 문제를 디버깅하는 방법
- 로그에 단순 시각만 찍지 말고 작업 ID와 등록 위치를 남긴다.
- 동기 코드, 마이크로태스크, 타이머, I/O 콜백을 분리해 최소 예제를 만든다.
- Node.js 버전과 테스트 러너의 가짜 타이머 사용 여부를 확인한다.
- 순서 보장이 꼭 필요하다면 큐 선택에 의존하지 말고 Promise나 명시적 상태 전이로 연결한다.
// 순서를 큐 운에 맡기지 않고 완료를 직접 연결한다.
await saveRecord();
await publishRecordSavedEvent();
결론
정확한 순서를 암기하기보다 각 API가 이벤트 루프의 어디에 작업을 예약하는지 이해해야 한다. 순서에 의존하는 코드는 테스트보다 구조를 먼저 단순화한다.