콘텐츠로 이동

Node.js Event Loop

분류: Layer 0 - 런타임 & 프레임워크 기초 | 작성일: 2026-03-22

Node.js Event Loop는 싱글 스레드인 Node.js가 I/O 작업을 블로킹 없이 처리할 수 있게 해주는 비동기 처리 메커니즘이다.

회사 서버가 전부 Node.js(Nest.js)로 되어 있다. 응답이 느리거나, 특정 요청이 다른 요청을 막거나, CPU를 많이 쓰는 작업에서 서버가 멈추는 현상을 이해하려면 Event Loop를 알아야 한다. “왜 이 코드가 느린가”에 대한 답이 여기서 나온다.

비유로 시작 — “카페 바리스타”

바리스타(Node.js) 한 명이 카페를 혼자 운영한다. 손님이 아메리카노를 주문하면(I/O 요청), 원두를 그라인딩하는 머신(libuv Thread Pool)에 맡겨두고, 그 사이에 다른 손님 주문을 받는다(다른 요청 처리). 머신이 완료 신호를 보내면(이벤트), 바리스타가 잠깐 손을 떼고 아메리카노를 완성한다. 하지만 손님이 “지금 당장 수학 문제 1만 개를 계산해줘”라고 하면(CPU 집약적), 바리스타가 그 자리에 멈춰서 계산만 하게 되고, 다른 손님은 그동안 무조건 기다려야 한다.

브라우저 Event Loop vs Node.js Event Loop — 비교표

프론트엔드 개발자라면 브라우저 Event Loop는 이미 익숙하다. Node.js Event Loop는 같은 개념에서 출발하지만 서버 환경에 맞게 확장된 버전이다.

항목브라우저 Event LoopNode.js Event Loop
기반 라이브러리브라우저 엔진 내장 (V8 + WebAPI)libuv (C 라이브러리)
process.nextTick없음있음 — 각 단계 전에 실행
setImmediate없음있음 — poll 단계 이후 check 단계
Thread Pool없음 (단일 스레드)있음 — 기본 4개 스레드 (파일 I/O 등)
비동기 I/O 모델Web API (fetch, XHR 등)epoll/kqueue(네트워크) + Thread Pool(파일)
루프 단계 수단순 (Microtask + Macrotask)6단계 (timers, poll, check 등)

브라우저에서 fetch를 써도 페이지가 멈추지 않는 것과 같은 원리로, Node.js에서도 DB 쿼리나 파일 읽기가 메인 스레드를 블로킹하지 않는다.

상위 원리: Reactor 패턴 (이벤트 기반 동시성 모델)

Node.js의 Event Loop는 Reactor 패턴이라는 더 큰 설계 원리의 구현체다. Reactor 패턴은 단일 스레드(또는 소수의 스레드)가 이벤트 디멀티플렉서(Event Demultiplexer)를 통해 여러 I/O 소스를 감시하고, 이벤트가 발생하면 해당 핸들러에 디스패치하는 구조다. 이 패턴은 스레드-per-커넥션 모델 대비 컨텍스트 스위칭 오버헤드를 최소화하여 높은 동시성을 달성한다.

같은 원리가 다른 시스템에서도 반복된다:

시스템Reactor 패턴 구현이벤트 디멀티플렉서특징
Node.jslibuv Event Loopepoll/kqueue/IOCP싱글 스레드 + Thread Pool(파일 I/O)
NginxWorker Process당 Event Loopepoll/kqueue워커 프로세스 수 = CPU 코어 수, 커넥션당 메모리 ≈ 수 KB
Redis싱글 스레드 Event Loopae 라이브러리 (epoll 래퍼)I/O 멀티플렉싱으로 초당 10만+ 명령 처리
Go runtimeGoroutine + netpollerepoll/kqueueM:N 스케줄링으로 Reactor를 언어 런타임에 내장

전이 가능한 사고 모델: “싱글 스레드가 어떻게 높은 동시성을 달성하는가?”라는 질문에 대한 답은 항상 같다 — 이벤트 디멀티플렉싱 + 논블로킹 I/O + 콜백 디스패치. 새로운 시스템을 만나면 “이벤트 루프가 있는가? 디멀티플렉서가 무엇인가? 블로킹 작업은 어떻게 분리하는가?”를 확인하면 동시성 모델을 빠르게 파악할 수 있다.

