브라우저
렌더링, 사용자 이벤트, Web API 비동기 작업을 한 UI 스레드 경험 안에서 조율한다.
화면 멈춤, input 지연, 렌더링 타이밍을 볼 때분류: Layer 0 - 런타임 & 프레임워크 기초 | 작성일: 2026-03-22
Node.js Event Loop는 싱글 스레드인 Node.js가 I/O 작업을 블로킹 없이 처리할 수 있게 해주는 비동기 처리 메커니즘이다.
회사 서버가 전부 Node.js(Nest.js)로 되어 있다. 응답이 느리거나, 특정 요청이 다른 요청을 막거나, CPU를 많이 쓰는 작업에서 서버가 멈추는 현상을 이해하려면 Event Loop를 알아야 한다. “왜 이 코드가 느린가”에 대한 답이 여기서 나온다.
정량 목표 — 어디까지 허용되는가: 추상적인 “느림”이 아니라 측정 가능한 SLO로 환산해야 디버깅 방향이 잡힌다. 프로덕션에서 흔히 채택되는 임계값은 다음과 같다.
perf_hooks의 eventLoopUtilization()으로 측정한다.이 수치들을 기준 삼아 “응답 지연이 의심된다” 같은 모호한 보고를 “p99 latency가 목표 200ms 대비 850ms로 탈선했고 동 시각 Event Loop Lag p99가 320ms 측정됨”처럼 의사결정 가능한 진술로 바꿀 수 있다.
퀴즈
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 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 쿼리나 파일 읽기가 메인 스레드를 블로킹하지 않는다.
렌더링, 사용자 이벤트, Web API 비동기 작업을 한 UI 스레드 경험 안에서 조율한다.
화면 멈춤, input 지연, 렌더링 타이밍을 볼 때서버 요청 처리 중 I/O 대기와 CPU 점유를 분리해 여러 연결을 같은 프로세스에서 다룬다.
API tail latency, CPU 블로킹, Thread Pool 병목을 볼 때Event Loop는 왜 등장했는가 — thread-per-connection의 한계
Node.js 이전의 주류 서버 모델은 Apache 같은 thread-per-connection(연결마다 OS 스레드 1개) 방식이었다. 단순하고 직관적이지만, 동시 연결이 늘면 두 종류의 비용이 비선형으로 폭증한다.
2^30 / 2^21 ≈ 512 스레드에서 가상 메모리가 고갈된다. 64-bit에서도 1만 연결 = 약 20GB 스택이 필요해 보통의 호스트에서는 비현실적이다. (출처: The C10K problem - Dan Kegel)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 패턴 (이벤트 기반 동시성 모델)
용어
하나의 루프가 여러 I/O 소스를 감시하다가 준비된 이벤트만 핸들러로 디스패치하는 동시성 패턴이다.
Node.js, Nginx, Redis처럼 커넥션 수가 많고 I/O 대기가 긴 시스템에서 반복된다.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를 채택할지 말지: 워크로드가 (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에 신호를 보내 콜백을 실행한다.
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 대기 시간을 측정한 후 조정하는 것이 안전하다.
실측에 기반한 결정 사례: 위 표만 보면 “늘리면 좋다”로 오해하기 쉬워 실제로 측정해보고 결정해야 한다. 두 가지 실험 시나리오를 권장한다.
UV_THREADPOOL_SIZE를 4 → 16으로 올린다. 측정 결과 p99 latency 변화가 ±5ms 이내라면(목표 SLO 대비 무의미) 롤백하고 그대로 4 유지. 이유: 네트워크 I/O는 epoll/kqueue가 커널에서 처리하므로 Thread Pool과 무관함을 본문에서 설명한 그대로다.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).
# 적용 예시 (반드시 프로세스 시작 전에 설정)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)를 갖고 있어서, 해당 단계의 콜백을 모두 처리하거나 시스템 한도에 도달할 때까지 실행한 후 다음 단계로 넘어간다.
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 만료된 setTimeout, setInterval 콜백을 실행한다.
대부분의 I/O 이벤트를 기다리고 준비된 콜백을 처리한다.
poll 이후 setImmediate 콜백을 실행한다.
소켓 종료처럼 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 논블로킹
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)(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에 둔다.crypto.pbkdf2Sync, fs.readFileSync, child_process.execSync, zlib.inflateSync) — 공식 가이드가 “서버 컨텍스트에서 사용 금지(not intended for use in the server context)“로 명시. 감지: eslint-plugin-n의 n/no-sync 규칙으로 PR 단계 차단.Call Stack / Task Queue / Microtask Queue — 실행 우선순위
process.nextTick: 현재 단계가 끝나는 즉시 실행 (Microtask보다도 먼저).then(), async/await 결과실행 순서: 동기 코드 → 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를 가지므로 메인 스레드를 전혀 방해하지 않는다.
측정된 개선폭과 결정 흐름: 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 };}BackOps 실무 시나리오
find() 한 번에 수만 건 → streaming() 또는 페이징으로 분할해 메모리·Loop 부하 감소| 개념 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가지 동시성 방식의 벤치마크 포함 비교
증상: 특정 API를 호출하면 다른 모든 API도 같이 느려지거나 타임아웃 발생. 로그를 보면 해당 요청 처리 중 다른 요청이 전혀 처리되지 않음 원인: CPU 집약적인 동기 코드가 Event Loop를 점유. 흔한 원인:
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은 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 발생증상: 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 큐가 쌓임 해결: 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 p99가 SLO(예: 100ms)를 돌파하고 동 시각 API p99 latency가 SLA 한계를 넘었다. 사용자 보고가 들어오기 시작했다. 원인 후보: 최근 배포한 코드에 동기 블로킹이 포함되었거나, 특정 사용자/데이터 조건이 기존 코드의 가정을 깨뜨림(예: Trigger.dev 사례 — 한 사용자가 8,000+ schedules를 만들어 schedule trigger 목록 조회 시 15초 lag 발생) 복구는 진단보다 먼저 시작한다. 진단(clinic, flamegraph)은 분 단위지만 SLA 위반은 초 단위로 누적된다.
# 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 도구로 플레임 그래프를 분석
# 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:42libuv, 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 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)# 동기 파일 읽기 패턴 확인 (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.jssetImmediate(() => console.log("setImmediate"));process.nextTick(() => console.log("nextTick"));
// 예상 출력:// nextTick ← 현재 단계 완료 직후 실행// setImmediate ← poll 단계 이후(check 단계)에 실행# 개발 환경에서 간단히 진단 (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줄 핵심
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 플래그 활용인터뷰 대비 핵심 질문 (실제 자주 출제)
process.nextTick과 setImmediate의 차이는?”최종 수정: 2026-04-13