개발자공부일기
Node.js의 이벤트 루프 본문
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) 실행
왜 이런 실행 순서가 나올까?
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 |