📖 더 보기: Reactor pattern - Wikipedia — Reactor 패턴의 구조와 변형 설명 📖 더 보기: Demystifying the Reactor Design Pattern in Node.js - Medium — Node.js에서 Reactor 패턴이 어떻게 구현되는지 상세 설명

싱글 스레드 + 논블로킹 — 원리

Node.js 메인 스레드는 하나다. 하지만 I/O 작업은 libuv라는 C 라이브러리가 Thread Pool(기본 4개 스레드)을 통해 백그라운드에서 처리한다. 완료되면 Event Loop에 신호를 보내 콜백을 실행한다.

[Node.js 메인 스레드] ↔ [Event Loop]
[libuv Thread Pool] (DB쿼리, 파일I/O, DNS 등 처리)
[OS 커널] (네트워크 I/O는 커널이 직접 비동기 처리)

libuv가 I/O 작업을 두 가지 방식으로 위임하는 데는 이유가 있다. 네트워크 I/O(TCP 소켓 읽기, HTTP 응답 대기 등)는 OS 커널이 epoll(Linux) / kqueue(macOS) / IOCP(Windows) 인터페이스를 통해 직접 비동기 처리해주므로 별도 스레드가 필요 없다. 반면 파일 I/O는 대부분의 OS에서 진정한 비동기 인터페이스를 제공하지 않아서 libuv가 Thread Pool의 스레드를 blocking 방식으로 실행하고 완료 후 메인 루프에 이벤트를 전달한다. 이 설계 때문에 파일 I/O가 많은 서버는 UV_THREADPOOL_SIZE를 늘려주면 병목이 해소될 수 있다.

UV_THREADPOOL_SIZE 환경변수로 Thread Pool 크기를 조정 가능 (기본 4, 최대 1024). DB 연결이 많은 서버라면 늘리는 것이 유리하다.

UV_THREADPOOL_SIZE 튜닝 Trade-off

Thread Pool 크기를 무조건 늘리면 좋은 것은 아니다. libuv Thread Pool은 파일 I/O, DNS 조회(getaddrinfo), 암호화(crypto) 등이 공유하는 고정 크기 풀이므로, 한 종류의 작업이 풀을 점유하면 다른 종류의 작업도 대기하게 된다.

상황늘리면 좋은 경우늘리면 나쁜/무의미한 경우
파일 I/O 집중 서버동시 파일 읽기/쓰기가 4개 이상이면 병목 해소네트워크 I/O 위주 서버(커널 비동기 처리)에는 효과 없음
DNS 조회 빈번외부 API 호출이 많은 서비스에서 DNS 병목 해소Cluster 모드에서 프로세스마다 풀이 생겨 스레드 총수 급증 (8 프로세스 × 128 = 1024 스레드)
crypto 연산pbkdf2, randomBytes 등 빈번한 경우OS 스케줄링 오버헤드 증가, 메모리 사용량 스레드당 ~1MB 증가

적정 값 판단 기준: 논리 코어 수를 기준으로 설정하되(os.cpus().length), Cluster 모드일 경우 논리 코어 수 / 워커 프로세스 수로 나눠야 스레드 폭증을 방지할 수 있다. 프로덕션에서는 clinic doctor로 Thread Pool 대기 시간을 측정한 후 조정하는 것이 안전하다.

Terminal window
# 적용 예시 (반드시 프로세스 시작 전에 설정)
UV_THREADPOOL_SIZE=16 node dist/main.js
# ⚠️ 프로세스 시작 후에는 변경 불가 — 아래는 효과 없음
process.env.UV_THREADPOOL_SIZE = '16'; // 이미 풀이 생성된 후라 무시됨

📖 더 보기: Thread pool work scheduling - libuv 공식 문서 — Thread Pool의 동작 원리와 제약사항

Event Loop 단계별 상세 — 왜 이런 순서인가

