Event Loop란?
Node.js Event Loop는 싱글 스레드에서 비동기 I/O를 처리하는 핵심 런타임 메커니즘입니다. Node.js가 수만 개의 동시 연결을 처리할 수 있는 이유가 바로 Event Loop입니다. 내부적으로 libuv 라이브러리가 OS의 비동기 I/O(epoll, kqueue, IOCP)를 추상화하여 이벤트 기반 논블로킹 모델을 제공합니다.
이 글에서는 Event Loop의 6개 페이즈, 마이크로태스크 큐, process.nextTick과 Promise의 실행 순서, 그리고 Event Loop 블로킹 진단까지 심층적으로 다룹니다. Node.js Streams 백프레셔 심화와 함께 읽으면 Node.js 내부 동작의 전체 그림을 파악할 수 있습니다.
Event Loop 6개 페이즈
Event Loop는 고정된 순서로 6개 페이즈를 반복합니다. 각 페이즈마다 해당 큐의 콜백을 처리합니다:
┌───────────────────────────┐
┌─▶│ 1. timers │ ← setTimeout, setInterval 콜백
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ 2. pending callbacks │ ← 시스템 콜백 (TCP 에러 등)
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ 3. idle, prepare │ ← 내부 전용 (libuv)
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ 4. poll │ ← I/O 콜백 (fs, net, http 등)
│ │ (새 I/O 이벤트 대기) │ Event Loop의 핵심!
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ 5. check │ ← setImmediate 콜백
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ 6. close callbacks │ ← socket.on('close') 등
│ └─────────────┬─────────────┘
│ │
│ ┌─────────────▼─────────────┐
│ │ process.nextTick 큐 │ ← 매 페이즈 전환 시 실행
│ │ Promise 마이크로태스크 │ ← 매 페이즈 전환 시 실행
│ └─────────────┬─────────────┘
└────────────────┘
각 페이즈 상세
1. Timers 페이즈
// setTimeout/setInterval의 콜백이 실행되는 페이즈
// ⚠️ 지정한 시간은 "최소 지연"이지 "정확한 시간"이 아님!
setTimeout(() => {
console.log('100ms 후... 실제로는 더 늦을 수 있음');
}, 100);
// 예: poll 페이즈에서 I/O 콜백이 150ms 걸렸다면
// → timers 페이즈 도달 시점이 150ms
// → setTimeout(cb, 100)의 콜백은 150ms 후에 실행됨
// setTimeout(fn, 0) vs setImmediate(fn)
// I/O 콜백 내부에서는 setImmediate가 항상 먼저
const fs = require('fs');
fs.readFile('file.txt', () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// 출력: immediate → timeout (항상 이 순서)
// 메인 모듈에서는 순서가 비결정적!
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 출력: 실행마다 다를 수 있음 (시스템 클럭 해상도에 의존)
4. Poll 페이즈 (핵심)
// Poll은 Event Loop의 가장 중요한 페이즈
// 두 가지 기능:
// 1. I/O 이벤트의 콜백 실행 (fs.read, net.connect 등)
// 2. 새로운 I/O 이벤트가 올 때까지 대기 (블로킹!)
// Poll의 동작 로직:
// if (poll 큐에 콜백 있음):
// → 콜백들을 순서대로 실행 (큐가 비거나 시스템 한도까지)
// else:
// if (setImmediate 스케줄됨):
// → check 페이즈로 이동
// elif (timer 만료됨):
// → timers 페이즈로 이동
// else:
// → 새 I/O 이벤트를 기다림 (블로킹)
// 이것이 Node.js가 CPU를 100% 점유하지 않는 이유!
// 할 일이 없으면 poll에서 OS에 이벤트 알림을 요청하고 대기함
// Linux: epoll_wait(), macOS: kqueue()
5. Check 페이즈
// setImmediate 콜백이 실행되는 페이즈
// poll 페이즈 직후에 실행되므로, I/O 완료 후 즉시 실행할 코드에 적합
// 재귀적 setImmediate = Event Loop 한 바퀴에 하나씩 실행
function processQueue() {
const item = queue.shift();
if (item) {
processItem(item);
setImmediate(processQueue); // 다음 틱에서 계속
// → 중간에 다른 I/O 콜백이 실행될 수 있음!
}
}
// vs 재귀적 process.nextTick = 모두 소진될 때까지 독점
function processQueueBlocking() {
const item = queue.shift();
if (item) {
processItem(item);
process.nextTick(processQueueBlocking); // ❌ I/O 기아(starvation)!
// → nextTick 큐가 빌 때까지 다른 페이즈 진입 불가
}
}
마이크로태스크 실행 순서
// 우선순위: process.nextTick > Promise.then > 매크로태스크
console.log('1: 동기');
process.nextTick(() => console.log('2: nextTick'));
Promise.resolve().then(() => console.log('3: Promise'));
setTimeout(() => console.log('4: setTimeout'), 0);
setImmediate(() => console.log('5: setImmediate'));
console.log('6: 동기');
// 출력 순서:
// 1: 동기
// 6: 동기
// 2: nextTick ← 동기 코드 완료 후, 페이즈 전환 전
// 3: Promise ← nextTick 후, 페이즈 전환 전
// 4: setTimeout ← timers 페이즈 (또는 5보다 나중일 수 있음)
// 5: setImmediate ← check 페이즈
// 중첩된 마이크로태스크
process.nextTick(() => {
console.log('A: nextTick 1');
process.nextTick(() => console.log('B: nextTick 2')); // 즉시 큐에 추가
Promise.resolve().then(() => console.log('C: Promise'));
});
// A → B → C (nextTick이 모두 소진된 후 Promise 실행)
// ⚠️ Node.js 11+ 변경사항:
// 각 매크로태스크 사이에 마이크로태스크 큐를 비움 (브라우저와 동일)
// Node.js 10 이하: 페이즈 내 모든 콜백 실행 후 마이크로태스크
// Node.js 11+: 각 콜백 사이에 마이크로태스크
Event Loop 블로킹 진단
Event Loop가 블로킹되면 모든 비동기 작업이 지연됩니다. CPU-intensive 코드나 동기 I/O가 주범입니다.
// Event Loop 지연 시간 측정
let lastCheck = Date.now();
setInterval(() => {
const now = Date.now();
const delay = now - lastCheck - 1000; // 1초 인터벌 기준 지연
if (delay > 50) {
console.warn(`Event Loop blocked: ${delay}ms`);
}
lastCheck = now;
}, 1000);
// 더 정밀한 측정: monitorEventLoopDelay (Node.js 11+)
const { monitorEventLoopDelay } = require('perf_hooks');
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
setInterval(() => {
console.log({
min: histogram.min / 1e6, // 나노초 → 밀리초
max: histogram.max / 1e6,
mean: histogram.mean / 1e6,
p99: histogram.percentile(99) / 1e6,
});
histogram.reset();
}, 5000);
// 결과 예시:
// { min: 0.01, max: 2.5, mean: 0.15, p99: 1.2 } → 정상
// { min: 0.01, max: 250, mean: 45, p99: 200 } → 블로킹 발생!
// NestJS/Express에서 미들웨어로 측정
app.use((req, res, next) => {
const start = process.hrtime.bigint();
setImmediate(() => {
const lag = Number(process.hrtime.bigint() - start) / 1e6;
if (lag > 100) {
console.warn(`Request ${req.url}: Event Loop lag ${lag}ms`);
}
});
next();
});
블로킹 방지 패턴
// ❌ Event Loop 블로킹 (절대 하면 안 됨)
const data = fs.readFileSync('huge-file.txt'); // 동기 I/O
const hash = crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512'); // CPU-intensive
JSON.parse(hugeJsonString); // 큰 JSON 파싱
// ✅ 패턴 1: 비동기 API 사용
const data = await fs.promises.readFile('huge-file.txt');
// ✅ 패턴 2: setImmediate로 청크 분할
async function processLargeArray(items) {
const CHUNK_SIZE = 1000;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
processChunk(chunk);
// 매 청크 후 Event Loop에 양보
await new Promise(resolve => setImmediate(resolve));
}
}
// ✅ 패턴 3: Worker Threads로 CPU 작업 오프로드
const { Worker, isMainThread, parentPort } = require('worker_threads');
if (isMainThread) {
// 메인 스레드: Event Loop 블로킹 없음
const worker = new Worker(__filename, { workerData: { password, salt } });
worker.on('message', (hash) => {
console.log('Hash computed:', hash);
});
} else {
// 워커 스레드: CPU-intensive 작업 수행
const hash = crypto.pbkdf2Sync(
workerData.password, workerData.salt, 100000, 64, 'sha512'
);
parentPort.postMessage(hash.toString('hex'));
}
libuv 스레드 풀
// libuv는 내부 스레드 풀(기본 4개)로 일부 작업을 처리
// 스레드 풀 사용하는 작업:
// - fs.* (파일 I/O)
// - dns.lookup() (DNS 조회)
// - crypto.pbkdf2, crypto.randomBytes 등
// - zlib 압축/해제
// 스레드 풀 크기 조정
// UV_THREADPOOL_SIZE=16 node app.js (최대 1024)
// ⚠️ 스레드 풀 고갈 문제
// 4개 스레드가 모두 바쁘면 → 파일 읽기, DNS 조회 등이 대기열에 쌓임
const fs = require('fs');
// 동시에 100개 파일 읽기 요청
for (let i = 0; i < 100; i++) {
fs.readFile(`file-${i}.txt`, () => {
// 스레드 풀 4개로 100개 처리 → 마지막 파일은 오래 대기
});
}
// → UV_THREADPOOL_SIZE를 늘리거나, 동시 요청 수를 제한
// 스레드 풀을 사용하지 않는 작업 (OS 비동기 I/O 직접 사용):
// - net.connect (TCP)
// - http.request
// - dns.resolve (c-ares 라이브러리)
// → 이들은 스레드 풀 크기에 영향받지 않음!
NestJS 성능 최적화 실전 가이드에서 Event Loop 블로킹을 방지하는 NestJS 패턴도 확인하세요.
정리
Node.js Event Loop는 6개 페이즈(timers → pending → idle → poll → check → close)를 순환하며, 매 페이즈 전환 시 process.nextTick과 Promise 마이크로태스크를 처리합니다. Poll 페이즈가 핵심으로, I/O 콜백 실행과 새 이벤트 대기를 담당합니다. 운영 핵심은 Event Loop를 블로킹하지 않는 것이며, monitorEventLoopDelay로 지연을 측정하고, CPU-intensive 작업은 Worker Threads로 오프로드하고, libuv 스레드 풀 크기를 적절히 조정해야 합니다.