콘텐츠로 이동

Node.js Event Loop

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

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

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

정량 목표 — 어디까지 허용되는가: 추상적인 “느림”이 아니라 측정 가능한 SLO로 환산해야 디버깅 방향이 잡힌다. 프로덕션에서 흔히 채택되는 임계값은 다음과 같다.

  • Event Loop Lag warning ≥ 100ms — Trigger.dev은 이 기준을 OpenTelemetry span 생성 트리거로 사용했다. 100ms를 넘는 동기 작업은 동시 처리 중인 다른 요청 모두에 100ms+ 지연을 더한다. (출처: How we tamed Node.js event loop lag - Trigger.dev)
  • Event Loop Lag alert ≥ 1s — 같은 사례에서 사용자 향 알림 임계값. 1초 차단은 거의 모든 API의 SLA(예: p99 < 500ms)를 위반한다.
  • Event Loop Utilization (ELU) ≤ 0.85 — ELU가 0.85~0.95에서 지속되면 burst 여유가 사라지고 미세한 jitter에도 tail latency가 튄다. 이 메트릭은 perf_hookseventLoopUtilization()으로 측정한다.

이 수치들을 기준 삼아 “응답 지연이 의심된다” 같은 모호한 보고를 “p99 latency가 목표 200ms 대비 850ms로 탈선했고 동 시각 Event Loop Lag p99가 320ms 측정됨”처럼 의사결정 가능한 진술로 바꿀 수 있다.

퀴즈

Event Loop Lag 100ms 경고가 중요한 이유는?

정답 보기

100ms 동안 메인 스레드가 막히면 동시에 처리 중인 다른 요청도 그만큼 뒤로 밀린다. 그래서 개별 함수 속도보다 전체 요청 tail latency 관점에서 봐야 한다.

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

