I/O-bound
대부분의 시간이 네트워크, DB, 파일, 외부 API 응답을 기다리는 데 쓰인다.
timeout, retry, connection pool, backpressure, 외부 의존성 latency를 먼저 본다.분류: Layer 0 - 런타임 & 프레임워크 기초 | 작성일: 2026-03-22
이 문서는 L0의 첫 토픽이다. 아직 운영 도구를 잘 몰라도 괜찮다. 첫 번째 읽기에서 붙잡아야 할 질문은 하나다.
Node.js는 왜 한 개의 메인 스레드로 여러 요청을 동시에 처리할 수 있는데, 어떤 코드는 그 장점을 한 번에 무너뜨리는가?
처음 읽을 때의 목표는 다음 네 가지다.
process.nextTick, Promise microtask, setImmediate, setTimeout의 실행 순서를 예측한다.첫 독서에서 모든 운영 도구를 외울 필요는 없다. clinic.js, ALB, Kubernetes, feature flag 같은 도구는 나중에 incident 대응에서 필요하다. 이 문서에서는 도구 사용 절차보다 “어떤 현상이 Event Loop 문제를 뜻하는가”를 먼저 잡는다.
섹션 5의 Thread Pool, 섹션 9의 Worker/Queue, 섹션 10의 Lag/ELU는 첫 회독에서 이름과 실패 신호만 잡아도 된다.
처음부터 튜닝값이나 도구 사용법을 외우려 하지 말고, “이 작업은 메인 스레드를 점유하는가, libuv Thread Pool을 쓰는가, 아예 별도 Worker/Queue로 보내야 하는가”를 구분하는 데 집중한다.
수치와 도구는 나중에 incident 대응이나 성능 개선을 할 때 다시 돌아와도 늦지 않다.
브라우저 Event Loop를 알고 있다면 출발점은 비슷하다. 둘 다 Call Stack이 비면 큐의 작업을 꺼내 실행한다. 하지만 Node.js는 화면 렌더링보다 서버 I/O, 소켓, 파일, DNS, 암호화, Worker 분리를 이해하는 쪽이 더 중요하다. 그래서 이 문서의 중심은 UI 반응성이 아니라 API tail latency와 서버 동시성이다.
Node.js Event Loop는 Node.js 메인 스레드가 직접 기다리지 않고, 준비된 I/O 이벤트와 예약된 콜백을 정해진 순서로 실행하게 해주는 런타임 루프다.
조금 더 풀어 쓰면 다음과 같다.
용어
Call Stack이 비었을 때 timers, poll, check 같은 phase의 큐를 순서대로 확인하고 콜백을 실행하는 루프다.
Node.js의 '싱글 스레드 서버'가 여러 연결을 다룰 수 있는 핵심 실행 모델이다.퀴즈
CPU가 특별히 빨라서가 아니라, I/O를 기다리는 동안 메인 스레드를 쉬게 두지 않고 다른 준비된 콜백을 처리하기 때문이다.
Event Loop를 이해하려면 먼저 이전 방식의 한계를 봐야 한다. 과거의 직관적인 서버 모델은 thread-per-connection이다. 연결 하나가 들어오면 OS 스레드 하나를 붙이고, 그 스레드가 요청을 끝까지 처리한다.
이 방식은 이해하기 쉽다. 코드도 동기적으로 쓰기 좋다. 문제는 연결 수가 늘어날 때 비용이 연결 수만큼 같이 늘어난다는 점이다.
| 문제 | thread-per-connection에서 벌어지는 일 | 왜 병목이 되는가 |
|---|---|---|
| 메모리 | 연결마다 스레드 스택이 필요하다 | 스레드 기본 스택을 2MB로 잡으면 10,000 연결은 스택만 약 20GB다 |
| 스케줄링 | OS가 수천 개 스레드를 번갈아 실행한다 | 실제 요청 처리보다 컨텍스트 스위칭에 CPU를 더 쓰기 시작한다 |
| I/O 대기 | DB, 네트워크, 파일 응답을 기다리는 동안 스레드가 묶인다 | 기다리는 스레드도 메모리와 스케줄링 비용을 계속 소비한다 |
1999년에 널리 알려진 C10K 문제는 “서버 한 대가 10,000개 동시 연결을 어떻게 다룰 것인가”라는 질문이었다. thread-per-connection만으로는 이 문제를 풀기 어렵다. 더 작게 계산해도 1GB 사용자 VM에서 스레드 스택을 2MB로 잡으면 2^30 / 2^21 ≈ 512개 스레드만으로도 가상 메모리가 고갈될 수 있다. 연결마다 스레드를 만들면 메모리와 스케줄링 비용이 먼저 터진다.
Node.js가 택한 방향은 반대다. 연결마다 스레드를 만들지 않는다. 하나의 메인 스레드가 많은 소켓을 감시하고, 실제로 읽거나 쓸 준비가 된 이벤트만 처리한다. 이것이 Reactor 패턴이다.
sequenceDiagram participant Conn as Many connections participant Kernel as OS kernel participant Loop as Event Loop participant Handler as JS handler Conn->>Kernel: sockets registered Kernel-->>Loop: ready events only Loop->>Handler: dispatch callback Handler-->>Loop: return quickly Loop->>Kernel: wait for next ready event
Ryan Dahl이 Node.js를 발표하며 강조한 문제의식도 여기에 있다. I/O는 계산보다 기다림이 훨씬 많다. 기다리는 동안 스레드를 붙잡아 두면 동시성이 메모리 문제로 바뀐다. Node.js는 “기다림”을 커널과 libuv에 맡기고, JS 메인 스레드는 준비된 콜백만 짧게 실행하게 만든다.
Reactor 패턴의 핵심은 세 단어로 압축된다.
이 사고 모델은 Node.js에만 갇히지 않는다. Nginx, Redis처럼 많은 연결을 적은 스레드로 처리하는 서버도 “준비된 이벤트만 빠르게 처리하고 오래 걸리는 일은 밖으로 밀어낸다”는 Reactor 계열의 감각으로 읽을 수 있다. 그래서 Event Loop를 배우면 특정 API 암기가 아니라 고동시성 서버가 대기 시간을 다루는 공통 언어를 얻는다.
용어
많은 I/O 소스 중 지금 처리 가능한 이벤트만 골라 알려주는 OS/런타임 구성요소다.
Linux의 epoll, macOS의 kqueue, Windows의 IOCP가 여기에 해당한다.Node.js에서는 이 모델이 대략 이렇게 이어진다.
flowchart LR
A[JS handler] --> B[libuv에 I/O 등록]
B --> C[OS kernel 감시]
C --> D{ready event?}
D -->|no| C
D -->|yes| E[poll queue]
E --> F[JS callback 실행]
F --> G[다음 요청 처리] 중요한 점은 “모든 일을 Thread Pool이 대신 처리한다”가 아니라는 것이다. Node.js의 비동기 I/O는 작업 종류에 따라 경로가 다르다.
| 작업 종류 | 주된 처리 경로 | 메인 스레드는 무엇을 하는가 |
|---|---|---|
| TCP/HTTP 소켓 | OS 커널의 epoll/kqueue/IOCP | 소켓을 감시 대상으로 등록하고, 준비된 이벤트의 콜백만 실행한다 |
| DB 쿼리 대부분 | DB 드라이버가 소켓으로 요청/응답 처리 | DB 응답을 기다리는 동안 다른 콜백을 처리한다 |
| 파일 I/O | libuv Thread Pool | 별도 스레드가 blocking 파일 작업을 수행하고 완료 이벤트를 돌려준다 |
DNS dns.lookup | libuv Thread Pool | OS resolver 호출이 풀을 점유할 수 있다 |
crypto.pbkdf2, zlib 일부 | libuv Thread Pool | CPU성 네이티브 작업을 풀에 맡기고 완료 콜백을 받는다 |
| 순수 JS 계산 | JS 메인 스레드 | Event Loop를 직접 점유한다 |
이 표에서 가장 많이 하는 오해가 나온다.
“Node.js는 비동기니까 무거운 작업도 알아서 백그라운드로 빠지겠지.”
아니다. 네트워크 I/O와 파일 I/O는 런타임이 분리할 수 있다. 하지만 내가 JS로 작성한 for 루프, 대형 JSON.parse, 복잡한 정규식, 대형 배열 정렬은 메인 스레드에서 실행된다. Event Loop가 그 코드를 실행하는 동안 다른 콜백은 아무것도 실행되지 못한다.
async/await은 Promise를 읽기 쉽게 쓰는 문법이다. CPU 코드를 다른 스레드로 보내는 문법이 아니다.
async function handler(data: string) { const parsed = JSON.parse(data); // 여기서 800ms가 걸리면 Event Loop가 800ms 막힌다. await saveToDatabase(parsed); // 이 부분은 I/O 대기 중 다른 요청 처리가 가능하다. return { ok: true };}이 함수는 async이지만 첫 줄의 JSON.parse는 동기 실행이다. await이 등장하기 전까지 Event Loop는 이 함수를 빠져나갈 수 없다. 그래서 첫 번째 읽기에서 반드시 기억할 문장은 다음이다.
I/O 대기는 Event Loop 밖으로 보낼 수 있지만, JS CPU 실행은 내가 직접 분리하지 않으면 Event Loop 안에서 돈다.
UV_THREADPOOL_SIZElibuv Thread Pool은 Node.js가 일부 blocking 작업을 메인 스레드 밖에서 실행하기 위해 쓰는 고정 크기 워커 풀이다. 기본 크기는 4이고, 프로세스 시작 전에 UV_THREADPOOL_SIZE로 조정할 수 있다. libuv 문서 기준 상한은 1024다.
풀을 공유한다는 말은 한 종류의 작업이 4개 스레드를 모두 점유하면 다른 종류의 작업도 줄을 선다는 뜻이다.
const crypto = require("node:crypto");const dns = require("node:dns");
// pbkdf2 4개가 기본 Thread Pool을 모두 점유할 수 있다.for (let i = 0; i < 4; i += 1) { crypto.pbkdf2("pw", "salt", 500_000, 64, "sha512", () => { console.log("crypto done"); });}
// 같은 풀을 쓰는 dns.lookup은 crypto 작업 뒤로 밀릴 수 있다.dns.lookup("example.com", () => { console.log("dns done");});이 코드는 네트워크가 느려서 DNS가 밀리는 것이 아니다. dns.lookup이 같은 libuv Thread Pool을 쓰기 때문에, 이미 풀을 점유한 crypto.pbkdf2 뒤에서 기다릴 수 있다.
UV_THREADPOOL_SIZE는 크게 잡을수록 좋아지는 값이 아니다.
| 값/조건 | 의미 | 판단 |
|---|---|---|
| 기본 4 | 파일 I/O, dns.lookup, crypto, zlib 등이 공유 | 대부분의 API 서버는 먼저 이 값으로 측정한다 |
| 8~32 | 파일/DNS/crypto burst가 자주 겹치는 서비스에서 후보 | 동일 부하에서 p99 latency와 Thread Pool 대기 시간이 줄어야 유지한다 |
| 수백 | 프로세스 하나에 많은 native 작업이 몰리는 특수 상황 | Cluster/컨테이너 복제 수까지 곱해 스레드 폭증을 계산해야 한다 |
| 1024 | libuv Thread Pool 상한 | 상한에 가까운 값은 보통 설계 냄새다 |
Cluster 모드를 함께 쓰면 계산이 달라진다. 프로세스 8개에서 UV_THREADPOOL_SIZE=64를 주면 호스트에는 libuv 워커 스레드만 512개가 생긴다. 각 스레드는 스택과 스케줄링 비용을 가진다. CPU 코어가 8개인 서버에서 수백 개 스레드를 만들면 원래 피하려던 스케줄링 비용이 다시 돌아온다.
반례 A: 외부 HTTP API가 느린 서비스
외부 HTTP API 호출이 대부분인 NestJS 서버에서 UV_THREADPOOL_SIZE를 4에서 16으로 올렸다. p99 latency가 310ms에서 307ms로만 바뀌었다면 의미 있는 개선으로 보기 어렵다. HTTP 소켓 대기는 보통 OS 커널 이벤트 감시 경로를 타므로 libuv Thread Pool 크기와 직접 관련이 없다. 이 경우는 풀을 되돌리고, 외부 API timeout, connection pooling, retry 폭주를 먼저 본다.
반례 B: 파일 I/O와 crypto가 섞인 서비스
동시에 12개의 crypto.pbkdf2와 파일 해시 계산이 들어오고, 기본 풀 4개에서 p99가 1.2초까지 튄다. UV_THREADPOOL_SIZE=16에서 p99가 420ms로 내려가고 Event Loop Lag는 그대로 낮다면 후보가 된다. 그래도 “16이 정답”은 아니다. 같은 부하를 다시 걸어 보고, 프로세스 수를 곱한 총 스레드 수가 호스트에 맞는지 확인해야 한다.
# 프로세스 시작 전에만 의미가 있다.UV_THREADPOOL_SIZE=16 node dist/main.js// 프로세스가 이미 시작된 뒤에는 실질 효과를 기대하면 안 된다.process.env.UV_THREADPOOL_SIZE = "16";운영 판단은 명령이 아니라 질문으로 시작한다.
Event Loop는 여러 phase를 순서대로 돈다. 모든 phase를 처음부터 같은 비중으로 외울 필요는 없다. 서버 코드를 읽을 때 가장 자주 쓰는 축은 timers, poll, check, 그리고 각 phase 사이의 microtask 처리다.
만료된 setTimeout, setInterval 콜백을 실행한다.
일부 시스템 I/O 콜백처럼 다음 루프로 미뤄진 콜백을 처리한다.
libuv 내부 준비 단계다. 애플리케이션 코드가 직접 쓰는 단계는 아니다.
새 I/O 이벤트를 기다리고 준비된 I/O 콜백을 실행한다.
setImmediate 콜백을 실행한다.
socket close 같은 종료 후처리 콜백을 실행한다.
timers -> pending callbacks -> idle/prepare -> poll -> check -> close callbacks -> 다시 timers서버가 “대기 중”인 대부분의 시간은 poll phase에 있다. poll phase는 준비된 I/O 콜백이 있으면 실행하고, 없으면 다음 이벤트나 타이머가 올 때까지 기다린다.
API 서버 관점에서는 다음처럼 읽으면 된다.
poll phase를 이해하면 setImmediate가 왜 I/O 직후에 자주 쓰이는지도 이해된다. setImmediate는 check phase에서 실행되고, check phase는 poll 뒤에 온다.
Node.js 20에 포함된 libuv 1.45.0 이후에는 timers 실행 위치가 바뀌었다. 예전에는 poll 전후로 timers를 확인했지만, 이후에는 poll 이후에 timers를 실행한다. 그래서 setTimeout(fn, 0)과 setImmediate(fn)의 상대 순서에 의존하는 코드는 Node 버전과 호출 위치에 따라 흔들릴 수 있다.
첫 독서에서는 이렇게 정리하면 충분하다.
setTimeout(fn, 0)은 “즉시”가 아니라 “최소 0ms 뒤 timers phase 후보”다.setImmediate(fn)은 poll phase 뒤 check phase에서 실행된다.setImmediate가 setTimeout(0)보다 먼저 온다.process.nextTickNode.js 실행 순서를 헷갈리게 만드는 이유는 phase 큐만 있는 것이 아니기 때문이다. 각 phase 콜백이 끝난 뒤 더 높은 우선순위의 큐가 비워진다.
| 큐/위치 | 대표 API | 실행 감각 |
|---|---|---|
| Call Stack | 현재 실행 중인 JS 함수 | 가장 먼저 끝까지 실행된다 |
process.nextTick queue | process.nextTick() | 현재 작업이 끝난 직후, Promise microtask보다 먼저 비워진다 |
| Promise microtask queue | Promise.then, queueMicrotask, await 이후 continuation | nextTick 다음, 다음 macrotask 전에 실행된다 |
| Macrotask/phase queue | setTimeout, I/O callback, setImmediate | Event Loop phase 순서에 따라 실행된다 |
작은 코드로 확인하자.
Promise.resolve().then(() => console.log("promise"));setTimeout(() => console.log("timeout"), 0);setImmediate(() => console.log("immediate"));process.nextTick(() => console.log("nextTick"));console.log("sync");
// 보통 관찰할 수 있는 핵심 순서:// sync// nextTick// promise// timeout/immediate는 호출 위치와 phase에 따라 달라질 수 있음여기서 확실한 부분은 세 가지다.
sync가 먼저다.process.nextTick이 Promise microtask보다 먼저다.process.nextTick은 왜 조심해야 하는가process.nextTick은 “바로 다음 tick에 실행”이라 편해 보이지만, 너무 많이 쓰면 I/O starvation을 만들 수 있다. starvation은 어떤 작업이 실행 기회를 얻지 못하고 계속 굶는 상태다.
function recursiveTick() { process.nextTick(recursiveTick);}
recursiveTick();
setImmediate(() => { console.log("여기까지 오지 못할 수 있다.");});nextTick 큐가 계속 다시 채워지면 Event Loop는 poll이나 check phase로 넘어갈 기회를 얻지 못한다. 서버에서는 “CPU 사용률은 높은데 I/O 콜백이 밀리는” 형태로 보일 수 있다.
반대로 긴 작업을 여러 조각으로 쪼개고 I/O에게도 기회를 주고 싶다면 setImmediate가 더 적합한 경우가 많다.
function chunkedWork(items, index = 0) { const end = Math.min(index + 1_000, items.length);
for (let i = index; i < end; i += 1) { doSmallCpuWork(items[i]); }
if (end < items.length) { setImmediate(() => chunkedWork(items, end)); }}이 예제는 CPU 작업을 없애지 않는다. 다만 한 번에 Event Loop를 오래 점유하지 않게 쪼개서 I/O 콜백이 끼어들 틈을 만든다. 작업 자체가 무겁고 오래 걸린다면 Worker Threads나 외부 Queue로 보내는 편이 더 낫다.
setTimeout(0)과 setImmediate의 반례메인 모듈 최상위에서는 둘의 순서를 보장하지 말아야 한다.
setTimeout(() => console.log("timeout"), 0);setImmediate(() => console.log("immediate"));
// 실행 환경과 타이밍에 따라 timeout이 먼저일 수도, immediate가 먼저일 수도 있다.하지만 I/O 콜백 안에서는 보통 setImmediate가 먼저다.
const fs = require("node:fs");
fs.readFile(__filename, () => { setTimeout(() => console.log("timeout"), 0); setImmediate(() => console.log("immediate"));});
// poll phase의 I/O 콜백 안에서 예약했으므로 check phase의 immediate가 먼저 실행된다.이 차이는 실무에서 “0ms timeout으로 다음에 실행” 같은 가정을 깨뜨린다. 다음 tick, microtask, timer, check phase가 모두 다르다는 것을 코드 리뷰에서 구분해야 한다.
Node.js 성능 문제의 절반은 “이 작업이 CPU-bound인가, I/O-bound인가”를 잘못 판단해서 생긴다.
대부분의 시간이 네트워크, DB, 파일, 외부 API 응답을 기다리는 데 쓰인다.
timeout, retry, connection pool, backpressure, 외부 의존성 latency를 먼저 본다.대부분의 시간이 JS 계산, 파싱, 직렬화, 정렬, 압축, 암호화 같은 실행에 쓰인다.
알고리즘 개선, 입력 상한, chunking, Worker Threads, 별도 프로세스를 먼저 본다.async function getUserProfile(userId: string) { const user = await userRepository.findOneBy({ id: userId }); const billing = await billingClient.getPlan(userId);
return { user, billing };}이 코드는 대기 시간이 길 수 있다. 하지만 await 중에는 Event Loop가 다른 요청을 처리할 수 있다. 병목은 Event Loop 점유가 아니라 DB 쿼리, 외부 API latency, connection pool 고갈일 가능성이 높다.
async function importRows(raw: string) { const rows = JSON.parse(raw); // 대형 입력이면 동기 CPU 작업이다. const sorted = rows.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
await repository.save(sorted); return sorted.length;}이 함수도 async이지만 앞부분은 CPU-bound다. JSON.parse와 sort가 오래 걸리면 그 시간만큼 모든 요청이 뒤로 밀린다. await repository.save만 비동기일 뿐이다.
| 패턴 | 왜 위험한가 | 첫 번째 방어선 |
|---|---|---|
대형 JSON.parse / JSON.stringify | 수십 MB 입력은 수백 ms에서 초 단위로 Event Loop를 점유할 수 있다 | HTTP body limit, pagination, streaming parser, Worker 분리 |
| 복잡한 정규식(ReDoS) | ^(a+)+$ 같은 중첩 quantifier는 aaaaaaaa!처럼 거의 맞다가 실패하는 입력에서 exponential time이 될 수 있다 | safe-regex류 정적 검사, 입력 길이 제한, 단순 regex로 교체 |
대형 배열 sort, map, reduce | 컬렉션 크기만큼 동기 실행된다 | 입력 상한, chunking, DB 정렬/페이징, Worker 분리 |
fs.readFileSync 등 sync API | 호출이 끝날 때까지 Event Loop가 멈춘다 | 비동기 API, startup-only 사용 원칙, lint rule |
crypto.pbkdf2Sync, execSync | CPU 또는 외부 프로세스 대기를 동기로 묶는다 | async API, Worker/child_process 분리 |
Node.js 공식 가이드의 예시처럼 50MB JSON 문자열은 JSON.stringify 약 0.7초, JSON.parse 약 1.3초만으로도 Event Loop를 오래 점유할 수 있다. 숫자를 외우라는 뜻이 아니라, “입력 크기가 커지면 비동기 함수 안의 동기 변환이 초 단위 전역 지연이 된다”는 감각을 잡으라는 뜻이다. 입력이 평소에는 작기 때문에 테스트에서 안 보이다가, 특정 고객 데이터나 악성 입력에서 갑자기 전체 서버 지연으로 나타난다.
CPU-bound라고 해서 바로 Worker Threads를 붙이면 안 된다. Worker는 메인 스레드를 보호하지만, 느린 알고리즘을 빠른 알고리즘으로 바꾸지는 않는다.
AppSignal의 공개 벤치마크는 이 경계를 잘 보여준다.
| 접근 | 관찰된 결과 | 해석 |
|---|---|---|
| 메인 스레드에서 재귀 Fibonacci | 약 1,341 req/s | CPU 작업이 Event Loop를 점유한다 |
| Worker Pool 분리 | 약 7,459 req/s | 메인 스레드 보호로 처리량이 오른다 |
| 알고리즘을 행렬 지수법으로 개선 | 약 478,176 req/s | 복잡도 개선이 Worker 분리보다 훨씬 크다 |
| 개선된 알고리즘에 Worker 추가 | 약 310,678 req/s | 메시지 전달 비용이 이득보다 커질 수 있다 |
결정 순서는 이렇다.
CPU 작업을 메인 스레드에서 빼는 선택지는 여러 개다. 이름만 외우면 실무에서 잘못 고르기 쉽다. 기준은 “얼마나 오래 걸리는가”, “격리가 얼마나 필요한가”, “HTTP 요청 생명주기 안에서 끝나야 하는가”, “재시도가 필요한가”다.
| 선택지 | 맞는 상황 | 피해야 할 상황 |
|---|---|---|
| Worker Threads | 같은 Node.js 프로세스 안에서 짧은~중간 CPU 작업을 병렬 처리 | 작업이 분 단위로 길거나, 크래시 격리가 매우 중요하거나, 외부 런타임이 필요할 때 |
| child_process | FFmpeg, Python 스크립트, 외부 CLI처럼 프로세스 격리가 필요한 작업 | 작은 작업을 매우 자주 보내 IPC 비용이 더 클 때 |
| Cluster | HTTP 서버 자체를 여러 프로세스로 띄워 코어를 활용 | 개별 요청 안의 CPU 작업을 자동으로 쪼개 주길 기대할 때 |
| BullMQ / 외부 Queue | 이메일, 보고서 생성, 배치, 재시도, 예약, 워커 수평 확장이 필요한 작업 | 요청 안에서 즉시 결과를 반환해야 하는 짧은 계산 |
flowchart TD
A[느린 작업 발견] --> B{입력 상한/알고리즘 개선 가능?}
B -->|yes| C[먼저 작업량 줄이기]
B -->|no| D{HTTP 요청 안에서 즉시 결과 필요?}
D -->|yes| E[Worker Threads 후보]
D -->|no| F{재시도/예약/분산 워커 필요?}
F -->|yes| G[BullMQ 또는 외부 Queue]
F -->|no| H{외부 CLI/런타임 필요?}
H -->|yes| I[child_process]
H -->|no| J[Worker Threads 또는 별도 서비스] Worker Threads는 같은 프로세스 안에 별도 JS 실행 컨텍스트를 만든다. Worker는 자체 V8 인스턴스와 Event Loop를 가진다. 메인 스레드와는 메시지로 통신한다.
const { Worker, isMainThread, parentPort, workerData,} = require("node:worker_threads");
function expensiveSum(numbers) { return numbers.reduce((acc, value) => acc + value, 0);}
if (isMainThread) { const worker = new Worker(__filename, { workerData: [1, 2, 3, 4, 5], });
worker.on("message", (result) => { console.log(result); // 15 });} else { parentPort.postMessage(expensiveSum(workerData));}이 예제의 목적은 Worker API를 외우는 것이 아니다. 중요한 모델은 “메인 Event Loop가 계산을 직접 붙잡지 않게 한다”는 점이다. 실제 서비스에서는 Worker를 요청마다 새로 만들기보다 Pool로 재사용해야 생성 비용을 줄일 수 있다.
// 나쁜 방향: 요청 handler가 CPU 작업을 끝까지 붙잡는다.@Get("/report")generateReport() { return this.reportService.buildHugeReportSync();}// 나은 방향: 요청은 작업 등록까지만 하고, 긴 작업은 워커가 처리한다.@Post("/report")async requestReport(@Body() dto: ReportRequestDto) { const job = await this.reportQueue.add("build-report", dto); return { jobId: job.id };}이 선택은 “비동기 문법을 썼다”가 아니라 “HTTP 요청 생명주기와 긴 CPU/배치 작업을 분리했다”는 점에서 중요하다.
Event Loop 문제를 감으로만 말하면 원인 추적이 어렵다. 최소한 두 가지 수치를 구분해야 한다.
용어
예약된 콜백이 기대한 시각보다 얼마나 늦게 실행됐는지 나타내는 지연 시간이다.
100ms 이상이면 사용자 요청 tail latency에 바로 영향을 줄 수 있다.용어
측정 구간 동안 Event Loop가 작업을 실행하느라 바빴던 비율이다.
0.85 이상이 지속되면 burst를 받아낼 여유가 거의 없다고 본다.| 관찰값 | 의미 | 첫 번째 의심 |
|---|---|---|
| Lag p99 20ms 이하 | 대부분의 API 서버에서 안정적인 편 | Event Loop보다는 DB/외부 API latency를 먼저 본다 |
| Lag p99 100ms 이상 | 동기 작업이 tail latency에 체감될 정도로 끼어든다 | 대형 JSON, 정규식, sync API, 대형 배열 처리 |
| Lag max 1s 이상 | 특정 요청이나 배치가 서버 전체를 멈추게 만들 수 있다 | 최근 배포, 특정 고객 데이터, 큰 입력 |
| ELU 0.85~0.95 지속 | 루프가 계속 바빠 burst 여유가 없다 | CPU-bound 코드, 너무 많은 콜백, 과도한 logging/serialization |
| CPU 낮음 + latency 높음 | Event Loop가 CPU를 쓰는 문제만은 아닐 수 있다 | 외부 API, DB, connection pool, queue backlog |
Trigger.dev의 incident 회고는 100ms lag를 OpenTelemetry span 생성 기준으로 삼고, 1초 이상을 사용자 알림 기준으로 보았다. 한 사례에서는 특정 사용자가 8,000개가 넘는 schedule을 만들었고, 목록 조회가 15초 수준의 lag로 이어졌다. 숫자는 서비스 SLO에 맞게 조정해야 하지만, 초심자에게 중요한 감각은 분명하다.
Event Loop Lag 100ms는 한 요청만 100ms 느린 것이 아니다. 같은 프로세스에서 실행 기회를 기다리던 다른 요청들도 함께 뒤로 밀린다.
로컬에서 원리를 확인할 때는 perf_hooks의 monitorEventLoopDelay가 가장 직접적이다.
const { monitorEventLoopDelay } = require("node:perf_hooks");
const histogram = monitorEventLoopDelay({ resolution: 20 });histogram.enable();
setInterval(() => { console.log({ meanMs: Number(histogram.mean / 1e6).toFixed(2), p99Ms: Number(histogram.percentile(99) / 1e6).toFixed(2), maxMs: Number(histogram.max / 1e6).toFixed(2), }); histogram.reset();}, 5_000);이 코드는 운영 런북이 아니라 관찰 모델이다. “Event Loop가 막히면 예약된 측정 콜백도 늦게 실행된다”는 사실을 숫자로 확인하게 해준다.
ELU는 바쁜 비율을 본다.
const { performance } = require("node:perf_hooks");
let previous = performance.eventLoopUtilization();
setInterval(() => { const current = performance.eventLoopUtilization(previous); previous = performance.eventLoopUtilization(); console.log(current.utilization.toFixed(3));}, 5_000);Lag와 ELU는 서로 보완적이다. 짧고 큰 블로킹은 Lag max로 잘 보이고, 루프가 계속 바쁜 상태는 ELU로 잘 보인다.
긴 incident 명령어를 외우기보다, 먼저 증상을 개념으로 연결해야 한다.
| 증상 | 의미 | 먼저 의심할 개념 |
|---|---|---|
| 특정 API 호출 중 다른 API도 같이 느려진다 | 한 handler가 Event Loop를 오래 점유했을 가능성 | CPU-bound JS, sync API, 대형 JSON/정규식 |
async/await을 썼는데도 요청이 멈춘다 | await 전의 동기 CPU 작업이 남아 있다 | async 문법과 CPU 분리의 차이 |
I/O 콜백이나 setImmediate가 오래 실행되지 않는다 | nextTick이나 microtask가 과도하게 큐를 채운다 | microtask starvation, process.nextTick 재귀 |
| 외부 API latency는 정상인데 p99가 튄다 | 응답 직렬화, 로그, DTO 변환이 메인 스레드를 점유할 수 있다 | JSON stringify, class-transformer, 대형 배열 |
| 파일 업로드/압축 때 전체 서버가 느려진다 | Thread Pool 또는 CPU 작업이 병목일 수 있다 | libuv Thread Pool, zlib/crypto, Worker 분리 |
UV_THREADPOOL_SIZE를 올렸는데 효과가 없다 | 병목이 Thread Pool 경로가 아닐 수 있다 | 네트워크 I/O, DB, 외부 API, Event Loop CPU |
| CPU 100%와 Lag가 같이 오른다 | 메인 스레드가 계산에 묶였을 가능성 | 알고리즘, Worker, 입력 상한 |
| CPU는 낮은데 latency가 높다 | Event Loop blocking이 주범이 아닐 수 있다 | DB slow query, connection pool, network timeout |
프로덕션에서 Event Loop Lag가 터졌을 때의 핵심은 “진단 도구를 멋지게 돌리는 것”이 아니다. 사용자 영향이 커지고 있다면 먼저 피해를 줄이고, 그다음 원인을 좁힌다.
여기서는 ALB, Kubernetes, feature flag API 명령을 길게 적지 않는다. 그 명령은 팀 운영 문서에 있어야 한다. 이 문서의 학습 가치는 “Event Loop Lag가 사용자 영향으로 번질 때 어떤 개념을 먼저 의심하는가”에 있다.
이 절은 선택이다. 첫 독서에서는 코드를 눈으로 따라가도 된다. 직접 실행하면 실행 순서와 blocking 감각이 더 빨리 잡힌다.
Promise.resolve().then(() => console.log("1. Promise microtask"));setTimeout(() => console.log("2. setTimeout macrotask"), 0);process.nextTick(() => console.log("3. nextTick"));console.log("4. sync");
// 예상 핵심 순서:// 4. sync// 3. nextTick// 1. Promise microtask// 2. setTimeout macrotasksetInterval(() => { console.log("tick", Date.now());}, 100);
setTimeout(() => { const start = Date.now(); while (Date.now() - start < 2_000) { // Event Loop를 2초 동안 점유한다. } console.log("blocking done");}, 1_000);1초 뒤 while이 시작되면 setInterval 출력이 2초 동안 멈춘다. 이것이 “한 요청의 CPU 작업이 다른 요청까지 지연시킨다”는 말을 가장 작게 재현한 예다.
setImmediate와 I/O 실험const fs = require("node:fs");
fs.readFile(__filename, () => { setTimeout(() => console.log("timeout"), 0); setImmediate(() => console.log("immediate"));});I/O 콜백 안에서는 poll 다음 check phase가 오므로 setImmediate가 먼저 실행되는 것을 확인할 수 있다.
운영 도구는 중요하지만, L0 첫 문서의 본문을 지배하면 학습 흐름이 끊긴다. 아래는 개념과 연결되는 최소 기억 단위다.
| 보고 싶은 것 | 도구 후보 | 해석 포인트 |
|---|---|---|
| Lag 수치 | perf_hooks.monitorEventLoopDelay | 예약된 콜백이 얼마나 늦게 실행되는가 |
| 바쁜 비율 | performance.eventLoopUtilization() | 루프가 측정 구간 동안 얼마나 계속 바빴는가 |
| CPU hot path | flamegraph, clinic flame, 0x | 어떤 함수가 CPU 시간을 많이 먹는가 |
| 증상 분류 | clinic doctor | Event Loop delay, I/O, memory 중 어느 축이 강한가 |
| 배포 영향 | trace, OpenTelemetry span | 특정 endpoint, 고객, job이 lag와 같이 움직이는가 |
도구를 돌리기 전 질문은 항상 같다. “지금 느린 것은 기다림인가, 메인 스레드 점유인가?”
ALB target 제외, Kubernetes rollout, feature flag 변경, 특정 endpoint 차단 같은 명령은 팀마다 다르다. 이 문서에 긴 명령을 넣어도 실제 운영 환경과 맞지 않으면 오히려 위험하다.
대신 이 문서에서 남겨야 할 학습 가치는 다음이다.
| 비교 | 핵심 차이 |
|---|---|
| 싱글 스레드 vs 비동기 | JS 메인 실행은 하나지만, I/O 대기는 커널/libuv에 맡겨 다른 콜백을 처리할 수 있다 |
| I/O-bound vs CPU-bound | I/O-bound는 기다림이 문제고, CPU-bound는 메인 스레드 점유가 문제다 |
| Promise microtask vs macrotask | Promise는 다음 phase 작업보다 먼저 실행된다 |
process.nextTick vs Promise | Node.js에서는 nextTick이 Promise microtask보다 먼저 비워진다 |
setImmediate vs setTimeout(0) | I/O 콜백 안에서는 setImmediate가 먼저인 경우가 많지만, 최상위에서는 순서를 보장하지 않는다 |
| Worker Threads vs Cluster | Worker는 요청 내부의 CPU 작업 분리에 가깝고, Cluster는 HTTP 서버 프로세스를 여러 개 띄우는 방식이다 |
| child_process vs Worker Threads | child_process는 프로세스 격리가 강하고, Worker는 같은 프로세스 안에서 메시지 기반 병렬 실행을 한다 |
| BullMQ vs Worker Threads | BullMQ는 긴 작업, 재시도, 예약, 워커 수평 확장에 맞고, Worker는 즉시성 있는 CPU 분리에 맞다 |
UV_THREADPOOL_SIZE가 효과 있는 작업과 효과 없는 작업을 구분할 수 있다.process.nextTick, Promise microtask, setImmediate, setTimeout(0)의 우선순위를 예측할 수 있다.async/await이 CPU 작업을 자동으로 분리하지 않는 이유를 설명할 수 있다.libuv, Reactor pattern, Event Demultiplexer, epoll, kqueue, IOCP, Event Loop Lag, Event Loop Utilization, monitorEventLoopDelay, eventLoopUtilization, process.nextTick, microtask queue, macrotask queue, setImmediate, setTimeout(0), UV_THREADPOOL_SIZE, Worker Threads, child_process, Cluster, BullMQ, ReDoS, backpressure, stream processing
setImmediate/timer 차이를 볼 때 가장 먼저 확인한다.monitorEventLoopDelay와 eventLoopUtilization 측정 API를 확인한다.Node.js Event Loop (지금 여기) ↓TypeScript Compiler Basics (런타임 전에 타입 정보가 어떻게 사라지는가) ↓DI / IoC (객체 생성과 의존성 연결을 프레임워크가 맡는 이유) ↓NestJS Discovery Module (런타임 메타데이터를 스캔해 확장 포인트를 만드는 방식)이 문서 다음에 TypeScript compiler를 배우면 “타입은 런타임에 남지 않는다”는 감각이 붙는다. 그다음 DI/IoC와 NestJS Discovery Module로 넘어가면, NestJS가 런타임에서 provider와 metadata를 어떻게 조립하는지 이해하기 쉬워진다. Event Loop는 그 모든 코드가 실제로 실행되는 바닥이다.
| 항목 | 핵심 내용 |
|---|---|
| Event Loop | 준비된 콜백을 phase 순서대로 실행하는 Node.js의 중심 루프 |
| Reactor 패턴 | 연결마다 스레드를 만들지 않고, 준비된 I/O 이벤트만 디스패치하는 동시성 모델 |
| libuv | OS별 I/O 감시와 Thread Pool을 추상화하는 Node.js 기반 라이브러리 |
| poll phase | I/O 이벤트를 기다리고 준비된 콜백을 처리하는 핵심 phase |
| microtask | Promise와 nextTick처럼 다음 phase 전에 비워지는 높은 우선순위 작업 |
| CPU-bound | 메인 스레드를 직접 점유하므로 async/await만으로 해결되지 않는다 |
| Thread Pool | 파일 I/O, DNS, crypto 등 일부 native 작업이 공유하며, 네트워크 I/O 만능 해법이 아니다 |
| Worker/Queue | CPU 작업이 길거나 재시도가 필요할 때 메인 Event Loop 밖으로 분리하는 선택지 |
| Lag/ELU | Event Loop가 얼마나 늦고 바쁜지 보는 기본 수치 |
5줄 핵심
process.nextTick과 Promise microtask는 timers, poll, check 같은 phase 작업보다 먼저 실행된다.UV_THREADPOOL_SIZE는 파일 I/O, DNS, crypto 같은 Thread Pool 작업에만 의미가 있고 네트워크 I/O 만능 튜닝값이 아니다.최종 수정: 2026-04-13