setTimeout setImmediate process.nextTick 실행 순서 정리

setTimeout setImmediate process.nextTick 실행 순서 정리

Node.js의 비동기 API는 모두 다음 줄에서 실행되지 않지만 같은 큐를 사용하지도 않는다. 실행 순서를 외운 숫자로만 이해하면 I/O 콜백 안팎에서 결과가 달라질 때 혼란스럽다.

한눈에 보기

  • process.nextTick은 현재 작업 직후, 이벤트 루프 단계가 넘어가기 전에 처리된다.
  • setImmediate는 check 단계에서 실행된다.
  • setTimeout은 지정 시간이 지난 뒤 timers 단계의 후보가 된다.

목차

세 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 안에서 계속 nextTick을 등록하면 이벤트 루프가 다음 단계로 넘어가지 못해 I/O가 굶을 수 있다.

function unsafeLoop() {
  process.nextTick(unsafeLoop);
}
// 다른 I/O 콜백이 실행될 기회를 잃을 수 있다.

선택 기준

같은 코드도 실행 문맥에 따라 달라진다

메인 모듈에서 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, 이벤트, 가짜 타이머처럼 실제 완료 조건을 기다려야 한다.

실행 순서 문제를 디버깅하는 방법

  1. 로그에 단순 시각만 찍지 말고 작업 ID와 등록 위치를 남긴다.
  2. 동기 코드, 마이크로태스크, 타이머, I/O 콜백을 분리해 최소 예제를 만든다.
  3. Node.js 버전과 테스트 러너의 가짜 타이머 사용 여부를 확인한다.
  4. 순서 보장이 꼭 필요하다면 큐 선택에 의존하지 말고 Promise나 명시적 상태 전이로 연결한다.
// 순서를 큐 운에 맡기지 않고 완료를 직접 연결한다.
await saveRecord();
await publishRecordSavedEvent();

결론

정확한 순서를 암기하기보다 각 API가 이벤트 루프의 어디에 작업을 예약하는지 이해해야 한다. 순서에 의존하는 코드는 테스트보다 구조를 먼저 단순화한다.

관련 노트