Node.js는 비동기 이벤트 기반으로 설계된 런타임이다. 그 핵심에 있는 것이 바로 Event Loop로, Node.js가 논블로킹 I/O를 수행하고 동시성을 달성하는 메커니즘이다.

가나디로 보는 Node.js Event Loop 가나디로 보는 Node.js Event Loop

Node.js의 동작 방식

Node.js는 JavaScript 실행 자체는 싱글 스레드(V8 엔진)로 처리하지만, I/O 작업은 libuv를 통해 커널이나 스레드 풀에 위임한다. 이벤트 루프는 이 위임된 작업의 완료 여부를 확인하고 등록된 콜백을 실행하는 역할을 한다. 덕분에 하나의 스레드로도 많은 동시 연결을 효율적으로 처리할 수 있다. 별도로 worker_threads 모듈을 사용하면 CPU 집약적 작업을 위한 멀티 스레드 처리도 가능하다.

Event Loop의 구조

alt text

이벤트 루프는 위와 같이 여러 단계(Phase)를 순환하며, 각 단계마다 콜백을 저장하는 FIFO 큐가 존재한다.

  • timers: setTimeout()setInterval()로 예약된 콜백을 실행
  • pending callbacks: 다음 루프 반복으로 연기된 I/O 콜백을 실행
  • idle, prepare: 내부적으로만 사용
  • poll: 새로운 I/O 이벤트를 탐색하고 I/O 관련 콜백을 실행
  • check: setImmediate() 콜백을 실행
  • close callbacks: close 이벤트를 발생시킨 콜백을 실행

매 루프마다 비동기 I/O 혹은 타이머 대기 작업이 있는지 확인하고, 대기 항목이 없다면 종료한다.

각 단계에서 콜백을 처리하는 도중에도 커널에 의해 새로운 이벤트가 폴링 큐에 추가될 수 있기 때문에, 오래 걸리는 콜백이 있으면 폴링 단계가 타이머의 임계값보다 훨씬 길어질 수 있다.

libuv 1.45.0 (Node.js 20) 버전부터 poll 단계에서 앞뒤로 타이머가 실행될 수 있던 것을 poll 단계를 완전히 마친 후에 타이머를 실행하도록 변경되었다.

timers

타이머 콜백은 setTimeout()setInterval()으로 예약된 콜백을 실행한다. 정확한 시각에 실행되는 것이 아니라 **최소 지연 시간(minimum delay)**을 보장하는 것이다. 지정된 시간에 최대한 빠르게 실행되겠지만 OS 스케줄링이나 다른 콜백에 의해 늦춰질 수 있다.

setTimeout 실제 지연 측정 결과

setTimeout(fn, 0)도 실제로는 1ms 이상 지연된다. 타이머의 실행 시점은 결국 Poll 단계가 제어하기 때문에, poll 단계에서 I/O 콜백이 오래 걸리면 timer 콜백도 그만큼 늦어질 수 있다. libuv가 최대 이벤트 처리 값을 두어 timer 단계에 도달하지 못하는 문제를 방지한다.

pending callbacks

I/O 콜백과 같은 것은 poll 단계에서 실행되고 각 I/O 콜백에서 시스템 오류와 같이 바로 실행하고 싶지 않은 콜백은 이 단계에서 실행한다.

e.g. TCP 연결 시 ECONNREFUSED 에러 발생 시 바로 콜백을 실행하고 싶지 않기에 pending callbacks 단계에서 실행된다.

poll

  1. I/O 폴링 시간 계산 (Calculating how long to block): 이벤트 루프가 얼마나 오랫동안 I/O를 기다리며 멈춰있을지(Block) 계산
  2. 이벤트 처리 (Processing events): Poll 큐(Queue) 에 있는 콜백들을 순차적으로 실행

상황 A: Poll 큐에 콜백이 있는 경우 (비어있지 않음)

  • 큐가 빌 때까지 또는 **시스템별 실행 한도(hard limit)**에 도달할 때까지 콜백을 동기적으로 실행
  • 이 과정은 단순한 FIFO(선입선출) 처리

상황 B: Poll 큐에 콜백이 없는 경우 (비어있음)

  • setImmediate() 예약되어 있는 경우:

    • Poll 단계를 즉시 종료하고 Check 단계로 이동하여 setImmediate 콜백을 실행
  • setImmediate() 없는 경우:

    • 이벤트 루프는 새로운 I/O 콜백이 큐에 들어올 때까지 대기(Block)
    • 이때 무한정 기다리는 것이 아니라, **가장 빨리 도래하는 타이머의 시간(Timer Threshold)**까지만 기다림

