본문 바로가기

Project/Node.js

내가 아는 Node.js -2

Event Loop

Node.js는 싱글 스레드 기반의 JavaScript 런타임입니다. 그러나 단일 스레드라는 제약에도 불구하고, 수천 개의 네트워크 요청이나 파일 I/O 작업을 동시에 처리할 수 있는 고성능 서버 애플리케이션을 개발할 수 있습니다. 그 중심에는 바로 Event Loop라는 메커니즘이 존재합니다. Node.js에서 Event Loop는 libuv 라는 C 기반 라이브러리를 통해 구현되며, 이는 이벤트 기반 비동기 I/O를 가능하게 해줍니다.

이벤트 루프는 자바스크립트가 단일 스레드에서 동작하는 언어임에도 높은 동시성을 제공할 수 있도록 설계된 핵심 구조입니다. I/O 작업, 타이머, 네트워크 요청, 파일 시스템 접근과 같은 작업이 비동기로 이루어질 수 있도록 지원하며, 작업 완료 후 등록된 콜백을 적절한 타이밍에 실행합니다. 이는 Node.js의 비동기 프로그래밍 모델의 본질이기도 합니다.

Event Loop는 어떤 단계를 거치는가?

Node.js의 Event Loop는 하나의 Tick 이라 불리는 루프 주기에서 여러 단계를 순차적으로 거치게됩니다. 각 단계는 특정한 타입의 콜백이나 이벤트를 처리합니다. 다음 단계로 넘어가기 전에 큐에 등록된 콜백들을 일정 조건까지 실행하게 됩니다. 공식 문서 기준으로 설명하면 다음과 같은 단계가 존재합니다.

(1) Timers Phase

이 단계에서는 setTimeout과 setInterval에 의해 예약된 콜백들이 실행됩니다. 중요한 점은 이 타이머 콜백들은 “정확한 시간 후”가 아닌 “최소 지연 시간(minimum threshold)이 지난 후” 실행된다는 것입니다. 예를 들어 setTimeout(fn, 0)으로 등록한 함수도 Event Loop가 해당 phase에 도달할 때까지는 실행되지 않습니다. 즉, 임계점을 넘었다면 실행된다고 보면 됩니다.

왜냐하면 Event Loop는 각 phase마다 큐를 순회하며 처리하고, 각 큐의 모든 작업을 비우거나 지정된 최대 콜백 실행 횟수에 도달할 때까지 머물 수 있기 때문입니다. 따라서 setTimeout으로 지정한 시간이 지났더라도 다른 phase에서 대기 중인 작업량이 많다면 실제 실행 시점은 지연될 수 있습니다. 이처럼 정확한 타이머 동작을 보장하지는 않는다는 점은 많은 개발자의 오해를 불러 일으킵니다.

(2) Pending Callbacks Phase

이 단계는 TCP 오류 처리와 같은 시스템 수준 콜백이 실행되는 시점입니다. 예를 들어 listen()이나 connect() 요청 이후 비동기적으로 발생한 오류에 대한 콜백 등이 여기에 포함됩니다. 일반적인 애플리케이션 코드에서는 자주 드러나지 않지만, 네트워크 레이어에서 중요한 역할을 합니다.

(3) Idle / Prepare Phase

이 단계는 libuv 내부에서만 사용되는 예약 단계로, 일반적인 애플리케이션 코드와는 직접적으로 관련되지 않습니다. 이벤트 루프 내부에서 다음 단계인 Poll을 준비하거나 내부 상태를 관리하는 데 활용됩니다.

(4) Poll Phase

여기서는 대부분의 I/O 작업이 처리됩니다. 파일 읽기/쓰기, 네트워크 요청 등 완료된 작업의 콜백이 이 단계에서 실행됩니다. 만약 I/O 이벤트가 없다면, Event Loop는 이 단계에서 일정 시간 대기하거나, 콜백이 존재하지 않는다면 다음 단계로 바로 이동합니다.

특히 fs.readFile()과 같은 작업은 이 phase에서 완료 후 콜백이 호출됩니다. 이러한 구조 덕분에 Node.js는 블로킹 없이 수많은 I/O를 효율적으로 처리할 수 있습니다.

