개발자공부일기

Node.js의 이벤트 루프 본문

Language/Javascript

Node.js의 이벤트 루프

JavaCPP 2025. 2. 20. 21:30

Node.js Event Loop

흔히 Node.js를 싱글 스레드 논 블로킹이라고 한다. Node.js는 하나의 스레드로 동작하지만 I/O 작업이 발생한 경우 이를 비동기적으로 처리할 수 있다. 분명 하나의 스레드는 하나의 실행 흐름만을 가지고 있고 파일 읽기와 같이 기다려야 하는 작업을 실행하면 그 작업이 끝나기 전에는 아무것도 할 수 없어야만 한다. 그러나 Node.js는 하나의 스레드만으로 여러 비동기 작업들을 블로킹 없이 수행할 수 있고 그 기반에는 이벤트 루프가 존재한다.

 

Nods.js 속 이벤트 루프

이벤트 루프 Node.js가 여러 비동기 작업을 관리하기 위한 구현체다. console.log("Hello World")와 같은 동기 작업이 아니라 file.readFile('test.txt', callback)과 같은 비동기 작업들을 모아서 관리하고 순서대로 실행할 수 있게 해주는 도구이며 위와 같이 구성되어있다.

우선 점선으로 된 nextTickQueue와 microTaskQueue는 이벤트 루프의 일부가 아니다. 따라서 아래에서 설명하는 내용에 해당되지 않는다. 비록 이벤트 루프를 구성하지는 않지만 Node.js의 비동기 작업 관리를 도와주는 것들로 아래에서 더 자세하게 다룬다.

 박스는 특정 작업을 수행하기 위한 페이즈(Phase)를 의미한다.그리고 Node.js의 이벤트 루프는

 

1.Timers Phase (타이머 처리 단계)

  • setTimeout()과 setInterval() 같은 타이머의 콜백이 실행되는 단계
  • 타이머의 시간이 만료된 후 실행됨

2.Pending Callbacks Phase (대기 중인 콜백 처리 단계)

  • I/O 작업의 일부 콜백이 실행됨

3.Idle, Prepare Phase (대기 및 준비 단계 - 내부 최적화 작업)

  • Node.js 내부에서 사용되며, 일반적으로 신경 쓰지 않아도 됨

4.Poll Phase (I/O 이벤트 처리 단계 - 가장 중요한 단계!)

  • 비동기 작업(예: 파일 읽기, 네트워크 요청)의 콜백이 실행되는 단계
  • 새로운 이벤트가 없으면 대기 상태가 됨

5.Check Phase (setImmediate() 실행 단계)

  • setImmediate()로 등록된 콜백이 실행되는 단계
  • setImmediate()는 setTimeout(0)보다 먼저 실행됨

6.Close Callbacks Phase (닫기 이벤트 처리 단계)

  • 소켓이나 핸들(예: process.on('exit'))이 닫힐 때 실행됨

로 구성되어있다.

페이즈 전환 순서 또한 그림에 나타난 것처럼 Timer Phase -> Pending Callbacks Phase -> Idle, Prepare Phase -> Poll Phase -> Check Phase -> Close Callbacks Phase -> Timer Phase 순을 따른다. 이렇게 한 페이즈에서 다음 페이즈로 넘어가는 것을 (Tick)이라고 부른다.

 페이즈는 자신만의  하나씩 가지고 있는데, 이 큐에는 이벤트 루프가 실행해야 하는 작업들이 순서대로 담겨있다. Node.js가 페이즈에 진입을 하면 이 큐에서 자바스크립트 코드(예를 들면 콜백)를 꺼내서 하나씩 실행한다. 만약 큐에 있는 작업들을 다 실행하거나, 시스템의 실행 한도에 다다르면 Node.js는 다음 페이즈로 넘어간다.

 

이벤트 루프 실행 과정 예제

다음 코드를 실행했을 때, 어떤 순서로 실행될까요?

const fs = require('fs');

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

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

fs.readFile(__filename, () => {
  console.log('파일 읽기 완료');

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

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

  setImmediate(() => {
    console.log('setImmediate(파일 읽기 후) 실행');
  });
});

process.nextTick(() => {
  console.log('nextTick(최초) 실행');
});

console.log('동기 코드 실행');

 

예상 실행 순서 분석

동기 코드 실행  
nextTick(최초) 실행  
setImmediate 실행  
setTimeout 실행  
파일 읽기 완료  
nextTick 실행  
setImmediate(파일 읽기 후) 실행  
setTimeout(0) 실행

Try it online!

왜 이런 실행 순서가 나올까?

1.동기 코드 실행

  • console.log('동기 코드 실행');은 가장 먼저 실행됨 (동기 코드이므로)

2.Microtask Queue (process.nextTick) 실행

  • process.nextTick(() => { console.log('nextTick(최초) 실행'); })
  • nextTick()은 모든 이벤트 루프 단계보다 우선 실행되므로 바로 실행됨

3.Timers Phase (setTimeout)

  • setTimeout(() => { console.log('setTimeout 실행'); }, 0);
  • 타이머는 0초여도 다음 이벤트 루프에서 실행되므로 뒤로 밀림

4.Check Phase (setImmediate())

  • setImmediate(() => { console.log('setImmediate 실행'); });
  • Check Phase에서 실행되므로 setTimeout(0)보다 먼저 실행됨

5.Poll Phase (파일 읽기 완료 후 실행)

  • fs.readFile(__filename, () => { console.log('파일 읽기 완료'); });
  • I/O 작업이 완료되었으므로 Poll Phase에서 실행됨

6.Microtask Queue (파일 읽기 후 nextTick()) 실행

  • process.nextTick(() => { console.log('nextTick 실행'); });
  • nextTick()은 모든 단계보다 우선 실행되므로 즉시 실행됨

7.Check Phase (파일 읽기 후 setImmediate())

  • setImmediate(() => { console.log('setImmediate(파일 읽기 후) 실행'); });
  • Check Phase에서 실행됨

8.Timers Phase (setTimeout(0))

  • setTimeout(() => { console.log('setTimeout(0) 실행'); }, 0);
  • Timers Phase에서 실행됨

 이벤트 루프 실행 순서 요약 

1.동기 코드 → 항상 즉시 실행
2.Microtask Queue(process.nextTick, Promise.then) → 모든 이벤트 루프 단계보다 우선 실행
3.Timers Phase(setTimeout, setInterval) → 정해진 시간이 지나야 실행
4.Poll Phase → 비동기 I/O 완료 후 실행
5.Check Phase → setImmediate() 실행 (Poll이 끝나면 바로 실행됨)

 

 

이벤트 루프에서 한 페이즈에 영원히 갇히지 않는 이유

이벤트 루프는 여러 개의 페이즈(Phase)를 반복적으로 실행하며, 각 페이즈에서 특정한 작업들을 처리합니다.

하지만 각 페이즈에서 새로운 작업이 계속 스케줄링되면 어떻게 될까요?
극단적인 경우, 한 페이즈에서 작업이 끝나지 않고 계속 새로운 작업이 추가되는 현상이 발생할 수도 있습니다.

 

하지만 Node.js 이벤트 루프는 한 페이즈에서 영원히 멈춰 있는 일이 발생하지 않습니다.
그 이유는?

  • 각 페이즈에는 실행 한도(Execution Threshold)가 있어서, 너무 많은 작업이 쌓이면 일정 부분만 처리하고 다음 페이즈로 넘어갑니다.
  • 이렇게 함으로써 이벤트 루프가 항상 다음 단계로 진행할 수 있도록 보장됩니다.

예제: 한 페이즈에서 끝없이 새로운 작업을 추가하는 경우

setImmediate(function run() {
  console.log('setImmediate 실행');
  setImmediate(run); // 계속해서 setImmediate()을 추가
});

이 코드는 setImmediate() 내부에서 또다시 setImmediate()를 호출하면서, Check Phase에서 무한 실행되도록 합니다.

하지만, 시스템의 실행 한도 때문에 Node.js는 이 작업을 무한히 실행하지 않고 적절한 시점에 다음 단계로 넘어갑니다.
즉, Check Phase가 계속 실행되더라도 일정 시간이 지나면 다른 페이즈(Timers, Poll 등)로 이동하게 됩니다.

 

예제: Microtask Queue가 계속 쌓이는 경우

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

infiniteNextTick();

 process.nextTick()은 이벤트 루프의 모든 단계보다 우선 실행되기 때문에,
위 코드처럼 process.nextTick()을 재귀적으로 계속 실행하면 이벤트 루프가 다음 단계로 이동하지 못하는 문제가 발생할 수 있습니다.

하지만, Node.js는 이런 상황을 감지하고 경고(RangeError: Maximum call stack size exceeded 등)를 발생시켜 무한 루프를 방지합니다.

결론

- 각 페이즈에서 실행 가능한 최대 작업량이 제한됨
- 한 페이즈에서 너무 많은 작업이 발생하면 적절한 시점에 다음 페이즈로 이동
- Microtask Queue(process.nextTick(), Promise.then())는 주의해야 하지만, 시스템이 무한 실행을 감지할 수 있음

- 이 덕분에 이벤트 루프는 항상 다음 단계로 진행하며, 한 페이즈에서 영원히 멈추는 일은 발생하지 않습니다. 

'Language > Javascript' 카테고리의 다른 글

Node.js의 Libuv 라이브러리  (0) 2025.02.26
JWT  (0) 2025.02.11
깊은 복사와 얕은 복사  (0) 2025.02.10
Express  (0) 2025.02.07
Arrow Function(화살표 함수)  (0) 2025.02.05