📖 더 보기: Node.js 공식 문서 - Event Loop — 각 단계별 공식 설명 🎬 더 보기: A Complete Visual Guide to Node.js Event Loop - Builder.io — 애니메이션으로 단계 흐름 시각화

Event Loop는 총 6개 단계(phase)를 순서대로 순환하며 실행된다. 각 단계는 FIFO 큐(First In, First Out)를 갖고 있어서, 해당 단계의 콜백을 모두 처리하거나 시스템 한도에 도달할 때까지 실행한 후 다음 단계로 넘어간다.

┌─────────────────────────────┐
│ 1. timers │ ← setTimeout, setInterval 콜백 실행
│ (지정 시간 >= 현재일 때)│ (정확한 시간 보장 아님, "최소" 시간)
├─────────────────────────────┤
│ 2. pending callbacks │ ← 이전 루프에서 연기된 I/O 에러 콜백
├─────────────────────────────┤
│ 3. idle, prepare │ ← 내부 전용 (개발자가 직접 쓰지 않음)
├─────────────────────────────┤
│ 4. poll ★ 핵심 │ ← I/O 이벤트 대기 및 처리
│ - 큐가 비어있으면 │ (DB 응답, HTTP 응답, 파일 읽기 등)
│ 다음 타이머까지 대기 │ 여기서 대부분의 시간을 보냄
├─────────────────────────────┤
│ 5. check │ ← setImmediate() 콜백 실행
├─────────────────────────────┤
│ 6. close callbacks │ ← socket.destroy() 등 close 이벤트
└─────────────────────────────┘
↑────────────── 계속 순환 ──┘
각 단계 사이사이에 Microtask Queue(process.nextTick + Promise) 처리

왜 poll 단계가 핵심인가: 서버가 “아무것도 안 하는 것처럼 보이는” 대부분의 시간은 poll 단계에서 I/O 이벤트를 기다리는 중이다. 요청이 들어오면 이 단계에서 깨어나 처리한다.

⚠️ Node.js 20+ (libuv 1.45.0+) 변경사항

Node.js 20부터 libuv 업데이트로 타이머 단계 동작이 변경되었다. 이전 버전에서는 poll 단계 전후 양쪽에서 타이머를 체크했지만, v20+에서는 poll 단계 이후에만 타이머를 실행한다. 실제로 setTimeout(fn, 0) 실행 시점이 미묘하게 달라질 수 있으므로, 정확한 실행 순서에 의존하는 코드는 주의가 필요하다.

블로킹 vs 논블로킹

  • 블로킹: 작업이 끝날 때까지 다음 코드 실행 안 함 → Event Loop 막힘
  • 논블로킹: 작업을 맡기고 바로 다음 코드 실행 → Event Loop 계속 돌아감

CPU-intensive 작업이 위험한 이유 — 원리

📖 더 보기: Don’t Block the Event Loop - Node.js 공식 가이드 — 블로킹 패턴과 해결책 공식 가이드

DB 쿼리나 HTTP 요청은 I/O라서 libuv가 백그라운드에서 처리한다. 하지만 for (let i = 0; i < 1_000_000_000; i++) 같은 순수 계산은 메인 스레드(= Event Loop 스레드)에서 직접 실행되어 Loop 자체를 막는다. 그 시간 동안 다른 모든 요청이 멈춘다.

흔한 숨겨진 블로킹 패턴:

  • JSON.parse(largeString) — 수십 MB 파싱 시 수백 ms 블로킹
  • Array.sort() on 수십만 건 — 동기 실행
  • 복잡한 정규식 — ReDoS(Regular Expression Denial of Service) 공격 위험
  • crypto.pbkdf2Sync(), fs.readFileSync() — 명시적으로 Sync인 API들

Call Stack / Task Queue / Microtask Queue — 실행 우선순위

  • Call Stack: 현재 실행 중인 동기 코드
  • process.nextTick: 현재 단계가 끝나는 즉시 실행 (Microtask보다도 먼저)
  • Microtask Queue: Promise .then(), async/await 결과
  • Macrotask Queue: setTimeout, setInterval, I/O 콜백