(5) Check Phase

이 단계는 setImmediate()로 예약된 콜백을 실행합니다. 이 함수는 Poll phase가 완료된 직후에 실행되므로, 때에 따라 setTimeout(fn, 0)보다 먼저 실행될 수 있습니다. 이 동작은 매우 중요한 차이를 만들어내는데, 이에 대해서는 아래에서 다시 자세히 설명하겠습니다.

(6) Close Callbacks Phase

마지막 단계에서는 socket.on('close', ...), process.on('exit', ...)와 같이 종료 관련 콜백들이 실행됩니다. 예를 들어 socket.destroy()나 server.close() 호출 이후 이 단계에서 콜백이 실행되는 방식입니다.

setTimeout() vs setImmediate()

둘의 차이는 스펠링에 차이도 있지만, 둘중 무엇이 먼저 실행될지 예측할 수 없습니다. setImmediate가 즉시 실행되는것으로 착각을 일으킬 수 있지만 실제로는 둘은 환경에 따라 차이가 발생합니다.

실제 동작 시점은 Event Loop의 phase에 따라 달라집니다.

setTimeout(fn, 0)은 Timers Phase에서, setImmediate()는 Check Phase에서 실행됩니다. 만약 타이머 큐가 비어있다면 Poll을 거쳐 바로 Check 단계로 넘어가며, 이 경우 setImmediate()가 더 먼저 실행될 수 있습니다.

중요한 것은, 이 순서가 항상 보장되지 않는다는 점입니다. 실행 순서는 호출 시점이 아닌 Event Loop의 상태에 따라 달라질 수 있으며, OS나 Node.js 버전, 시스템 환경 등에 따라 다르게 나타날 수 있습니다. 공식 문서에서도 이를 명시하고 있으며, “실험에 의존하지 말고 의도적으로 순서를 제어하려면 명확하게 I/O 작업을 활용하라”고 제안합니다.

예를 들어 다음과 같은 코드를 보겠습니다.

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});

이 경우 setImmediate가 항상 setTimeout보다 먼저 실행됩니다. 이유는 readFile()이 완료된 후 Poll 단계가 끝난 시점에서 Check 단계로 바로 넘어가기 때문입니다. 이처럼 명시적인 I/O 작업 안에서 두 콜백을 비교하면, Event Loop 동작 순서를 예측 가능하게 만들 수 있습니다.

이제 위 내용을 어느정도 이해를 했다면, 예측해서 setTimeout, setImmediate를 사용할 순 없습니다. 그래서,

process.nextTick() - Event Loop를 건너뛰는 특수한 큐

Node.js에서 비동기 작업을 제어할 때 흔히 사용되는 API로 setTimeout, setImmediate 등이 있지만, 그 외에 또 하나의 중요한 메커니즘이 있습니다. 바로 process.nextTick()입니다.

처음 접할 땐 단순히 "다음 Event Loop에 실행된다"는 설명으로 이해하기 쉽지만, 실제로는 Event Loop의 동작 흐름과는 조금 다른, 별도의 우선순위를 갖는 특별한 큐를 통해 동작한다는 점이 핵심입니다.

process.nextTick()의 실행 시점

process.nextTick()은 현재 실행 중인 작업이 모두 완료된 직후, 즉 현재 Tick의 마무리 시점에 등록된 콜백을 실행합니다. 중요한 건 Event Loop의 다음 phase로 넘어가기 전에 실행된다는 점입니다.

console.log('start');

process.nextTick(() => {
  console.log('nextTick callback');
});

console.log('end');

출력 결과

start
end
nextTick callback

이 예시에서 process.nextTick()은 console.log('end')가 끝난 직후 실행됩니다. Event Loop의 어떤 phase도 거치지 않고, 현재 Tick을 벗어나지 않은 상태에서 실행된다는 점이 setTimeout이나 setImmediate와 다릅니다.

nextTick Queue - Event Loop 바깥의 우선순위 큐

앞서 Event Loop는 Timers → Pending → Poll → Check 등의 phase를 순차적으로 거친다고 설명드렸습니다. 그런데 process.nextTick()은 이 흐름에 포함되지 않습니다. libuv에서 제공하는 Event Loop 큐와는 별도로, Node.js 내부에서 관리되는 nextTick 큐를 따로 두고 있습니다.