바리스타(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 쿼리나 파일 읽기가 메인 스레드를 블로킹하지 않는다.

브라우저와 Node.js Event Loop 구분

브라우저

렌더링, 사용자 이벤트, Web API 비동기 작업을 한 UI 스레드 경험 안에서 조율한다.

화면 멈춤, input 지연, 렌더링 타이밍을 볼 때

Node.js

서버 요청 처리 중 I/O 대기와 CPU 점유를 분리해 여러 연결을 같은 프로세스에서 다룬다.

API tail latency, CPU 블로킹, Thread Pool 병목을 볼 때

Event Loop는 왜 등장했는가 — thread-per-connection의 한계

Node.js 이전의 주류 서버 모델은 Apache 같은 thread-per-connection(연결마다 OS 스레드 1개) 방식이었다. 단순하고 직관적이지만, 동시 연결이 늘면 두 종류의 비용이 비선형으로 폭증한다.

  • 메모리 한계 — 정량 증거: 스레드 기본 스택이 ~2MB이므로 32-bit Linux의 1GB 사용자 VM에서는 2^30 / 2^21 ≈ 512 스레드에서 가상 메모리가 고갈된다. 64-bit에서도 1만 연결 = 약 20GB 스택이 필요해 보통의 호스트에서는 비현실적이다. (출처: The C10K problem - Dan Kegel)
  • 컨텍스트 스위칭 한계: 수천 스레드를 OS 스케줄러가 돌리면 “스위칭에 쓰는 CPU 시간이 실제 요청 처리 시간을 추월”하는 지점이 1만 연결 근처에서 나타난다 — 이것이 1999년 Dan Kegel이 정의한 C10K 문제다.

Ryan Dahl이 JSConf EU 2009에서 Node.js를 발표하며 내세운 명제는 “I/O는 다르게 해야 한다(I/O needs to be done differently)“였다. 해결책이 곧 아래 Reactor 패턴 — 연결마다 스레드를 두는 대신 하나의 스레드가 epoll/kqueue로 수천 개 소켓을 동시에 감시하고 준비된 것만 처리한다. 같은 1GB 머신에서 Apache가 ~512 연결에서 OOM이 나는 동안 Node.js는 같은 메모리로 수만 연결을 다룰 수 있다. (출처: Node.js by Ryan Dahl - JSConf.eu 2009)

이 토픽이 사라지면 무엇이 깨지는가: Event Loop를 빼면 Node.js는 곧 thread-per-connection으로 회귀한다 — 회사의 Nest.js 한 인스턴스가 수천 커넥션을 동시에 받는다는 가정 자체가 OOM으로 무너진다. 본 문서의 CPU 블로킹 경고들이 의미를 갖는 것도 이 “싱글 스레드가 모두를 떠받친다”는 전제 위에서다.

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

용어

Reactor 패턴

하나의 루프가 여러 I/O 소스를 감시하다가 준비된 이벤트만 핸들러로 디스패치하는 동시성 패턴이다.

Node.js, Nginx, Redis처럼 커넥션 수가 많고 I/O 대기가 긴 시스템에서 반복된다.

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를 채택할지 말지: 워크로드가 (1) 요청당 I/O 대기 시간이 CPU 처리 시간을 압도하고 (2) 동시 연결이 코어 수 × 수십을 초과하면 Reactor가 유리하다. 반대로 요청마다 수십~수백 ms의 순수 CPU 연산을 하는 워크로드(ML 추론, 이미지 처리, 압축)는 Reactor 단독으로 불리하며, Go의 M:N 스케줄링이나 Rust+Tokio 같은 work-stealing 런타임, 혹은 Worker Pool 분리가 필요하다. 본 문서의 AppSignal 벤치마크가 보여준 “Worker Pool 도입 4배 vs 알고리즘 개선 360배”는 이 결정 규칙을 어긴 사례(I/O 가정에 맞지 않는 워크로드를 Reactor 위에 그대로 올림)에 가깝다.

📖 더 보기: 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에 신호를 보내 콜백을 실행한다.

논블로킹 I/O 위임 흐름
sequenceDiagram
participant Main as Node.js main thread
participant Libuv as libuv
participant Kernel as OS kernel

Main->>Libuv: non-blocking I/O 등록
Libuv->>Kernel: epoll/kqueue/IOCP 감시
Kernel-->>Libuv: ready event
Libuv-->>Main: callback queue로 전달
Main->>Main: callback 실행
[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 대기 시간을 측정한 후 조정하는 것이 안전하다.

실측에 기반한 결정 사례: 위 표만 보면 “늘리면 좋다”로 오해하기 쉬워 실제로 측정해보고 결정해야 한다. 두 가지 실험 시나리오를 권장한다.

  • 케이스 A — 네트워크 I/O 위주 API 서버: 외부 HTTP API 호출이 대부분이고 파일 I/O가 적은 서비스에서 UV_THREADPOOL_SIZE를 4 → 16으로 올린다. 측정 결과 p99 latency 변화가 ±5ms 이내라면(목표 SLO 대비 무의미) 롤백하고 그대로 4 유지. 이유: 네트워크 I/O는 epoll/kqueue가 커널에서 처리하므로 Thread Pool과 무관함을 본문에서 설명한 그대로다.
  • 케이스 B — DNS·crypto 혼합 서비스: dns.lookup(Thread Pool 사용)이 burst하면 crypto.pbkdf2 응답까지 같이 느려지는지 확인. 풀이 4일 때 동시 요청 8개 중 4개가 큐에서 대기 → p99 latency 두 배. 16으로 늘리면 DNS·crypto burst가 겹쳐도 p99 안정. 이때만 16 채택.

판단 규칙: 튜닝 전후 동일 부하 테스트(예: autocannon -c 50 -d 30)에서 p99 latency 또는 Event Loop Lag p99가 통계적으로 유의미하게 좋아지지 않으면 변경 철회. 잘못 키운 풀은 Cluster 모드에서 메모리 폭증의 원인이 된다(스레드당 ~1MB).

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)를 갖고 있어서, 해당 단계의 콜백을 모두 처리하거나 시스템 한도에 도달할 때까지 실행한 후 다음 단계로 넘어간다.

Event Loop queue 처리 흐름
flowchart TD
A[요청 또는 I/O 이벤트] --> B[poll phase]
B --> C{준비된 I/O가 있는가?}
C -->|yes| D[callback queue]
C -->|no| E[다음 timer 또는 이벤트까지 대기]
D --> F[process.nextTick queue]
F --> G[Promise microtask queue]
G --> H[다음 phase]
I[CPU-bound sync work] --> J[Event Loop lag 증가]
J --> D

Event Loop phase 흐름

  1. timers

    만료된 setTimeout, setInterval 콜백을 실행한다.

  2. poll

    대부분의 I/O 이벤트를 기다리고 준비된 콜백을 처리한다.

  3. check

    poll 이후 setImmediate 콜백을 실행한다.

  4. close callbacks

    소켓 종료처럼 close 이벤트 후처리를 실행한다.

┌─────────────────────────────┐
│ 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 자체를 막는다. 그 시간 동안 다른 모든 요청이 멈춘다.

흔한 숨겨진 블로킹 패턴 — 정상 입력은 ms 단위로 통과하므로 코드 리뷰에서 놓치기 쉬운 silent failure가 핵심:

  • JSON.parse(largeString) — Node.js 공식 가이드 벤치마크 기준 50MB JSON 문자열을 JSON.parse하면 1.3초, JSON.stringify는 0.7초 Event Loop를 점유한다. 클라이언트가 보낸 객체 크기를 사전에 차단하지 않으면 단일 악성 요청이 모든 다른 요청을 ~2초 정지시킬 수 있다. 감지·완화: Express라면 app.use(express.json({ limit: '1mb' }))로 입력 상한 + monitorEventLoopDelay로 p99 alert. (출처: Don’t Block the Event Loop - Node.js)
  • 복잡한 정규식 (ReDoS)(a+)*, (a|a)*, backreference (a.*) \1 같은 중첩 quantifier는 입력에 따라 exponential time. 공식 예시 /(\/.+)+$/에 슬래시 100개 + 개행을 넣으면 단일 match() 호출이 무기한 블로킹된다. 감지: safe-regex npm으로 정적 검증, PR 단계에서 차단.
  • Array.sort() / map / reduce 대용량 컬렉션 — 수십만 건은 동기 실행. 감지: 입력 길이 가드(if (arr.length > 10_000) throw ...)를 boundary에 둔다.
  • 명시적 Sync API (crypto.pbkdf2Sync, fs.readFileSync, child_process.execSync, zlib.inflateSync) — 공식 가이드가 “서버 컨텍스트에서 사용 금지(not intended for use in the server context)“로 명시. 감지: eslint-plugin-nn/no-sync 규칙으로 PR 단계 차단.

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를 가지므로 메인 스레드를 전혀 방해하지 않는다.

측정된 개선폭과 결정 흐름: AppSignal의 공개 벤치마크에서 30번째 피보나치 계산을 단일 메인 스레드 재귀로 처리하면 1,341 req/s, Worker Pool(코어 수 - 1)로 분리하면 7,459 req/s로 약 4배 개선이 측정되었다. 다만 같은 벤치마크에서 알고리즘을 재귀 → 행렬 지수법으로 바꾸면 478,176 req/s로 약 360배 개선이 발생했고, 여기에 Worker Pool을 추가하면 메시지 전달 오버헤드 때문에 오히려 310,678 req/s로 떨어졌다. (출처: Dealing with CPU-bound Tasks in Node.js - AppSignal)

이 데이터에서 도출되는 결정 규칙: (1) 먼저 알고리즘 복잡도를 줄일 수 있는지 점검 → (2) 알고리즘이 이미 최적이면 Worker Pool로 분리 → (3) 둘 다 적용해서 더 느려지면 Worker는 회수. 이 순서를 따르지 않고 곧장 Worker Threads부터 도입하면 위 사례처럼 역효과가 난다.

// 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 블로킹 발생 시 즉시 복구 절차

섹션 제목: “🔧 프로덕션에서 Event Loop 블로킹 발생 시 즉시 복구 절차”

증상: 모니터링 대시보드에서 Event Loop Lag p99가 SLO(예: 100ms)를 돌파하고 동 시각 API p99 latency가 SLA 한계를 넘었다. 사용자 보고가 들어오기 시작했다. 원인 후보: 최근 배포한 코드에 동기 블로킹이 포함되었거나, 특정 사용자/데이터 조건이 기존 코드의 가정을 깨뜨림(예: Trigger.dev 사례 — 한 사용자가 8,000+ schedules를 만들어 schedule trigger 목록 조회 시 15초 lag 발생) 복구는 진단보다 먼저 시작한다. 진단(clinic, flamegraph)은 분 단위지만 SLA 위반은 초 단위로 누적된다.

Terminal window
# 1. 즉시 트래픽 차단 또는 분산 (분 단위)
# - 로드밸런서에서 문제 인스턴스를 out-of-rotation
# AWS ALB: aws elbv2 deregister-targets --target-group-arn $TG --targets Id=$INSTANCE_ID
# Kubernetes: kubectl scale deployment $APP --replicas=$(($CURRENT * 2)) # 일단 인스턴스 늘려 부하 분산
# 예상 출력: 새 인스턴스가 Ready 상태로 전환되고 대상 인스턴스의 요청 수가 감소
# 2. 가능하면 직전 안정 버전으로 롤백 (트래픽 차단 후 1~2분 내)
kubectl rollout undo deployment/$APP # 직전 ReplicaSet으로 복귀
kubectl rollout status deployment/$APP --timeout=2m # 롤백 완료 확인
# 예상 출력: deployment "app" successfully rolled out
# 3. Feature flag로 의심 코드만 비활성화 (롤백이 무거울 때)
# 예: LaunchDarkly, ConfigCat, 또는 환경변수 기반 토글
curl -X PATCH $FLAG_API/heavy-job-enabled -d '{"value":false}'
# 효과: 코드는 그대로지만 문제 경로만 우회 → 5~30초 내 lag 정상화 확인
// 4. 임시 완화: 위험 엔드포인트에 응답 크기 가드 추가 (Trigger.dev이 적용한 패턴)
// 한 사용자의 데이터가 Loop를 점유하지 못하도록 페이지네이션·상한 강제
@Get('/schedules')
async listSchedules(@Query('limit') limit = 100) {
const safeLimit = Math.min(limit, 1000); // ← 8k+ schedules 시나리오 차단
return this.repo.find({ take: safeLimit });
}

복구 검증 체크포인트: 위 단계 후 monitorEventLoopDelay로 p99가 100ms 미만으로 회귀했는지 확인. 회귀하지 않으면 다음 의심 경로로 이동(외부 의존성 latency 폭주, DB 슬로우 쿼리). 정상화 확인 후에만 clinic으로 근본 원인 분석을 시작한다.

📖 더 보기: How we tamed Node.js event loop lag - Trigger.dev — 100ms 임계값으로 감지하고 페이지네이션·로그 상한으로 완화한 실제 incident 회고


증상: 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