실행 순서: 동기 코드 → process.nextTick → Microtask(Promise) → Macrotask(setTimeout 등)

Promise.resolve().then(() => console.log("A")); // Microtask
setTimeout(() => console.log("B"), 0); // Macrotask
process.nextTick(() => console.log("D")); // nextTick
console.log("C"); // 동기
// 실제 출력 순서:
// C ← 동기 코드 먼저
// D ← process.nextTick
// A ← Promise (Microtask)
// B ← setTimeout (Macrotask)

⚠️ setImmediate vs setTimeout(0) — 실행 순서의 비결정적 특성

setImmediatesetTimeout(fn, 0)의 실행 순서는 호출 위치에 따라 달라진다. 이 점을 모르면 예상치 못한 버그가 생긴다.

메인 모듈에서 호출 시 — 순서 보장 안 됨 (비결정적)

// node test-order.js (I/O 콜백 밖에서 직접 호출)
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// 실행할 때마다 순서가 달라질 수 있음:
// 경우 1: timeout → immediate
// 경우 2: immediate → timeout
// ← 프로세스 성능/타이밍에 따라 비결정적!

I/O 콜백 내부에서 호출 시 — setImmediate가 항상 먼저

const fs = require("fs");
fs.readFile(__filename, () => {
// I/O 콜백(poll 단계) 안에서 호출하면 순서 보장됨
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
});
// 항상 같은 순서:
// immediate ← poll 단계 직후 check 단계이므로 항상 먼저
// timeout ← 다음 timers 단계에서 실행

왜 이런 차이가 생기는가: 메인 모듈 실행 중에는 timers 단계가 이미 시작되기 전일 수도 있고 지나쳤을 수도 있어 순서가 OS 스케줄링에 따라 달라진다. 반면 I/O 콜백 안에서는 현재 poll 단계를 막 처리했으므로 다음 단계가 반드시 check(setImmediate)임이 보장된다.

실무 팁: setTimeout(fn, 0) 대신 setImmediate(fn)을 쓰면 I/O 처리 후 실행이 보장되므로 더 예측 가능하다. 단, 실행 순서에 의존하는 코드는 테스트로 검증하라.

📖 더 보기: Understanding setImmediate() - Node.js 공식 문서 — setImmediate와 setTimeout(0)의 차이를 공식 예제로 설명 (입문)

Worker Threads — CPU 집약적 작업 해결책

CPU 집약적 작업을 메인 스레드에서 분리하려면 Worker Threads를 사용한다. Worker는 자체 V8 인스턴스와 Event Loop를 가지므로 메인 스레드를 전혀 방해하지 않는다. 벤치마크 상 CPU 집약적 작업 처리 시간을 최대 70% 단축할 수 있다.

// worker-pool.js — 무거운 CPU 작업을 Worker Thread로 분리
const {
Worker,
isMainThread,
parentPort,
workerData,
} = require("worker_threads");
const os = require("os");
if (isMainThread) {
// 메인 스레드: CPU 코어 수만큼 Worker 생성 (컨텍스트 스위칭 최소화)
const cpuCount = os.availableParallelism(); // Node.js 18.14+ 지원
console.log(`Worker ${cpuCount}개 생성 (CPU 코어 수 기준)`);
const worker = new Worker(__filename, {
workerData: { input: [1, 2, 3, 4, 5] },
});
worker.on("message", (result) => {
console.log("Worker 결과:", result);
// 예상 출력: Worker 결과: 15 (합계)
});
worker.on("error", (err) => console.error("Worker 에러:", err));
worker.on("exit", (code) => {
if (code !== 0) console.error(`Worker가 코드 ${code}로 종료됨`);
});
} else {
// Worker 스레드: 실제 무거운 작업 수행
const sum = workerData.input.reduce((acc, n) => acc + n, 0);
parentPort.postMessage(sum);
// → 메인 스레드의 Event Loop와 완전히 독립적으로 실행
}

Nest.js에서 Worker Threads 활용 패턴:

