Node.js 개발자라면 누구나 "비동기 논블로킹"이라는 말을 들어봤을 것이다. 다만 대부분의 개발자들은 이벤트 루프가 정확히 어떻게 동작하는지, 왜 어떤 콜백은 먼저 실행되고 어떤 콜백은 나중에 실행되는지 정확히 알지 못한 채 코드를 작성한다. 그러다 보니 예상 밖의 타이밍 이슈나 성능 병목이 생겨도 원인을 파악하기 어렵다. 이번에는 Node.js 이벤트 루프의 구조, 마이크로태스크 큐와 매크로태스크 큐의 차이, 그리고 실전에서 바로 써먹을 수 있는 성능 최적화 방법을 완벽하게 정리해서 소개하겠다.
간단히 말해, 이벤트 루프는 "지금 실행할 게 없으면 큐에서 다음 작업을 가져와서 실행해"라는 뜻이다. 비동기 함수(예: setTimeout, fs.readFile, http.request)를 호출하면 그 작업은 Node.js의 libuv 라이브러리가 백그라운드 스레드 풀에서 처리하고, 작업이 끝나면 콜백을 태스크 큐에 집어넣는다. 이벤트 루프가 이를 감지해서 실행하는 구조다.
setTimeout, setInterval 콜백이 실행되는 단계다. 타이머의 지정된 시간이 경과하면 큐에 들어간 콜백들이 이 단계에서 처리된다.
이전 이벤트 루프 반복에서 연기된 I/O 콜백을 처리한다. TCP 소켓 에러 콜백이나 일부 시스템 작업 콜백이 여기에 해당한다.
내부용으로, 일반 개발자는 신경 쓸 필요가 없다.
가장 중요한 단계다. fs.readFile, 네트워크 요청 등 대부분의 I/O 콜백이 실행된다. 만약 poll 큐에 콜백이 남아 있으면 타이머 단계로 넘어가지 않고 계속 대기한다. 즉, poll 단계에서 블로킹되면 setTimeout이 늦게 실행될 수 있다.
setImmediate 콜백이 실행되는 단계다. poll 단계가 끝나자마자 실행된다.
socket.destroy()나 server.close() 같은 close 이벤트의 콜백이 실행된다.
마이크로태스크(Microtask)는 Promise, process.nextTick, queueMicrotask 콜백이 들어가는 특수한 큐다. 마이크로태스크 큐는 매크로태스크 큐보다 먼저 처리된다. 즉, 각 이벤트 루프 단계가 끝난 직후 마이크로태스크 큐가 완전히 비워질 때까지 다음 단계로 넘어가지 않는다.
매크로태스크(Macrotask)는 setTimeout, setInterval, setImmediate, I/O 콜백 같은 일반적인 비동기 작업이다. 매크로태스크는 하나씩 처리되고, 각 매크로태스크 사이사이에 마이크로태스크 큐가 완전히 처리된다.
console.log('시작');
setTimeout(() => {
console.log('setTimeout');
}, 0);
Promise.resolve()
.then(() => {
console.log('Promise 1');
})
.then(() => {
console.log('Promise 2');
});
process.nextTick(() => {
console.log('nextTick');
});
console.log('끝');
✓ 올바른 실행 순서(예상 결과)
이 코드를 실행하면 어떤 순서로 출력될까? 많은 개발자들이 setTimeout이 먼저 나올 거라고 생각하지만, 실제로는 이렇다.
시작
끝
nextTick
Promise 1
Promise 2
setTimeout
왜 이런 순서가 될까? process.nextTick과 Promise는 모두 마이크로태스크이고, setTimeout은 매크로태스크이기 때문이다. 콜 스택에서 "시작"과 "끝"이 동기적으로 먼저 출력되고, 그 다음 마이크로태스크 큐(nextTick, Promise들)가 모두 처리된 후에야 매크로태스크인 setTimeout이 실행된다.
| 함수 | 이벤트 루프 단계 | 특징 |
|---|---|---|
| setTimeout(fn, 0) | timers 단계 | 최소 1ms의 지연이 있고, 최소 4ms 이상 지연될 수 있음(브라우저 제한) |
| setImmediate(fn) | check 단계 | poll 단계 직후 바로 실행되므로 더 빠름 |
setImmediate(() => console.log('setImmediate'));
setTimeout(() => console.log('setTimeout'), 0);
✓ 결과
setImmediate
setTimeout
setImmediate가 먼저 출력된다. poll 단계가 비어 있으면 check 단계(setImmediate)가 timers 단계(setTimeout)보다 먼저 실행되기 때문이다. 다만 I/O 작업 내부에서 이들을 호출하면 순서가 다를 수 있다.
const fs = require('fs');
console.log('A');
fs.readFile(__filename, () => {
console.log('B - fs.readFile 콜백');
setTimeout(() => console.log('C - fs 내부 setTimeout'), 0);
setImmediate(() => console.log('D - fs 내부 setImmediate'));
});
setTimeout(() => {
console.log('E - 최상위 setTimeout');
}, 0);
setImmediate(() => {
console.log('F - 최상위 setImmediate');
});
console.log('G');
✓ 실행 결과
A
G
E - 최상위 setTimeout
F - 최상위 setImmediate
B - fs.readFile 콜백
D - fs 내부 setImmediate
C - fs 내부 setTimeout
왜 이렇게 될까? 순서를 따라가보자.
1. 동기 코드("A", "G")가 먼저 실행된다.
2. 이벤트 루프가 timers 단계로 간다. 최상위 setTimeout("E")이 실행된다.
3. check 단계로 간다. 최상위 setImmediate("F")이 실행된다.
4. poll 단계로 돌아오면 fs.readFile 작업이 완료되어 "B" 콜백이 실행된다.
5. fs 콜백 내부의 setImmediate("D")는 check 큐에 들어가고, setTimeout("C")은 timers 큐에 들어간다.
6. poll 단계가 끝나면 check 단계로 가서 "D"를 실행한다.
7. 다음 루프의 timers 단계에서 "C"를 실행한다.
✗ 문제 있는 코드
const fs = require('fs');
setTimeout(() => {
console.log('타이머 실행됨');
}, 100);
fs.readFile('./large-file.json', 'utf8', (err, data) => {
// 매우 무거운 파싱 작업 - 150ms 소요
const parsed = JSON.parse(data);
console.log('파일 파싱 완료');
});
"타이머 실행됨"이 100ms 후에 나올 거라고 생각했다면 틀렸다. 파일 읽기가 끝나서 콜백이 poll 단계에서 처리되는데, 그 콜백 내부의 JSON.parse가 150ms 걸리면, 총 이벤트 루프 사이클이 150ms 이상 걸린다. 따라서 타이머는 예상보다 훨씬 늦게 실행된다.
✓ 해결책: Heavy 작업을 비동기로 분리
const fs = require('fs');
setTimeout(() => {
console.log('타이머 실행됨');
}, 100);
fs.readFile('./large-file.json', 'utf8', (err, data) => {
// 파싱을 setImmediate로 미룬다
setImmediate(() => {
const parsed = JSON.parse(data);
console.log('파일 파싱 완료');
});
});
이렇게 하면 poll 단계에서는 빠르게 빠져나가고, 타이머도 정시에 실행되며, 파서 작업은 그 다음 사이클에서 실행된다.
마이크로태스크는 매 매크로태스크마다 실행되기 때문에, 만약 큰 작업을 마이크로태스크로 큐에 집어넣으면 다른 I/O 작업들이 지연될 수 있다. 따라서 CPU 집약적인 작업은 setImmediate로 미루는 것이 낫다.
// ✗ 나쁜 예: 마이크로태스크에서 무거운 작업
for (let i = 0; i < 1000000; i++) {
queueMicrotask(() => {
// 복잡한 계산
});
}
// ✓ 좋은 예: setImmediate로 분산
for (let i = 0; i < 1000000; i++) {
setImmediate(() => {
// 복잡한 계산
});
}
setImmediate를 사용하면 이벤트 루프의 다른 단계들(I/O, 타이머)이 그 사이에 끼어들 수 있어서, 전체 시스템의 반응성이 훨씬 좋아진다.
이벤트 루프는 Node.js 성능의 핵심이다. 잘못 이해하면 예상 밖의 타이밍 이슈나 메모리 누수가 발생한다. 다음 체크리스트를 참고해서 코드를 점검하자.
• Promise와 setTimeout의 실행 순서 차이를 이해하는가? 마이크로태스크가 먼저 실행된다는 것을 잊지 말자.
• poll 단계에서 블로킹되면 타이머와 setImmediate 모두 지연된다. I/O 콜백 내부의 동기 작업을 최소화하자.
• 큰 작업을 여러 개의 setImmediate로 분산하면 다른 이벤트들이 끼어들 수 있다. CPU 집약적인 작업은 마이크로태스크 대신 setImmediate를 써라.
• process.nextTick은 매우 빠르지만, 무분별하게 쓰면 I/O 작업을 아사(starvation)시킬 수 있다.
• setTimeout(fn, 0)과 setImmediate(fn)는 다르다. 명확한 의도에 따라 골라 써야 한다.
이벤트 루프는 한 번 이해하면 Node.js의 성능 최적화와 버그 디버깅이 훨씬 수월해진다. 이 글의 실전 예제들을 직접 터미널에서 실행해보고, 각 단계에서 어떤 콜백이 실행되는지 체험하면, 더 깊은 이해가 될 것이다.