check

poll 단계가 끝난 후 setImmediate() 콜백을 실행한다. poll 단계에서 들어오는 연결, 요청 등을 대기하다 poll queue가 비어있고 setImmediate() 콜백이 있다면 check 단계로 넘어가 즉시 실행한다.

close callbacks

소켓이나 핸들이 갑자기 종료되면 close 이벤트가 발생한다. 그렇지 않으면 process.nextTick()를 통해 발생된다.

setImmediate() vs setTimeout()

setImmediate()setTimeout()은 비슷해 보이지만 호출 시점에 따라 동작이 다르다.

setTimeout()은 timer 단계에서 실행되며 최소 지연 시간을 보장할 뿐, 정확한 시각에 실행되지 않는다. 반면 setImmediate()은 poll 단계가 끝난 후 check 단계에서 즉시 실행된다.

메인 모듈에서 둘을 함께 호출하면 실행 순서가 비결정적이다. setTimeout(fn, 0)은 내부적으로 1ms로 클램핑되는데, timer 단계 진입 시점에 1ms가 경과했느냐에 따라 순서가 갈린다.

setImmediate vs setTimeout 실행 결과

하지만 I/O 콜백 안에서는 이미 poll 단계에 있으므로, check 단계(setImmediate)가 다음 루프의 timer 단계(setTimeout)보다 항상 먼저 실행된다.

process.nextTick()

동작 원리와 주의점

process.nextTick() 은 현재 작업이 완료된 후 바로 실행된다. 이벤트 루프의 단계(Phase)에 속하지 않으며, C/C++ 핸들러에서 JavaScript로 전환되는 시점에 실행된다.

만약 재귀적으로 process.nextTick()이 실행되면 I/O 처리가 지연되어 이벤트 루프에서 폴링 단계에 도달하지 못한다.

활용 사례

process.nextTick()은 콜 스택(call stack)이 전부 비워진 후 실행된다. 이를 활용하면 비동기를 의도했지만 동기적으로 작성된 함수를 비동기로 실행되게끔 할 수 있다.

let bar = null;

// this has an asynchronous signature, but calls callback synchronously
function someAsyncApiCall(callback) {
  callback();
}

// the callback is called before `someAsyncApiCall` completes.
someAsyncApiCall(() => {
  // since someAsyncApiCall hasn't completed, bar hasn't been assigned any value
  console.log('bar', bar); // null
});

bar = 1;

process.nextTick() 적용 후

let bar = null;

function someAsyncApiCall(callback) {
  process.nextTick(callback);
}

someAsyncApiCall(() => {
  console.log('bar', bar); // 1
});

bar = 1;

그럼 실제 우리가 사용하는 코드에서는?

const server = net.createServer(() => {}).listen(8080);

server.on('listening', () => {});

const server = net.createServer(() => {}).listen(8080);에서 listen이 실행되면 어떻게 하나? 해당 함수에 대한 아무런 콜백을 지정하기 전에 실행된다면 우리는 정보를 놓칠 수 있다.

그래서 server.on... 이 실행되는 것을 기다리기 위해 listenprocess.nextTick()으로 보장해준다.

process.nextTick() vs setImmediate()

  • process.nextTick(): 같은 단계에서 즉시 실행된다
  • setImmediate(): 이벤트의 다음 루프 단계 혹은 다음 ‘tick’ 에 실행된다.

사용하는 이유

공식 문서에서는 크게 두 가지 이유를 제시한다.

  1. 이벤트 루프가 계속 진행되기 전에 에러 처리, 불필요한 리소스 정리, 요청 재시도 등을 할 수 있게 해준다.
  2. 콜 스택이 해제(unwound) 된 후, 이벤트 루프가 계속 진행되기 전에 콜백을 실행해야 하는 경우가 있다.

Microtask vs Macrotask

Event loop의 각 단계의 작업을 Macrotask라 하고 각 단계에서 Microtask가 끝나야 다음 단계로 넘어간다.

Microtask:

실행 순서 / 우선순위

Microtask는 이벤트 루프의 각 단계 사이에서 실행된다. 그 중에서도 process.nextTick()이 Promise 콜백보다 먼저 실행된다.

Microtask는 Macrotask 보다 우선순위가 높다.

현재 단계의 콜백 실행 완료
    → process.nextTick() 큐 전부 비우기
    → Promise microtask 큐 전부 비우기
    → 다음 단계로 이동

References