// ❌ Event Loop를 막는 패턴 — 이 Handler가 실행되는 동안 다른 모든 요청이 대기
@Get('/bad')
badEndpoint() {
let sum = 0;
for (let i = 0; i < 1_000_000_000; i++) sum += i; // 수초간 Loop 점유
return sum;
}
// ✅ 무거운 작업은 Queue로 분리
@Get('/good')
async goodEndpoint() {
await this.queue.add('heavy-job', { ... }); // Worker가 별도로 처리
return { queued: true };
}
  • 느린 API 응답 원인 분석 (Event Loop 블로킹 여부 확인)
  • async/await 코드의 실행 순서 예측
  • CPU 집약적 작업을 별도 Worker Thread로 분리 결정
  • 메모리 누수 및 성능 프로파일링
  • 대용량 파일/데이터 처리 시 스트리밍 vs 일괄 처리 방식 결정

BackOps 실무 시나리오

  • 야간 배치 작업(대량 데이터 집계)이 낮 시간 API 성능에 영향을 준다면 → Event Loop 블로킹 의심, Worker Thread 또는 별도 프로세스 분리 검토
  • “특정 시간대에만 API 응답이 느려진다” → clinic doctor로 타임라인 확인, CPU-intensive 배치 작업과 겹치는 시간대인지 확인
  • Nest.js에서 TypeORM으로 대량 데이터 조회 시: find() 한 번에 수만 건 → streaming() 또는 페이징으로 분할해 메모리·Loop 부하 감소
  • Slack API 응답을 기다리는 동안 다른 요청이 처리되는 이유 → I/O는 논블로킹이라 Event Loop가 계속 돌기 때문
  • Nest.js 서비스의 응답 지연 이슈 → Event Loop 블로킹 의심
  • async/await 코드 리뷰 시 실행 순서 파악
  • 무거운 작업(대용량 데이터 처리)을 Queue로 분리해야 하는 이유 이해
개념 A개념 B차이점
setTimeout(fn, 0)Promise.then()Promise가 먼저 실행됨 (Microtask 우선)
싱글 스레드비동기 처리스레드는 하나지만 I/O는 논블로킹으로 동시 처리 가능
블로킹CPU 집약적CPU 집약적 작업은 블로킹의 일종. 차이는 I/O 블로킹은 논블로킹으로 해결 가능하지만, CPU 집약적은 Worker Thread 분리가 필요
async/awaitPromiseasync/await은 Promise의 문법적 설탕
Worker Threadschild_processWorker는 같은 프로세스 내에서 메모리를 공유하며 실행, child_process는 완전히 별도 프로세스 생성

Worker Threads / child_process / Cluster / 외부 Queue(BullMQ) — 선택 매트릭스

CPU 집약적 작업을 메인 스레드에서 분리할 때, 어떤 방식을 선택할지는 아래 기준으로 판단한다:

기준Worker Threadschild_processClusterBullMQ (외부 Queue)
데이터 공유SharedArrayBuffer로 메모리 공유 가능 (제로카피)불가 — IPC(JSON 직렬화)로 통신불가 — IPC 통신Redis 기반 — 프로세스/서버 간 공유
CPU 코어 활용같은 프로세스 내 스레드 → 코어 수만큼 생성 권장별도 프로세스 → 코어 수 이상도 가능코어 수만큼 워커 프로세스 자동 분배워커 서버를 별도로 스케일아웃
작업 지속 시간짧은중간 (ms수 초)중간긴 (초분 단위)요청 단위 (서버 수명 동안 유지)긴 작업 (분~시간), 재시도 필요 시
장애 격리스레드 크래시 시 프로세스 전체 영향 가능완전 격리 — 자식 프로세스 크래시가 부모에 영향 없음워커 크래시 시 자동 재생성 가능완전 격리 — 워커 장애 시 작업 자동 재시도
대표 사용 사례이미지 리사이징, 암호화, 대용량 JSON 파싱외부 CLI 실행, Python/FFmpeg 호출HTTP 서버 멀티코어 수평 확장이메일 발송, 보고서 생성, 야간 배치