이 큐는 모든 phase보다 우선순위가 높기 때문에, 어떤 phase에서 실행되었든 간에 해당 작업이 끝나고 나면 즉시 nextTick 큐를 확인하고 콜백을 처리하게 됩니다.

이 때문에 process.nextTick()을 남용할 경우, 일반적인 Event Loop 흐름이 막히게 되어 예상치 못한 부작용이 발생할 수 있습니다.

무한 루프

function repeat() {
  process.nextTick(repeat);
}

repeat();

위 코드는 무한히 nextTick 큐에 자신을 등록하기 때문에, Event Loop의 다른 phase로는 절대 넘어가지 못하고 프로그램이 멈춘 것처럼 보이게 됩니다. 심지어 setTimeout, setImmediate는 아예 실행되지도 못합니다.

이처럼 process.nextTick()은 Event Loop의 흐름을 완전히 우회하는 강력한 도구이므로, 사용 시 반드시 의도한 위치에서만 정확히 한정해서 사용해야 합니다.

아래 예시 코드를 통해서 조금은 파악할 수 있을 것이라 생각됩니다.

setTimeout vs setImmediate vs process.nextTick – 실행 순서 비교 예제

const fs = require('fs');

console.log('start');

setTimeout(() => {
  console.log('setTimeout');
}, 0);

setImmediate(() => {
  console.log('setImmediate');
});

process.nextTick(() => {
  console.log('nextTick');
});

fs.readFile(__filename, () => {
  console.log('fs.readFile');

  setTimeout(() => {
    console.log('setTimeout inside fs.readFile');
  }, 0);

  setImmediate(() => {
    console.log('setImmediate inside fs.readFile');
  });

  process.nextTick(() => {
    console.log('nextTick inside fs.readFile');
  });
});

console.log('end');
start
end
nextTick
setTimeout
setImmediate
fs.readFile
nextTick inside fs.readFile
setImmediate inside fs.readFile
setTimeout inside fs.readFile

해석

처음에는 동기 코드가 먼저 실행됩니다. 위에서는 start, end가 되겠네요 동기적으로 먼저 Event Loop에서 처리가 되빈다. 그 이후 동기 코드가 전부 종료가 되고 나머지는 비동기로 처리되고 있는 상황일 것입니다.

하지만 Event Loop를 완벽하게 우회하는 process.nextTick()이 동작을 하게 되겠죠? nextTick은 어떤 phase보다 먼저 실행될 것입니다. 파일 읽기전 setTimeout, setImmediate중 어떤것이 먼저 실행될지 예측을 할 수 없습니다.

그래서 setTimeout과 setImmediate 이 두개는 현재 Event Loop의 상태에 따라 비결정적이라고 볼 수 있습니다. 위의 값이 무조건 정답이 아닐 수 있다는 것입니다.

I/O가 완료되고 I/O 처리는 libuv에서 진행되고, Poll Phase 이후 작업이 됩니다. Poll Phase 이후에 Check가 존재하기 때문에 nextTick이후 setImmediate가 동작하게 되고 그 이후 Timer인 setTimeout이 동작하게 될 것입니다. 

결론

비동기 API를 사용할 때 실행 순서를 보장해야 하는 경우라면, 각 API가 속한 Event Loop Phase를 정확히 파악하고, fs.readFile 등의 I/O 작업을 명시적으로 활용해서 Phase 간 우선순위를 유도하는 방법이 가장 안전합니다.

이 글은 단순히 “뭐가 먼저 실행된다”는 식의 암기보다는, Node.js가 어떻게 Event Loop와 큐를 구성하고 실행 순서를 결정하는지에 대한 원리를 기반으로 설명한 것입니다.

'Project > Node.js' 카테고리의 다른 글

내가 아는 Node.js -4  (0) 2025.05.28
내가 아는 Node.js -3  (0) 2025.05.28
내가 아는 Node.js -1  (0) 2025.05.28
Node.js 시스템에서 비동기 대기 처리(Queueing)의 필요성  (0) 2025.05.23
Node.js의 GC(Garbage Collection)  (1) 2025.05.23