Node.js Event Loop
분류: Layer 0 - 런타임 & 프레임워크 기초 | 작성일: 2026-03-22
1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”Node.js Event Loop는 싱글 스레드인 Node.js가 I/O 작업을 블로킹 없이 처리할 수 있게 해주는 비동기 처리 메커니즘이다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”회사 서버가 전부 Node.js(Nest.js)로 되어 있다. 응답이 느리거나, 특정 요청이 다른 요청을 막거나, CPU를 많이 쓰는 작업에서 서버가 멈추는 현상을 이해하려면 Event Loop를 알아야 한다. “왜 이 코드가 느린가”에 대한 답이 여기서 나온다.
3. 핵심 개념
섹션 제목: “3. 핵심 개념”비유로 시작 — “카페 바리스타”
바리스타(Node.js) 한 명이 카페를 혼자 운영한다. 손님이 아메리카노를 주문하면(I/O 요청), 원두를 그라인딩하는 머신(libuv Thread Pool)에 맡겨두고, 그 사이에 다른 손님 주문을 받는다(다른 요청 처리). 머신이 완료 신호를 보내면(이벤트), 바리스타가 잠깐 손을 떼고 아메리카노를 완성한다. 하지만 손님이 “지금 당장 수학 문제 1만 개를 계산해줘”라고 하면(CPU 집약적), 바리스타가 그 자리에 멈춰서 계산만 하게 되고, 다른 손님은 그동안 무조건 기다려야 한다.
브라우저 Event Loop vs Node.js Event Loop — 비교표
프론트엔드 개발자라면 브라우저 Event Loop는 이미 익숙하다. Node.js Event Loop는 같은 개념에서 출발하지만 서버 환경에 맞게 확장된 버전이다.
| 항목 | 브라우저 Event Loop | Node.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.js | libuv Event Loop | epoll/kqueue/IOCP | 싱글 스레드 + Thread Pool(파일 I/O) |
| Nginx | Worker Process당 Event Loop | epoll/kqueue | 워커 프로세스 수 = CPU 코어 수, 커넥션당 메모리 ≈ 수 KB |
| Redis | 싱글 스레드 Event Loop | ae 라이브러리 (epoll 래퍼) | I/O 멀티플렉싱으로 초당 10만+ 명령 처리 |
| Go runtime | Goroutine + netpoller | epoll/kqueue | M: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 대기 시간을 측정한 후 조정하는 것이 안전하다.
# 적용 예시 (반드시 프로세스 시작 전에 설정)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")); // MicrotasksetTimeout(() => console.log("B"), 0); // Macrotaskprocess.nextTick(() => console.log("D")); // nextTickconsole.log("C"); // 동기
// 실제 출력 순서:// C ← 동기 코드 먼저// D ← process.nextTick// A ← Promise (Microtask)// B ← setTimeout (Macrotask)⚠️ setImmediate vs setTimeout(0) — 실행 순서의 비결정적 특성
setImmediate와 setTimeout(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 };}4. 실무에서 어디에 쓰이나
섹션 제목: “4. 실무에서 어디에 쓰이나”- 느린 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가 계속 돌기 때문
5. 현재 내 업무와 연결점
섹션 제목: “5. 현재 내 업무와 연결점”- Nest.js 서비스의 응답 지연 이슈 → Event Loop 블로킹 의심
- async/await 코드 리뷰 시 실행 순서 파악
- 무거운 작업(대용량 데이터 처리)을 Queue로 분리해야 하는 이유 이해
6. 자주 헷갈리는 개념 비교
섹션 제목: “6. 자주 헷갈리는 개념 비교”| 개념 A | 개념 B | 차이점 |
|---|---|---|
| setTimeout(fn, 0) | Promise.then() | Promise가 먼저 실행됨 (Microtask 우선) |
| 싱글 스레드 | 비동기 처리 | 스레드는 하나지만 I/O는 논블로킹으로 동시 처리 가능 |
| 블로킹 | CPU 집약적 | CPU 집약적 작업은 블로킹의 일종. 차이는 I/O 블로킹은 논블로킹으로 해결 가능하지만, CPU 집약적은 Worker Thread 분리가 필요 |
| async/await | Promise | async/await은 Promise의 문법적 설탕 |
| Worker Threads | child_process | Worker는 같은 프로세스 내에서 메모리를 공유하며 실행, child_process는 완전히 별도 프로세스 생성 |
Worker Threads / child_process / Cluster / 외부 Queue(BullMQ) — 선택 매트릭스
CPU 집약적 작업을 메인 스레드에서 분리할 때, 어떤 방식을 선택할지는 아래 기준으로 판단한다:
| 기준 | Worker Threads | child_process | Cluster | BullMQ (외부 Queue) |
|---|---|---|---|---|
| 데이터 공유 | SharedArrayBuffer로 메모리 공유 가능 (제로카피) | 불가 — IPC(JSON 직렬화)로 통신 | 불가 — IPC 통신 | Redis 기반 — 프로세스/서버 간 공유 |
| CPU 코어 활용 | 같은 프로세스 내 스레드 → 코어 수만큼 생성 권장 | 별도 프로세스 → 코어 수 이상도 가능 | 코어 수만큼 워커 프로세스 자동 분배 | 워커 서버를 별도로 스케일아웃 |
| 작업 지속 시간 | 짧은 | 중간 | 요청 단위 (서버 수명 동안 유지) | 긴 작업 (분~시간), 재시도 필요 시 |
| 장애 격리 | 스레드 크래시 시 프로세스 전체 영향 가능 | 완전 격리 — 자식 프로세스 크래시가 부모에 영향 없음 | 워커 크래시 시 자동 재생성 가능 | 완전 격리 — 워커 장애 시 작업 자동 재시도 |
| 대표 사용 사례 | 이미지 리사이징, 암호화, 대용량 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가지 동시성 방식의 벤치마크 포함 비교
6.5 트러블슈팅
섹션 제목: “6.5 트러블슈팅”🔧 특정 요청이 들어오면 서버 전체가 수 초간 멈춤
섹션 제목: “🔧 특정 요청이 들어오면 서버 전체가 수 초간 멈춤”증상: 특정 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만 awaitasync 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🔧 clinic.js로 블로킹 원인 찾기
섹션 제목: “🔧 clinic.js로 블로킹 원인 찾기”증상: Event Loop Lag는 감지되었는데 어느 코드가 원인인지 특정하기 어려움
원인: 복잡한 코드베이스에서 블로킹 지점을 육안으로 찾기 어려움
해결: clinic.js의 Doctor 또는 Flame 도구로 플레임 그래프를 분석
# 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:427. 체크리스트
섹션 제목: “7. 체크리스트”- Node.js가 싱글 스레드인데 왜 여러 요청을 동시 처리할 수 있는지 설명할 수 있다
- CPU 집약적 작업이 왜 서버 전체를 느리게 만드는지 설명할 수 있다
- Promise.then()과 setTimeout(fn, 0)의 실행 순서를 맞출 수 있다
- Nest.js에서 async/await를 쓰는 이유를 Event Loop 관점에서 설명할 수 있다
- Event Loop Lag를 측정하는 방법을 알고 있다
8. 추가 학습 키워드
섹션 제목: “8. 추가 학습 키워드”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
8.5 추천 리소스
섹션 제목: “8.5 추천 리소스”- 📖 Node.js 공식 문서 - Event Loop — 각 단계별 공식 설명, 가장 정확한 레퍼런스 (입문)
- 📖 Don’t Block the Event Loop - Node.js 공식 가이드 — 블로킹 패턴과 해결책 공식 가이드 (중급)
- 🎬 A Complete Visual Guide to Node.js Event Loop - Builder.io — 시각적 애니메이션으로 Event Loop 흐름 이해 (입문)
- 📖 How we tamed Node.js event loop lag - Trigger.dev — 실제 프로덕션에서 Event Loop Lag를 발견하고 해결한 실전 사례 (중급)
- 📖 Node.js 2025: Mastering Worker Threads & Clustering - Medium — Worker Threads와 Cluster를 프로덕션에서 활용하는 실전 가이드 (중급)
- 📖 Reactor pattern - Wikipedia — Reactor 패턴의 구조, 변형, 적용 사례 (중급)
- 📖 Demystifying the Reactor Design Pattern in Node.js - Medium — Node.js Reactor 패턴 구현 상세 (중급)
- 📖 Thread pool work scheduling - libuv 공식 문서 — UV_THREADPOOL_SIZE 동작 원리와 제약 (중급)
- 📖 Worker Threads vs Child Processes - Amplication — Worker Threads와 child_process 선택 기준 (중급)
- 📖 Single thread vs child process vs worker threads vs cluster - Alvin Lal — 4가지 동시성 방식 벤치마크 비교 (중급)
9. 내가 직접 확인해볼 것
섹션 제목: “9. 내가 직접 확인해볼 것”- 간단한 Node.js 코드로 setTimeout / Promise 실행 순서 직접 확인
// 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 서비스에서 무거운 작업이 있는지 찾아보기
# 동기 파일 읽기 패턴 확인 (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.nextTickvssetImmediate차이 코드로 확인
// node test-tick-vs-immediate.jssetImmediate(() => console.log("setImmediate"));process.nextTick(() => console.log("nextTick"));
// 예상 출력:// nextTick ← 현재 단계 완료 직후 실행// setImmediate ← poll 단계 이후(check 단계)에 실행- clinic.js로 현재 서비스의 Event Loop 건강 상태 진단
# 개발 환경에서 간단히 진단 (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"10. 핵심 요약
섹션 제목: “10. 핵심 요약”| 항목 | 핵심 내용 |
|---|---|
| 싱글 스레드 + 논블로킹 | 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줄 핵심
- Node.js는 싱글 스레드지만 Event Loop로 I/O를 논블로킹 처리한다
- I/O 작업은 기다리는 동안 다른 요청을 처리하므로 동시 처리가 가능하다
- CPU 집약적 작업은 Event Loop 자체를 막아 모든 요청이 멈춘다 — Worker Thread로 분리하라
- Promise(Microtask)는 setTimeout(Macrotask)보다 먼저 실행된다
- Nest.js 성능 이슈의 원인을 찾으려면 Event Loop 이해가 필수다
11. 다음 학습 경로
섹션 제목: “11. 다음 학습 경로”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.nextTick과setImmediate의 차이는?” - “프로덕션에서 Event Loop Lag를 발견하면 어떻게 진단하는가?”
최종 수정: 2026-04-13