실무 판단 흐름: (1) HTTP 요청을 멀티코어로 분산하고 싶다 → Cluster (2) 요청 내에서 CPU 연산만 분리하면 된다 → Worker Threads (3) 외부 프로그램을 실행해야 한다 → child_process (4) 작업이 길고 재시도/스케줄링이 필요하다 → BullMQ

📖 더 보기: Worker Threads vs Child Processes: Which one should you use? - Amplication — Worker Threads와 child_process의 상세 비교 📖 더 보기: Single thread vs child process vs worker threads vs cluster - Alvin Lal — 4가지 동시성 방식의 벤치마크 포함 비교

🔧 특정 요청이 들어오면 서버 전체가 수 초간 멈춤

섹션 제목: “🔧 특정 요청이 들어오면 서버 전체가 수 초간 멈춤”

증상: 특정 API를 호출하면 다른 모든 API도 같이 느려지거나 타임아웃 발생. 로그를 보면 해당 요청 처리 중 다른 요청이 전혀 처리되지 않음 원인: CPU 집약적인 동기 코드가 Event Loop를 점유. 흔한 원인:

  • 대용량 JSON 파싱/직렬화 (JSON.parse, JSON.stringify - 동기 실행)
  • 복잡한 정규식 (ReDoS 공격 패턴)
  • 대용량 배열 정렬/변환 (Array.sort, map, reduce)
  • fs.readFileSync, crypto.pbkdf2Sync 등 동기 API 사용

해결:

// ❌ JSON.parse 대용량 데이터 — 동기로 Loop 점유
@Get('/bad')
parseData() {
return JSON.parse(fs.readFileSync('huge-file.json', 'utf8')); // 수십 MB → 수 초 블로킹
}
// ✅ 스트리밍 또는 Worker Thread로 분리
@Get('/good')
async parseData() {
await this.workerQueue.add('parse-job', { filePath: 'huge-file.json' });
return { queued: true };
}

🔧 async/await을 썼는데 여전히 느림

섹션 제목: “🔧 async/await을 썼는데 여전히 느림”

증상: async/await로 코드를 작성했는데도 성능이 개선되지 않음 원인: async/await은 I/O 비동기를 편하게 쓰는 문법일 뿐, CPU 계산 자체를 비동기로 만들어주지 않음. await 앞에 동기 CPU 연산이 있으면 그 부분은 여전히 블로킹

// ❌ await 앞의 동기 코드는 여전히 블로킹
async processData(data: any[]) {
const result = data.map(item => heavyCalculation(item)); // ← 이 부분은 동기
await this.repo.save(result); // 이것만 비동기
}
// ✅ CPU 작업을 Worker Thread로, I/O만 await
async processData(data: any[]) {
const result = await this.workerService.calculate(data); // Worker에서 처리
await this.repo.save(result);
}

🔧 process.nextTick을 과도하게 쓰면 I/O Starvation 발생

섹션 제목: “🔧 process.nextTick을 과도하게 쓰면 I/O Starvation 발생”

증상: setImmediate나 I/O 콜백이 영원히 실행되지 않고, CPU 사용률은 높음 원인: process.nextTick은 각 Event Loop 단계 전에 큐가 비워질 때까지 실행됨. nextTick 콜백 안에서 다시 nextTick을 재귀 호출하면 I/O 콜백이 실행될 기회를 영원히 얻지 못함 (I/O Starvation)

// ❌ 재귀 nextTick — I/O Starvation 유발
function recursiveTick() {
process.nextTick(recursiveTick); // 절대 끝나지 않아 I/O 처리 불가
}
// ✅ setImmediate 사용 — I/O 콜백에게 실행 기회를 줌
function recursiveWork() {
setImmediate(recursiveWork); // poll 단계 이후에 실행되므로 I/O 처리 가능
}

🔧 Event Loop 지연(Lag)이 발생하는지 모니터링 방법

섹션 제목: “🔧 Event Loop 지연(Lag)이 발생하는지 모니터링 방법”

증상: 서버 응답이 간헐적으로 느린데 원인을 특정하기 어려움. 코드에서 명확한 블로킹 지점을 찾기 어려움 원인: 작은 동기 작업들이 누적되거나, 높은 요청 부하로 인해 Event Loop 큐가 쌓임 해결: Event Loop Lag를 직접 측정하여 모니터링

// 방법 1: setInterval 기반 간이 측정 (ms 단위)
let lastCheck = Date.now();
setInterval(() => {
const now = Date.now();
const lag = now - lastCheck - 1000; // 이상적이면 0, 지연되면 양수
if (lag > 50) {
console.warn(`[EventLoop] Lag 감지: ${lag}ms — Event Loop 블로킹 의심`);
}
lastCheck = now;
}, 1000);
// 예상 출력 (정상): (출력 없음)
// 예상 출력 (블로킹 발생 시):
// [EventLoop] Lag 감지: 234ms — Event Loop 블로킹 의심

📖 더 보기: Node.js perf_hooks - monitorEventLoopDelay — 공식 API로 정밀한 Event Loop 지연 측정 (중급)

// 방법 2: perf_hooks 공식 API (더 정확, 나노초 단위 히스토그램)
const { monitorEventLoopDelay } = require("node:perf_hooks");
const h = monitorEventLoopDelay({ resolution: 20 }); // 20ms 간격 샘플링
h.enable();
// 일정 시간 후 통계 확인
setTimeout(() => {
h.disable();
console.log(`평균 지연: ${(h.mean / 1e6).toFixed(2)}ms`);
console.log(`최대 지연: ${(h.max / 1e6).toFixed(2)}ms`);
console.log(`P99 지연: ${(h.percentile(99) / 1e6).toFixed(2)}ms`);
}, 10000);
// 예상 출력 (정상):
// 평균 지연: 0.12ms
// 최대 지연: 1.45ms
// P99 지연: 0.89ms
// 예상 출력 (블로킹 발생 시):
// 평균 지연: 45.67ms
// 최대 지연: 1823.12ms
// P99 지연: 523.45ms

증상: Event Loop Lag는 감지되었는데 어느 코드가 원인인지 특정하기 어려움 원인: 복잡한 코드베이스에서 블로킹 지점을 육안으로 찾기 어려움 해결: clinic.js의 Doctor 또는 Flame 도구로 플레임 그래프를 분석

Terminal window
# 1. clinic.js 설치
npm install -g clinic
# 2. clinic doctor로 이벤트 루프 진단 (자동으로 문제 유형 분류)
clinic doctor -- node dist/main.js
# → 브라우저가 열리면서 진단 결과 출력
# → "Event Loop Delay" / "I/O Issue" / "Memory" 중 어떤 유형인지 자동 판단
# 3. 더 상세한 분석은 clinic flame (플레임 그래프)
clinic flame -- node dist/main.js
# → CPU 사용 시간이 가장 많은 함수를 플레임 그래프로 시각화
# → 가장 넓은(오래 실행된) 함수가 블로킹 원인
# 4. 부하 테스트와 동시에 진단 (autocannon 사용)
clinic doctor --autocannon [ / ] -- node dist/main.js
# 예상 출력 (문제 있을 때):
# ✗ Event loop delay detected (avg: 234ms, max: 1823ms)
# ✗ Possible blocking operation in: users.service.js:42
  • Node.js가 싱글 스레드인데 왜 여러 요청을 동시 처리할 수 있는지 설명할 수 있다
  • CPU 집약적 작업이 왜 서버 전체를 느리게 만드는지 설명할 수 있다
  • Promise.then()과 setTimeout(fn, 0)의 실행 순서를 맞출 수 있다
  • Nest.js에서 async/await를 쓰는 이유를 Event Loop 관점에서 설명할 수 있다
  • Event Loop Lag를 측정하는 방법을 알고 있다

libuv, Worker Threads, cluster module, PM2, setImmediate vs setTimeout, Promise chaining, async iterator, Event Loop Lag, UV_THREADPOOL_SIZE, clinic.js, 0x, os.availableParallelism, perf_hooks monitorEventLoopDelay, Reactor 패턴, BullMQ, child_process, SharedArrayBuffer

  • 간단한 Node.js 코드로 setTimeout / Promise 실행 순서 직접 확인
