Node.js의 Event Loop: 작동 메커니즘과 실제 예제
1. 개요
Event Loop는 Node.js가 JavaScript 단일 스레드만 사용하면서도 수천 개의 요청을 동시에 처리할 수 있게 해주는 핵심 메커니즘입니다. 많은 개발자들이 매일 async/await, setTimeout, Promise를 작성하지만 실제 실행 순서가 어떻게 되는지 명확하지 않습니다.
이 글은 Event Loop의 구조를 구체적인 단계(phase)를 통해 설명하고, microtask와 macrotask를 구분하며, 실행 가능한 코드로 설명합니다. 이 글을 읽은 후에는 복잡한 비동기 코드의 로그 순서를 정확히 예측할 수 있고, event loop를 block하는 것과 같은 일반적인 오류를 피할 수 있습니다.
2. Node.js가 단일 스레드로 작동하는 방식
Node.js는 JavaScript를 주 스레드(main thread)에서 실행하지만, I/O 작업(파일 읽기, 네트워크 호출, DB 쿼리)은 libuv로 전달됩니다. libuv는 스레드 풀이나 커널 메커니즘(epoll, kqueue…)을 사용하여 비동기 I/O를 처리하는 C 라이브러리입니다.
┌───────────────────────────┐
│ JavaScript (단일 스레드) │
└──────────────┬────────────┘
│ fs.readFile, setTimeout, net... 호출
▼
┌───────────────────────────┐
│ libuv (C/C++) │
│ - 스레드 풀 │
│ - Event Loop │
│ - OS 비동기 API │
└───────────────────────────┘
I/O 작업이 완료되면, libuv는 콜백을 해당 큐에 넣습니다. Event Loop는 지속적으로 이 큐들을 확인하고 차례가 되면 콜백을 실행합니다. 이것이 “loop”입니다.
주의: 귀하의 JavaScript 코드는 항상 하나의 스레드에서 실행됩니다. 무거운 동기식 코드를 작성하면(예: 큰 계산 루프), 전체 event loop를 block하여 다른 모든 요청이 대기해야 합니다.
3. Event Loop의 단계들
Event Loop는 단순한 루프가 아니라 매번 반복(tick)할 때마다 정해진 순서로 6개의 단계를 거칩니다:
┌───────────────────────┐
┌─>│ timers │ setTimeout, setInterval
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ pending callbacks │ 지연된 I/O 콜백
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ idle, prepare │
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ poll │ I/O 이벤트 (파일 읽기, 네트워크)
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
│ │ check │ setImmediate
│ └──────────┬─────────────┘
│ ┌──────────▼─────────────┐
└──┤ close callbacks │ socket.on('close')
└─────────────────────────┘
3.1. 각 단계의 간단한 설명
- timers: 기한이 끝난
setTimeout/setInterval의 콜백을 실행합니다. - pending callbacks: 이전 주기에서 연기된 I/O 콜백을 실행합니다.
- poll: 새로운 I/O 이벤트를 기다리고 처리합니다(파일 읽기 완료, 네트워크 응답 수신 등). 이것은 가장 시간이 많이 걸리는 단계입니다.
- check:
setImmediate의 콜백을 실행합니다. - close callbacks: 종료 이벤트를 처리합니다(예:
socket.on('close', ...)).
각 단계에는 자신의 큐(queue)가 있습니다. Event Loop는 현재 단계의 큐에서 모든 콜백(또는 제한까지)을 처리한 후에만 다음 단계로 이동합니다.
4. Microtask Queue: Promise와 process.nextTick
위의 6개 macrotask 단계 외에도 Node.js는 microtask queue를 가지고 있으며, 이는 각 단계 사이에 처리됩니다. 전체 루프가 끝날 때까지 기다릴 필요가 없습니다.
두 가지 유형의 microtask이 있습니다:
process.nextTick()— 자체 큐로, 항상 다른 모든 microtask 이전에 처리됩니다.Promise.then/catch/finally— 표준 microtask 큐(ECMAScript 사양에 따름).
console.log('1 - sync start');
setTimeout(() => console.log('2 - setTimeout'), 0);
Promise.resolve().then(() => console.log('3 - promise'));
process.nextTick(() => console.log('4 - nextTick'));
console.log('5 - sync end');
설명:
- 동기식
console.log라인은 즉시 실행됩니다(1,5). setTimeout은 콜백을 timers 큐로 푸시합니다. 다음 단계에서 실행됩니다.process.nextTick과Promise.then은 모두 microtask이지만,nextTick이 더 높은 우선순위를 가집니다.- 동기식 코드 실행이 완료된 후, Node.js는
nextTick큐를 모두 비운 다음Promisemicrotask 큐를 모두 비운 후 timers 단계에 진입합니다.
1 - sync start
5 - sync end
4 - nextTick
3 - promise
2 - setTimeout
주의:
process.nextTick()을 재귀적으로 연속으로 호출하면(예: 한 콜백 내에서 다시nextTick을 호출), event loop는 절대 다음 단계로 진행하지 않습니다. 이를 “I/O starvation”이라고 합니다.
5. setTimeout vs setImmediate: 어느 순서가 맞을까?
이것은 가장 헷갈리는 질문입니다. setTimeout(fn, 0)과 setImmediate(fn) 사이의 순서는 top-level에서 호출하면 고정되지 않지만, I/O 콜백 내에서 호출하면 고정됩니다.
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
이 코드는 fs.readFile의 콜백 내에 있습니다. 즉, poll 단계에 있습니다. poll이 끝나면 Event Loop는 항상 새 루프가 timers 단계로 돌아오기 전에 check 단계(setImmediate)로 이동합니다.
immediate
timeout
반대로, 이 두 명령을 top-level에 배치하면(어떤 I/O 콜백도 아닌), 순서는 그 시점의 Node.js 시작 프로세스 성능에 따라 달라집니다. production 코드에서 이 순서에 의존하면 안 됩니다.
6. Event Loop 차단: 가장 흔한 오류
JavaScript를 처리하는 스레드가 하나뿐이기 때문에, 오래 실행되는 동기식 코드는 전체 서버를 차단합니다.
const express = require('express');
const app = express();
app.get('/fibonacci', (req, res) => {
function fib(n) {
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
res.send(`Result: ${fib(40)}`); // blocking!
});
app.listen(3000);
요청이 /fibonacci를 호출하면, fib(40) 함수는 동기적으로 실행되어 몇 초가 걸립니다. 그 동안 다른 모든 요청(간단한 ping이라도)은 대기해야 합니다. 왜냐하면 event loop가 하나의 작업에 갇혀 있기 때문입니다.
6.1. 해결책: CPU 집약적인 작업을 Worker Thread로 이동
const { Worker } = require('worker_threads');
app.get('/fibonacci', (req, res) => {
const worker = new Worker('./fib-worker.js', {
workerData: { n: 40 }
});
worker.on('message', (result) => res.send(`Result: ${result}`));
worker.on('error', (err) => res.status(500).send(err.message));
});
// fib-worker.js
const { workerData, parentPort } = require('worker_threads');
function fib(n) {
return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}
parentPort.postMessage(fib(workerData.n));
Worker는 자신의 스레드에서 실행되며 main thread를 차지하지 않습니다. 메인 요청의 event loop는 worker가 계산하는 동안에도 다른 요청을 처리할 수 있도록 자유롭습니다.
언제 Worker Thread가 필요 없는가: I/O 작업(DB 읽기, API 호출, 파일 읽기)의 경우 이미 비동기이므로 worker가 필요하지 않습니다. Worker Thread는 CPU 집약적인 작업(무거운 계산, 암호화, 이미지 처리 등)에만 유용합니다.
7. perf_hooks를 사용하여 Event Loop 지연 측정
production 환경에서 event loop가 block되었는지 감지하려면 내장 perf_hooks 모듈을 사용할 수 있습니다.
const { monitorEventLoopDelay } = require('perf_hooks');
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
setInterval(() => {
console.log(`Event loop delay (mean): ${histogram.mean / 1e6} ms`);
histogram.reset();
}, 5000);
monitorEventLoopDelay는 ideal schedule과 비교하여 event loop가 “지연”되는 시간을 측정하는 히스토그램을 생성합니다. mean 값이 비정상적으로 높으면(예: 100ms 이상), 어딘가에 blocking code가 있다는 신호입니다.
8. 실행 순서를 확인하는 테스트 작성
microtask/macrotask 순서를 올바르게 이해했는지 확인하려면, 로그를 배열에 기록한 후 순서를 assert하는 테스트를 작성할 수 있습니다.
// order.js
function recordOrder(log) {
log.push('sync-start');
setTimeout(() => log.push('timeout'), 0);
Promise.resolve().then(() => log.push('promise'));
process.nextTick(() => log.push('next-tick'));
log.push('sync-end');
}
module.exports = { recordOrder };
// order.test.js
const { recordOrder } = require('./order');
test('실행 순서: sync -> nextTick -> promise -> timeout', (done) => {
const log = [];
recordOrder(log);
// 동기식 코드는 즉시 실행 완료
expect(log).toEqual(['sync-start', 'sync-end']);
setImmediate(() => {
expect(log).toEqual([
'sync-start',
'sync-end',
'next-tick',
'promise',
'timeout',
]);
done();
});
});
이 테스트는 Jest와 setImmediate를 사용하여 모든 microtask와 timers 단계가 실행될 때까지 기다린 후, log의 최종 순서를 assert합니다. 이것이 눈으로만 읽는 것이 아니라 event loop 이해를 정확하게 검증하는 방법입니다.
npx jest order.test.js
PASS ./order.test.js
✓ 실행 순서: sync -> nextTick -> promise -> timeout (5 ms)
9. 일반적인 오류
process.nextTick을 재귀적으로 과도하게 사용: I/O starvation을 일으켜 서버가 새 요청을 처리할 수 없게 됩니다.setTimeout(fn, 0)이 즉시 실행된다고 생각: 실제로는 최소 하나의 event loop tick을 기다리고, 모든 microtask 이후에 실행됩니다.- Main thread에서 CPU 집약적인 코드 실행: 예를 들어 대용량 JSON 파싱, 높은 비용의 bcrypt 암호화, 동기식 이미지 처리 등은 모두 Worker Thread 또는 자식 프로세스로 옮겨야 합니다.
- Top-level에서
setTimeoutvssetImmediate순서를 가정: 신뢰할 수 없으므로, 이에 의존하는 로직을 작성하지 마세요.
주의해야 할 때: 애플리케이션이 대용량 데이터를 처리하는 엔드포인트가 있으면(Excel 내보내기, 통계 계산, 이미지 리사이즈),
monitorEventLoopDelay를 정기적으로 측정하여 전체 서버에 영향을 미치기 전에 조기에 감지하세요.
10. 결론
Event Loop는 Node.js가 적은 리소스로 많은 수의 연결을 처리할 수 있는 이유이지만, 하나의 제약이 따릅니다: 모든 동기식 JavaScript 코드는 단 하나의 스레드에서 실행됩니다. 6개의 단계(timers, pending callbacks, poll, check, close)와 microtask queue의 위치(process.nextTick, Promise)를 명확히 이해하면 실행 순서를 정확하게 예측하고 디버깅하기 어려운 오류를 피할 수 있습니다.
CPU 집약적인 작업을 만나면 worker_threads를 사용하거나 별도의 프로세스로 분리하는 것을 두려워하지 마세요. Event Loop는 당신이 그것을 질식시키지 않을 때만 강력합니다.