콘텐츠로 이동

Node.js Event Loop

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

이 문서는 L0의 첫 토픽이다. 아직 운영 도구를 잘 몰라도 괜찮다. 첫 번째 읽기에서 붙잡아야 할 질문은 하나다.

Node.js는 왜 한 개의 메인 스레드로 여러 요청을 동시에 처리할 수 있는데, 어떤 코드는 그 장점을 한 번에 무너뜨리는가?

처음 읽을 때의 목표는 다음 네 가지다.

  1. Event Loop가 “비동기 문법”이 아니라 서버 런타임의 동시성 모델이라는 점을 설명한다.
  2. I/O 대기와 CPU 점유가 왜 완전히 다른 문제인지 구분한다.
  3. process.nextTick, Promise microtask, setImmediate, setTimeout의 실행 순서를 예측한다.
  4. 서버가 느려질 때 “증상 -> 의미 -> 먼저 의심할 개념”으로 좁혀 간다.

첫 독서에서 모든 운영 도구를 외울 필요는 없다. 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 이벤트와 예약된 콜백을 정해진 순서로 실행하게 해주는 런타임 루프다.

조금 더 풀어 쓰면 다음과 같다.

  • Event Loop: 실행할 콜백이 있는지 계속 확인하고, 준비된 콜백을 하나씩 실행하는 반복 구조다.
  • 논블로킹 I/O(non-blocking I/O): I/O가 끝날 때까지 메인 스레드를 세워 두지 않고, 커널이나 libuv에 감시를 맡긴 뒤 완료 이벤트를 나중에 받는 방식이다.
  • Reactor 패턴: 여러 I/O 소스를 감시하다가 준비된 이벤트만 핸들러로 디스패치하는 동시성 패턴이다.
  • libuv: Node.js가 OS별 비동기 I/O 차이를 숨기기 위해 사용하는 C 라이브러리다. Event Loop, 네트워크 I/O 감시, Thread Pool 같은 기반 기능을 제공한다.

용어

Event Loop

Call Stack이 비었을 때 timers, poll, check 같은 phase의 큐를 순서대로 확인하고 콜백을 실행하는 루프다.

Node.js의 '싱글 스레드 서버'가 여러 연결을 다룰 수 있는 핵심 실행 모델이다.

퀴즈

Event Loop가 빠른 이유는 무엇인가?

정답 보기

CPU가 특별히 빨라서가 아니라, I/O를 기다리는 동안 메인 스레드를 쉬게 두지 않고 다른 준비된 콜백을 처리하기 때문이다.

3. 선행 기술의 한계: thread-per-connection에서 Reactor로

섹션 제목: “3. 선행 기술의 한계: thread-per-connection에서 Reactor로”

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 패턴이다.

thread-per-connection과 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 메인 스레드는 준비된 콜백만 짧게 실행하게 만든다.

4. Reactor 패턴을 Node.js 안에서 보기

섹션 제목: “4. Reactor 패턴을 Node.js 안에서 보기”

Reactor 패턴의 핵심은 세 단어로 압축된다.

  1. 감시: 여러 I/O 소스를 OS 커널 인터페이스에 등록한다.
  2. 대기: 준비된 I/O가 생길 때까지 메인 스레드를 blocking하지 않는다.
  3. 디스패치: 준비된 이벤트가 오면 알맞은 JS 콜백을 실행한다.

이 사고 모델은 Node.js에만 갇히지 않는다. Nginx, Redis처럼 많은 연결을 적은 스레드로 처리하는 서버도 “준비된 이벤트만 빠르게 처리하고 오래 걸리는 일은 밖으로 밀어낸다”는 Reactor 계열의 감각으로 읽을 수 있다. 그래서 Event Loop를 배우면 특정 API 암기가 아니라 고동시성 서버가 대기 시간을 다루는 공통 언어를 얻는다.

용어

Event Demultiplexer

많은 I/O 소스 중 지금 처리 가능한 이벤트만 골라 알려주는 OS/런타임 구성요소다.

Linux의 epoll, macOS의 kqueue, Windows의 IOCP가 여기에 해당한다.

Node.js에서는 이 모델이 대략 이렇게 이어진다.

Node.js 논블로킹 I/O 흐름
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/Olibuv Thread Pool별도 스레드가 blocking 파일 작업을 수행하고 완료 이벤트를 돌려준다
DNS dns.lookuplibuv Thread PoolOS resolver 호출이 풀을 점유할 수 있다
crypto.pbkdf2, zlib 일부libuv Thread PoolCPU성 네이티브 작업을 풀에 맡기고 완료 콜백을 받는다
순수 JS 계산JS 메인 스레드Event Loop를 직접 점유한다

이 표에서 가장 많이 하는 오해가 나온다.

“Node.js는 비동기니까 무거운 작업도 알아서 백그라운드로 빠지겠지.”

아니다. 네트워크 I/O와 파일 I/O는 런타임이 분리할 수 있다. 하지만 내가 JS로 작성한 for 루프, 대형 JSON.parse, 복잡한 정규식, 대형 배열 정렬은 메인 스레드에서 실행된다. Event Loop가 그 코드를 실행하는 동안 다른 콜백은 아무것도 실행되지 못한다.

4.1 왜 async I/O는 CPU blocking을 해결하지 못하는가

섹션 제목: “4.1 왜 async I/O는 CPU blocking을 해결하지 못하는가”

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 안에서 돈다.

5. libuv Thread Pool과 UV_THREADPOOL_SIZE

섹션 제목: “5. libuv Thread Pool과 UV_THREADPOOL_SIZE”

libuv 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/컨테이너 복제 수까지 곱해 스레드 폭증을 계산해야 한다
1024libuv 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이 정답”은 아니다. 같은 부하를 다시 걸어 보고, 프로세스 수를 곱한 총 스레드 수가 호스트에 맞는지 확인해야 한다.

Terminal window
# 프로세스 시작 전에만 의미가 있다.
UV_THREADPOOL_SIZE=16 node dist/main.js
// 프로세스가 이미 시작된 뒤에는 실질 효과를 기대하면 안 된다.
process.env.UV_THREADPOOL_SIZE = "16";

운영 판단은 명령이 아니라 질문으로 시작한다.

  1. 이 병목이 Thread Pool을 쓰는 작업에서 생겼는가?
  2. 풀 대기 시간이 실제로 p99 latency에 영향을 주는가?
  3. 풀을 늘렸을 때 Event Loop Lag, CPU, 메모리, 스레드 수가 함께 악화되지 않는가?
  4. Cluster나 컨테이너 replica 수를 곱한 총 스레드 수가 감당 가능한가?

6. Event Loop phase: timers, poll, check를 중심으로 보기

섹션 제목: “6. Event Loop phase: timers, poll, check를 중심으로 보기”

Event Loop는 여러 phase를 순서대로 돈다. 모든 phase를 처음부터 같은 비중으로 외울 필요는 없다. 서버 코드를 읽을 때 가장 자주 쓰는 축은 timers, poll, check, 그리고 각 phase 사이의 microtask 처리다.

Node.js Event Loop phase 흐름

  1. timers

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

  2. pending callbacks

    일부 시스템 I/O 콜백처럼 다음 루프로 미뤄진 콜백을 처리한다.

  3. idle/prepare

    libuv 내부 준비 단계다. 애플리케이션 코드가 직접 쓰는 단계는 아니다.

  4. poll

    새 I/O 이벤트를 기다리고 준비된 I/O 콜백을 실행한다.

  5. check

    setImmediate 콜백을 실행한다.

  6. close callbacks

    socket close 같은 종료 후처리 콜백을 실행한다.

timers
-> pending callbacks
-> idle/prepare
-> poll
-> check
-> close callbacks
-> 다시 timers

서버가 “대기 중”인 대부분의 시간은 poll phase에 있다. poll phase는 준비된 I/O 콜백이 있으면 실행하고, 없으면 다음 이벤트나 타이머가 올 때까지 기다린다.

API 서버 관점에서는 다음처럼 읽으면 된다.

  • HTTP 요청이 들어오면 소켓 read 이벤트가 준비된다.
  • DB 응답이 돌아오면 DB 드라이버의 소켓 이벤트가 준비된다.
  • 파일 읽기가 끝나면 libuv Thread Pool이 완료 이벤트를 돌려준다.
  • 이 준비된 이벤트들의 JS 콜백이 poll 주변에서 실행된다.

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에서 실행된다.
  • I/O 콜백 안에서 예약하면 보통 setImmediatesetTimeout(0)보다 먼저 온다.
  • 메인 모듈 최상위에서 둘을 같이 예약하면 순서가 비결정적일 수 있다.

Node.js 실행 순서를 헷갈리게 만드는 이유는 phase 큐만 있는 것이 아니기 때문이다. 각 phase 콜백이 끝난 뒤 더 높은 우선순위의 큐가 비워진다.

큐/위치대표 API실행 감각
Call Stack현재 실행 중인 JS 함수가장 먼저 끝까지 실행된다
process.nextTick queueprocess.nextTick()현재 작업이 끝난 직후, Promise microtask보다 먼저 비워진다
Promise microtask queuePromise.then, queueMicrotask, await 이후 continuationnextTick 다음, 다음 macrotask 전에 실행된다
Macrotask/phase queuesetTimeout, I/O callback, setImmediateEvent 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에 따라 달라질 수 있음

여기서 확실한 부분은 세 가지다.

  1. 동기 코드 sync가 먼저다.
  2. process.nextTick이 Promise microtask보다 먼저다.
  3. Promise microtask는 timer나 I/O 같은 다음 phase 작업보다 먼저다.

7.1 process.nextTick은 왜 조심해야 하는가

섹션 제목: “7.1 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로 보내는 편이 더 낫다.

7.2 setTimeout(0)setImmediate의 반례

섹션 제목: “7.2 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가 모두 다르다는 것을 코드 리뷰에서 구분해야 한다.

8. CPU-bound와 I/O-bound를 구분하는 기준

섹션 제목: “8. CPU-bound와 I/O-bound를 구분하는 기준”

Node.js 성능 문제의 절반은 “이 작업이 CPU-bound인가, I/O-bound인가”를 잘못 판단해서 생긴다.

CPU-bound와 I/O-bound 판단

I/O-bound

대부분의 시간이 네트워크, DB, 파일, 외부 API 응답을 기다리는 데 쓰인다.

timeout, retry, connection pool, backpressure, 외부 의존성 latency를 먼저 본다.

CPU-bound

대부분의 시간이 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.parsesort가 오래 걸리면 그 시간만큼 모든 요청이 뒤로 밀린다. 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, execSyncCPU 또는 외부 프로세스 대기를 동기로 묶는다async API, Worker/child_process 분리

Node.js 공식 가이드의 예시처럼 50MB JSON 문자열은 JSON.stringify 약 0.7초, JSON.parse 약 1.3초만으로도 Event Loop를 오래 점유할 수 있다. 숫자를 외우라는 뜻이 아니라, “입력 크기가 커지면 비동기 함수 안의 동기 변환이 초 단위 전역 지연이 된다”는 감각을 잡으라는 뜻이다. 입력이 평소에는 작기 때문에 테스트에서 안 보이다가, 특정 고객 데이터나 악성 입력에서 갑자기 전체 서버 지연으로 나타난다.

8.4 알고리즘 개선이 Worker보다 먼저일 때

섹션 제목: “8.4 알고리즘 개선이 Worker보다 먼저일 때”

CPU-bound라고 해서 바로 Worker Threads를 붙이면 안 된다. Worker는 메인 스레드를 보호하지만, 느린 알고리즘을 빠른 알고리즘으로 바꾸지는 않는다.

AppSignal의 공개 벤치마크는 이 경계를 잘 보여준다.

접근관찰된 결과해석
메인 스레드에서 재귀 Fibonacci약 1,341 req/sCPU 작업이 Event Loop를 점유한다
Worker Pool 분리약 7,459 req/s메인 스레드 보호로 처리량이 오른다
알고리즘을 행렬 지수법으로 개선약 478,176 req/s복잡도 개선이 Worker 분리보다 훨씬 크다
개선된 알고리즘에 Worker 추가약 310,678 req/s메시지 전달 비용이 이득보다 커질 수 있다

결정 순서는 이렇다.

  1. 입력 크기 제한이나 pagination으로 일을 줄일 수 있는가?
  2. 알고리즘 복잡도를 낮출 수 있는가?
  3. 그래도 CPU 시간이 길다면 Worker Threads나 child_process로 분리할 수 있는가?
  4. 작업이 길고 재시도/스케줄링이 필요하다면 BullMQ 같은 외부 Queue가 맞는가?

9. CPU 작업을 어디로 보낼 것인가

섹션 제목: “9. CPU 작업을 어디로 보낼 것인가”

CPU 작업을 메인 스레드에서 빼는 선택지는 여러 개다. 이름만 외우면 실무에서 잘못 고르기 쉽다. 기준은 “얼마나 오래 걸리는가”, “격리가 얼마나 필요한가”, “HTTP 요청 생명주기 안에서 끝나야 하는가”, “재시도가 필요한가”다.

선택지맞는 상황피해야 할 상황
Worker Threads같은 Node.js 프로세스 안에서 짧은~중간 CPU 작업을 병렬 처리작업이 분 단위로 길거나, 크래시 격리가 매우 중요하거나, 외부 런타임이 필요할 때
child_processFFmpeg, Python 스크립트, 외부 CLI처럼 프로세스 격리가 필요한 작업작은 작업을 매우 자주 보내 IPC 비용이 더 클 때
ClusterHTTP 서버 자체를 여러 프로세스로 띄워 코어를 활용개별 요청 안의 CPU 작업을 자동으로 쪼개 주길 기대할 때
BullMQ / 외부 Queue이메일, 보고서 생성, 배치, 재시도, 예약, 워커 수평 확장이 필요한 작업요청 안에서 즉시 결과를 반환해야 하는 짧은 계산
CPU 작업 분리 결정 흐름
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를 가진다. 메인 스레드와는 메시지로 통신한다.

worker-example.js
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/배치 작업을 분리했다”는 점에서 중요하다.

10. Event Loop Lag와 ELU로 보는 숫자 감각

섹션 제목: “10. Event Loop Lag와 ELU로 보는 숫자 감각”

Event Loop 문제를 감으로만 말하면 원인 추적이 어렵다. 최소한 두 가지 수치를 구분해야 한다.

용어

Event Loop Lag

예약된 콜백이 기대한 시각보다 얼마나 늦게 실행됐는지 나타내는 지연 시간이다.

100ms 이상이면 사용자 요청 tail latency에 바로 영향을 줄 수 있다.

용어

Event Loop Utilization (ELU)

측정 구간 동안 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_hooksmonitorEventLoopDelay가 가장 직접적이다.

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로 잘 보인다.

11. 실패 신호: 증상에서 개념으로 되돌아가기

섹션 제목: “11. 실패 신호: 증상에서 개념으로 되돌아가기”

긴 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가 터졌을 때의 핵심은 “진단 도구를 멋지게 돌리는 것”이 아니다. 사용자 영향이 커지고 있다면 먼저 피해를 줄이고, 그다음 원인을 좁힌다.

  1. 영향이 큰 경로를 잠시 줄인다: rate limit, feature flag, pagination 상한, 위험 endpoint 차단.
  2. 최근 배포나 특정 고객 데이터처럼 조건이 분명하면 우회한다.
  3. Lag p99가 100ms 아래로 돌아오는지 확인한다.
  4. 정상화 후 flamegraph, trace, heap/CPU profile로 근본 원인을 찾는다.

여기서는 ALB, Kubernetes, feature flag API 명령을 길게 적지 않는다. 그 명령은 팀 운영 문서에 있어야 한다. 이 문서의 학습 가치는 “Event Loop Lag가 사용자 영향으로 번질 때 어떤 개념을 먼저 의심하는가”에 있다.

이 절은 선택이다. 첫 독서에서는 코드를 눈으로 따라가도 된다. 직접 실행하면 실행 순서와 blocking 감각이 더 빨리 잡힌다.

event-loop-order.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. sync");
// 예상 핵심 순서:
// 4. sync
// 3. nextTick
// 1. Promise microtask
// 2. setTimeout macrotask
blocking-demo.js
setInterval(() => {
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 작업이 다른 요청까지 지연시킨다”는 말을 가장 작게 재현한 예다.

const fs = require("node:fs");
fs.readFile(__filename, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
});

I/O 콜백 안에서는 poll 다음 check phase가 오므로 setImmediate가 먼저 실행되는 것을 확인할 수 있다.

13. 선택 부록: 도구와 운영 절차는 어떻게 압축해서 기억할까

섹션 제목: “13. 선택 부록: 도구와 운영 절차는 어떻게 압축해서 기억할까”

운영 도구는 중요하지만, L0 첫 문서의 본문을 지배하면 학습 흐름이 끊긴다. 아래는 개념과 연결되는 최소 기억 단위다.

Event Loop 문제에서 도구를 고르는 기준
보고 싶은 것도구 후보해석 포인트
Lag 수치perf_hooks.monitorEventLoopDelay예약된 콜백이 얼마나 늦게 실행되는가
바쁜 비율performance.eventLoopUtilization()루프가 측정 구간 동안 얼마나 계속 바빴는가
CPU hot pathflamegraph, clinic flame, 0x어떤 함수가 CPU 시간을 많이 먹는가
증상 분류clinic doctorEvent Loop delay, I/O, memory 중 어느 축이 강한가
배포 영향trace, OpenTelemetry span특정 endpoint, 고객, job이 lag와 같이 움직이는가

도구를 돌리기 전 질문은 항상 같다. “지금 느린 것은 기다림인가, 메인 스레드 점유인가?”

프로덕션 incident 절차를 본문에서 길게 다루지 않는 이유

ALB target 제외, Kubernetes rollout, feature flag 변경, 특정 endpoint 차단 같은 명령은 팀마다 다르다. 이 문서에 긴 명령을 넣어도 실제 운영 환경과 맞지 않으면 오히려 위험하다.

대신 이 문서에서 남겨야 할 학습 가치는 다음이다.

  • Lag p99가 SLO를 넘고 API p99도 같이 튀면 사용자 영향이 이미 시작됐다고 본다.
  • 원인 분석보다 피해 축소가 먼저일 수 있다.
  • 위험 경로의 입력 상한, pagination, feature flag 우회는 Event Loop 점유 시간을 줄이는 임시 완화다.
  • 정상화 후 CPU profile과 trace로 “어떤 handler가 루프를 오래 붙잡았는가”를 찾는다.
비교핵심 차이
싱글 스레드 vs 비동기JS 메인 실행은 하나지만, I/O 대기는 커널/libuv에 맡겨 다른 콜백을 처리할 수 있다
I/O-bound vs CPU-boundI/O-bound는 기다림이 문제고, CPU-bound는 메인 스레드 점유가 문제다
Promise microtask vs macrotaskPromise는 다음 phase 작업보다 먼저 실행된다
process.nextTick vs PromiseNode.js에서는 nextTick이 Promise microtask보다 먼저 비워진다
setImmediate vs setTimeout(0)I/O 콜백 안에서는 setImmediate가 먼저인 경우가 많지만, 최상위에서는 순서를 보장하지 않는다
Worker Threads vs ClusterWorker는 요청 내부의 CPU 작업 분리에 가깝고, Cluster는 HTTP 서버 프로세스를 여러 개 띄우는 방식이다
child_process vs Worker Threadschild_process는 프로세스 격리가 강하고, Worker는 같은 프로세스 안에서 메시지 기반 병렬 실행을 한다
BullMQ vs Worker ThreadsBullMQ는 긴 작업, 재시도, 예약, 워커 수평 확장에 맞고, Worker는 즉시성 있는 CPU 분리에 맞다
  • Event Loop를 “비동기 문법”이 아니라 Reactor 기반 런타임 모델로 설명할 수 있다.
  • thread-per-connection이 왜 메모리와 컨텍스트 스위칭 한계에 부딪히는지 말할 수 있다.
  • 네트워크 I/O, 파일 I/O, 순수 JS 계산이 각각 어떤 경로로 처리되는지 구분할 수 있다.
  • UV_THREADPOOL_SIZE가 효과 있는 작업과 효과 없는 작업을 구분할 수 있다.
  • timers, poll, check phase의 차이를 설명할 수 있다.
  • process.nextTick, Promise microtask, setImmediate, setTimeout(0)의 우선순위를 예측할 수 있다.
  • async/await이 CPU 작업을 자동으로 분리하지 않는 이유를 설명할 수 있다.
  • CPU-bound 작업에 대해 알고리즘 개선, Worker Threads, child_process, Cluster, BullMQ 중 무엇을 먼저 볼지 판단할 수 있다.
  • Event Loop Lag와 ELU 수치를 보고 첫 의심 범위를 좁힐 수 있다.

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

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 이벤트만 디스패치하는 동시성 모델
libuvOS별 I/O 감시와 Thread Pool을 추상화하는 Node.js 기반 라이브러리
poll phaseI/O 이벤트를 기다리고 준비된 콜백을 처리하는 핵심 phase
microtaskPromise와 nextTick처럼 다음 phase 전에 비워지는 높은 우선순위 작업
CPU-bound메인 스레드를 직접 점유하므로 async/await만으로 해결되지 않는다
Thread Pool파일 I/O, DNS, crypto 등 일부 native 작업이 공유하며, 네트워크 I/O 만능 해법이 아니다
Worker/QueueCPU 작업이 길거나 재시도가 필요할 때 메인 Event Loop 밖으로 분리하는 선택지
Lag/ELUEvent Loop가 얼마나 늦고 바쁜지 보는 기본 수치

5줄 핵심

  1. Node.js는 연결마다 스레드를 만들지 않고, Event Loop와 Reactor 패턴으로 준비된 I/O만 처리한다.
  2. I/O 대기는 논블로킹으로 분리할 수 있지만, JS CPU 코드는 직접 분리하지 않으면 메인 스레드를 막는다.
  3. process.nextTick과 Promise microtask는 timers, poll, check 같은 phase 작업보다 먼저 실행된다.
  4. UV_THREADPOOL_SIZE는 파일 I/O, DNS, crypto 같은 Thread Pool 작업에만 의미가 있고 네트워크 I/O 만능 튜닝값이 아니다.
  5. Event Loop Lag와 ELU가 튀면 “어떤 코드가 메인 스레드를 오래 붙잡았는가”부터 의심한다.

최종 수정: 2026-04-13