test-event-loop.js
// node test-event-loop.js 로 실행
Promise.resolve().then(() => console.log("1. Promise (Microtask)"));
setTimeout(() => console.log("2. setTimeout (Macrotask)"), 0);
process.nextTick(() => console.log("3. nextTick"));
console.log("4. 동기 코드");
// 예상 출력:
// 4. 동기 코드
// 3. nextTick
// 1. Promise (Microtask)
// 2. setTimeout (Macrotask)
  • Nest.js 서비스에서 무거운 작업이 있는지 찾아보기
Terminal window
# 동기 파일 읽기 패턴 확인 (Event Loop 블로킹 위험)
grep -rn "readFileSync\|writeFileSync\|pbkdf2Sync\|execSync" src/ --include="*.ts"
# 결과가 있다면 → 비동기 버전으로 교체 검토 필요
# 예상 출력 (발견 시):
# src/utils/file.service.ts:23: const data = fs.readFileSync('config.json', 'utf8');
# → 이 패턴은 Event Loop를 막으므로 fs.readFile (비동기) 또는 fs.promises.readFile로 교체
  • process.nextTick vs setImmediate 차이 코드로 확인
// node test-tick-vs-immediate.js
setImmediate(() => console.log("setImmediate"));
process.nextTick(() => console.log("nextTick"));
// 예상 출력:
// nextTick ← 현재 단계 완료 직후 실행
// setImmediate ← poll 단계 이후(check 단계)에 실행
  • clinic.js로 현재 서비스의 Event Loop 건강 상태 진단
Terminal window
# 개발 환경에서 간단히 진단 (Node.js 설치 필요)
npm install -g clinic autocannon
# 서버 시작하면서 동시에 부하 테스트 + 진단
clinic doctor --autocannon [ /api/users ] -- node dist/main.js
# → 진단 완료 후 브라우저가 열리며 결과 확인
# → 정상: "No issues detected"
# → 문제 있음: "Event Loop Delay detected — check users.service.js"
항목핵심 내용
싱글 스레드 + 논블로킹I/O는 libuv가 백그라운드 처리 → Loop는 계속 돌아감
CPU 집약적 작업메인 스레드를 직접 점유 → 모든 요청 대기 → Worker Thread로 분리
실행 순서동기 → process.nextTick → Promise(Microtask) → setTimeout(Macrotask)
poll 단계대부분의 시간을 여기서 보냄 — I/O 이벤트를 기다리는 구간
진단 도구clinic doctor / Event Loop Lag 측정 / UV_THREADPOOL_SIZE 조정

5줄 핵심

  1. Node.js는 싱글 스레드지만 Event Loop로 I/O를 논블로킹 처리한다
  2. I/O 작업은 기다리는 동안 다른 요청을 처리하므로 동시 처리가 가능하다
  3. CPU 집약적 작업은 Event Loop 자체를 막아 모든 요청이 멈춘다 — Worker Thread로 분리하라
  4. Promise(Microtask)는 setTimeout(Macrotask)보다 먼저 실행된다
  5. Nest.js 성능 이슈의 원인을 찾으려면 Event Loop 이해가 필수다
Node.js Event Loop (지금 여기)
Node.js Streams ← 대용량 데이터를 Event Loop 부하 없이 처리하는 방법
NestJS BullMQ / Queue ← CPU 집약적 작업을 Worker로 분리하는 실전 패턴
Node.js Cluster Module ← 멀티코어 활용 (Worker Threads vs Cluster 차이)
Node.js 성능 프로파일링 ← clinic.js, 0x, --prof 플래그 활용

인터뷰 대비 핵심 질문 (실제 자주 출제)

  • “Node.js는 싱글 스레드인데 어떻게 동시에 여러 요청을 처리하는가?”
  • “Event Loop의 6단계를 설명하고, poll 단계가 핵심인 이유는?”
  • “CPU-intensive 작업과 I/O 블로킹의 차이는? 각각 어떻게 해결하는가?”
  • process.nextTicksetImmediate의 차이는?”
  • “프로덕션에서 Event Loop Lag를 발견하면 어떻게 진단하는가?”

최종 수정: 2026-04-13