Node.js Event Loop 동작 원리

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 스레드 풀 크기를 적절히 조정해야 합니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux