✍️ 필사 모드: Node.js 이벤트 루프 & libuv 내부 구조 끝장 가이드 — 6단계 Phase, Microtask, Worker Threads, V8 힙까지 (2025)
한국어들어가며 — "싱글스레드"의 진실
Node.js 공식 문서 첫 페이지에는 "Node.js uses an event-driven, non-blocking I/O model that makes it lightweight and efficient"라고 쓰여 있다. 그리고 어느 튜토리얼에서나 "Node.js는 싱글스레드야"라는 문장이 나온다. 둘 다 반은 맞고 반은 틀리다.
JavaScript 실행 컨텍스트(V8)는 싱글스레드가 맞다. 하지만 Node.js 프로세스 전체는 절대 싱글스레드가 아니다. libuv의 worker thread pool이 기본 4개(설정에 따라 최대 1024개), V8 자체도 GC/컴파일 스레드를 별도로 돌린다. node --v8-options | grep -i thread 찍어보면 내부 스레드 힌트가 수십 개 나온다. htop으로 단순한 Express 서버를 띄워도 스레드가 7~10개 떠 있다.
그럼에도 개발자가 쓰는 JS 코드는 하나의 이벤트 루프 스레드에서 순차 실행된다. 이 루프는 libuv가 관리하고, V8이 JS 콜백을 실행하고, 다시 libuv로 돌아온다. 이 글은 그 한 루프의 6단계 phase, microtask/macrotask 우선순위, setImmediate vs setTimeout의 미묘한 경쟁, async/await가 내부적으로 어떻게 번역되는지, CPU-bound 작업이 루프를 블록했을 때 어떻게 대응하는지 까지 Node.js의 내부 동작을 처음부터 해부한다.
20년 동안 Rails/Django처럼 "요청 당 스레드/프로세스" 모델로 서버를 짜던 사람이 Node.js를 처음 만나면 어리둥절해한다. "왜 한 스레드로 10만 connection을 버티지?"에 대한 진짜 답은 OS 커널의 I/O 멀티플렉싱(epoll/kqueue/IOCP) + libuv의 추상화 + V8의 빠른 클로저 호출이라는 3층 구조에 있다.
1. Node.js 아키텍처 전체 조감
┌──────────────────────────────────────────────────────┐
│ 사용자 JavaScript 코드 (app.js) │
└──────────────────────────────────────────────────────┘
↕ (bindings)
┌──────────────────────────────────────────────────────┐
│ Node.js Core Modules (JS + C++) │
│ fs, net, http, crypto, stream, ... │
└──────────────────────────────────────────────────────┘
↕
┌──────────────┐ ┌──────────────┐ ┌───────────────┐
│ V8 │ │ libuv │ │ OpenSSL/zlib │
│ (JS Engine) │ │ (Event Loop) │ │ (Crypto/Gzip)│
└──────────────┘ └──────────────┘ └───────────────┘
↕
┌──────────────────────────────────────────────────────┐
│ OS Kernel (Linux: epoll, macOS: kqueue, Win: IOCP) │
└──────────────────────────────────────────────────────┘
- V8: Google의 JS 엔진. 코드 파싱, 바이트코드(Ignition), JIT 컴파일(Sparkplug/Maglev/TurboFan), 가비지 컬렉션(Orinoco).
- libuv: Node.js를 위해 Joyent가 의뢰했던 C 라이브러리(원래는 Windows IOCP 지원용). 이벤트 루프, thread pool, DNS, 파일 I/O, TCP/UDP, TTY, 타이머 통합 관리.
- Bindings: V8이 JS에서 C++를 부를 수 있게 해주는 N-API/Node-API 계층.
중요: libuv가 하는 일은 사실 "운영체제마다 다른 I/O 멀티플렉싱 API를 공통 인터페이스로 감싸는 것"이다. epoll(Linux), kqueue(macOS/BSD), IOCP(Windows), event ports(Solaris) — 이 4개를 하나의 API로 통일한다.
2. libuv 이벤트 루프 — 6 phase의 정체
Node.js 공식 문서가 말하는 **"Event Loop phases"**는 다음과 같다:
┌───────────────────────────┐
┌─▶│ timers │ ← setTimeout / setInterval 콜백
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ pending callbacks │ ← 이전 루프에서 미뤄진 I/O 에러 콜백 등
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
│ │ idle, prepare │ ← 내부 용도 (사용자 코드 거의 없음)
│ └─────────────┬─────────────┘ ┌──────────────┐
│ ┌─────────────▼─────────────┐ │ incoming: │
│ │ poll │◀─────┤ connections,│
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────▼─────────────┐ └──────────────┘
│ │ check │ ← setImmediate 콜백
│ └─────────────┬─────────────┘
│ ┌─────────────▼─────────────┐
└──┤ close callbacks │ ← socket.on('close') 등
└───────────────────────────┘
한 "틱(tick)"은 위를 한 바퀴 도는 것이다. 각 phase에는 FIFO 큐가 있고, phase에 들어가면 그 시점에 준비된 콜백들을 처리한 뒤 다음 phase로 넘어간다.
2.1 Timers
setTimeout(fn, 100)이 등록되면 libuv는 내부적으로 min-heap에 "만료 시각 = now + 100ms" 노드를 넣는다. 매 루프 틱의 timer phase에서 heap top이 현재 시각보다 작으면 pop해서 콜백을 실행한다.
중요한 점: setTimeout(fn, 100)은 "정확히 100ms 뒤"가 아니라 "100ms 이후 어느 시점에 루프가 돌 때" 실행된다. Poll phase가 오래 걸리면 지연된다. setTimeout(fn, 1)이 실제로는 5~15ms 뒤에 실행되는 경우가 흔하다.
2.2 Pending Callbacks
TCP 에러처럼 이전 틱에서 발생했지만 지금 실행해야 하는 시스템 콜백이 여기서 처리된다. 사용자 입장에선 거의 안 보이는 phase.
2.3 Idle, Prepare
Libuv 내부 용도. libuv가 외부 시스템(metrics 수집 등)에 hook을 제공하는 자리. 대부분의 앱은 여기에 뭘 등록하지 않는다.
2.4 Poll — 사실상 대부분의 시간을 보내는 곳
Poll phase에서 Node는 epoll_wait()(또는 kqueue/IOCP 대응)을 호출한다. 여기서 I/O 이벤트를 기다린다.
1. poll queue에 있는 I/O 콜백들을 전부 실행
2. 큐가 비었다면:
a. setImmediate()가 예약돼 있으면 → check phase로
b. timer가 만료 임박이면 → timer phase로
c. 아니면 → epoll_wait()로 OS에 "이벤트 오면 깨워줘" 블록
3. 새 이벤트가 오면 해당 콜백을 poll queue에 넣고 실행
**"Node가 idle 상태에서 CPU 0%인 이유"**가 여기 있다. 할 일이 없으면 epoll_wait()에서 커널 레벨로 블록되고, OS 스케줄러가 다른 프로세스에 CPU를 넘긴다.
2.5 Check
setImmediate()로 등록된 콜백들이 실행된다.
2.6 Close Callbacks
socket.destroy(), stream.end() 같은 리소스 정리 콜백이 실행된다. 'close' 이벤트 리스너들이 여기서 동작한다.
3. setImmediate vs setTimeout(fn, 0) — 미묘한 경쟁
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
이 코드의 출력 순서는 예측 불가능하다. 왜?
setTimeout(fn, 0)은 내부적으로setTimeout(fn, 1)로 clamp된다 (최소 1ms).- 지금 이벤트 루프가 main script를 다 실행하고 첫 틱에 들어갔을 때, timer phase에 도달하는 시점에 **"이미 1ms이 지났느냐"**에 따라 결과가 달라진다.
- 빠른 시스템에서는 "아직 1ms 안 지남 → timer 건너뜀 → check → setImmediate 실행"이 되고, 느린 시스템에서는 반대.
확정적인 순서가 필요한 경우 — I/O 콜백 내부에서는:
const fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
})
이 경우 항상 immediate 먼저다. fs.readFile 콜백이 poll phase에서 실행된 뒤, 다음 phase는 check(= setImmediate)이기 때문이다. Timer phase는 한 바퀴 돌아서 다음 틱에야 온다.
4. Microtask — 이벤트 루프 밖의 세계
4.1 두 가지 microtask 큐
Node에는 사실상 이벤트 루프 phase 사이에 매번 실행되는 microtask 단계가 있다:
process.nextTick큐: Node 고유, Promise보다 우선- Promise microtask 큐: V8의 표준 Promise/async 큐
처리 순서:
각 phase의 콜백 1개 실행 후
→ process.nextTick 큐 모두 비우기
→ Promise microtask 큐 모두 비우기
→ 다음 콜백
4.2 실험
setImmediate(() => console.log('1. setImmediate'))
setTimeout(() => console.log('2. setTimeout'), 0)
Promise.resolve().then(() => console.log('3. promise'))
process.nextTick(() => console.log('4. nextTick'))
console.log('5. sync')
출력:
5. sync
4. nextTick
3. promise
2. setTimeout (또는 1번이 먼저 올 수도)
1. setImmediate
규칙:
- 동기 코드가 먼저.
- 동기 끝난 뒤 microtask 큐를 비움. nextTick → Promise 순서.
- 이벤트 루프 phase로 진입.
- 각 phase 콜백 사이사이에도 microtask 큐가 비워진다.
4.3 process.nextTick이 위험한 이유
function explode() {
process.nextTick(explode)
}
explode()
이 코드는 무한루프다. 이벤트 루프는 next tick 큐가 완전히 빌 때까지 다음 phase로 넘어가지 않는다. 즉 I/O 콜백, 타이머가 전부 기아 상태(starvation)가 된다.
Promise.resolve().then(...)으로 재귀해도 같은 문제가 생긴다. 재귀적인 async 체인은 반드시 setImmediate 또는 setTimeout으로 루프에 "숨 쉴 틈"을 줘야 한다.
4.4 Node 11부터 달라진 점
Node 10까지는 phase 전체가 끝난 뒤에 microtask 큐를 한꺼번에 비웠다. 그래서 setTimeout 콜백 100개를 돌린 뒤에야 Promise가 돌아갔다. Node 11+는 각 콜백 사이마다 microtask를 비우도록 바뀌어서 브라우저와 동작이 일치한다. 오래된 튜토리얼과 현대 Node의 동작이 다른 주된 이유다.
5. Thread Pool — "싱글스레드 신화"의 균열
5.1 Worker Pool (UV_THREADPOOL_SIZE)
libuv는 기본 4개짜리 worker thread pool을 유지한다. 다음 작업들이 여기로 간다:
- 파일 I/O (
fs.*대부분) - DNS 조회 (
dns.lookup, Glibc의getaddrinfo사용) crypto.pbkdf2,bcrypt,scrypt,argon2같은 CPU-heavy cryptozlib압축/해제
process.env.UV_THREADPOOL_SIZE = 16 같이 환경변수로 늘릴 수 있다(최대 1024). 단, 이건 앱 시작 전에 세팅해야 반영된다.
5.2 왜 파일 I/O는 thread pool인가?
Linux에는 epoll이 있지만 일반 파일(정규 파일)은 epoll로 감시할 수 없다. 파일 읽기는 매번 블로킹이다(io_uring이 등장하기 전까지는). 그래서 libuv는 thread pool에서 blocking read/write를 돌리고, 완료되면 그 결과를 main event loop에 비동기로 통지한다.
반면 소켓은 epoll로 non-blocking 감시가 가능해서 thread pool을 쓰지 않는다. 이게 Node가 네트워크는 잘하지만 대량 파일 I/O에는 한계가 있는 이유다.
5.3 io_uring의 희망
Node 20+에서는 libuv가 Linux에서 io_uring 지원을 실험적으로 추가했다(UV_USE_IO_URING=1). io_uring은 파일 I/O도 진짜 비동기로 커널이 처리해주기 때문에 thread pool을 우회할 수 있다. 아직 완전 기본값은 아니지만, 점진적으로 대체될 것이다.
5.4 pool starvation
4개뿐인 thread pool이 장시간 crypto나 fs 작업으로 바쁘면, 다른 fs 호출이 전부 대기한다. 다음은 흔한 함정:
// 비밀번호 해싱이 pool을 전부 점유 → 다른 fs 읽기가 밀린다
for (const user of users) {
bcrypt.hash(user.password, 10, callback)
}
대책:
UV_THREADPOOL_SIZE늘리기 (CPU 코어 수에 맞추기)- CPU-bound 작업은 Worker Threads로 분리
- 동시성 한도 두기 (p-queue, bottleneck 등)
6. Worker Threads — 진짜 병렬 JS
6.1 왜 필요한가
libuv의 worker pool은 C++ 작업만 실행한다. JS 코드로 CPU 집약 연산(이미지 처리, 파싱, 암호화)을 할 때는 main event loop가 블록된다.
// ❌ 이벤트 루프 블록 → 모든 요청이 지연
app.post('/process', (req, res) => {
const result = heavyMatrixMultiplication(req.body.matrix) // 2초 걸림
res.json(result)
})
6.2 Worker Threads API
// main.js
const { Worker } = require('worker_threads')
const w = new Worker('./heavy.js', { workerData: matrix })
w.on('message', (result) => res.json(result))
// heavy.js
const { parentPort, workerData } = require('worker_threads')
parentPort.postMessage(heavyMatrixMultiplication(workerData))
각 worker는:
- 별도의 V8 isolate(별도 힙, 별도 이벤트 루프)
postMessage로 main과 통신 (내부는 structured clone)SharedArrayBuffer로 메모리 공유 가능 (Atomics로 동기화)
6.3 Worker vs Cluster vs 자식 프로세스
| 방식 | 프로세스 분리 | 메모리 공유 | 시작 비용 | 용도 |
|---|---|---|---|---|
| Worker Threads | ✘ (같은 프로세스) | SharedArrayBuffer 가능 | ~30 ms | CPU-bound JS 작업 |
| Cluster (fork) | ✔ | ✘ (IPC만) | ~100 ms | HTTP 서버 수평 스케일 |
| child_process.spawn | ✔ | ✘ (stdio/IPC) | ~100 ms+ | 외부 바이너리 실행 |
| child_process.fork | ✔ | ✘ (IPC만) | ~100 ms | 다른 Node 스크립트 실행 |
실용 가이드:
- HTTP 요청 처리량 ↑ → Cluster 또는 reverse proxy + 복수 인스턴스
- 한 요청당 무거운 JS 계산 → Worker Threads
- 외부 Python/Rust 바이너리 → spawn
6.4 Piscina — worker pool 라이브러리
Worker를 매번 새로 만들면 30~50ms씩 오버헤드가 쌓인다. Piscina는 worker pool 추상화로 재사용·자동 확장을 제공한다. Node 프로덕션에서 CPU-bound 작업을 돌리는 표준 방식이다.
7. async/await 내부 — Promise와 이벤트 루프
7.1 async 함수는 Promise를 반환하는 함수다
async function foo() {
return 42
}
// ≡
function foo() {
return Promise.resolve(42)
}
7.2 await는 resume point다
async function demo() {
console.log('A')
await fetchData()
console.log('B')
await fetchMore()
console.log('C')
}
V8은 이를 state machine으로 번역한다. 각 await는 함수 실행을 일시 중단하고 generator처럼 그 시점을 저장한다. Promise가 resolve되면 microtask 큐에 resume 작업이 들어가고, 이벤트 루프의 다음 microtask phase에서 실행된다.
그래서:
console.log('1')
demo()
console.log('2')
출력: 1, A, 2, B, C. demo()는 첫 await까지 동기 실행되고, await fetchData() 시점에 반환되어 2가 찍힌다. 이후 fetchData가 resolve되면 microtask로 B가 실행된다.
7.3 async는 공짜가 아니다
V8의 async 변환은 예전엔 promise 3개를 생성했다(함수 전체, await 포인트, 결과 unwrap용). 2018년 "zero-cost async"로 최적화되어 1개로 줄었지만, 여전히 generator 기반 state machine이라 맨 루프보다는 느리다.
Tight loop에서 await 쓰면 눈에 띄게 느려진다:
// 느림: 1만 번 루프 × await → 1만 microtask
for (const item of items) {
await validate(item)
}
// 빠름: 동시 병렬
await Promise.all(items.map(validate))
8. I/O 모델 비교 — 왜 Node가 10K 커넥션을 버티나
8.1 "Thread per Connection" 모델 (Rails, 전통 Java)
- 각 HTTP 요청 → 새 스레드
- 1만 동시 연결 → 1만 스레드 → 각 8MB 스택 → 80GB RAM 😱
- 컨텍스트 스위칭 비용도 폭발
8.2 Node (event-driven)
- 1 스레드 + epoll 감시 목록 1만 개의 fd
- 메모리 수백 MB로 1만 연결 처리
- CPU-bound 작업만 없다면 매우 효율
8.3 Go / Elixir (M:N)
- Go: goroutine(2KB 초기 스택) + M:N 스케줄러
- Elixir: Erlang lightweight process (초기 300~500 bytes)
- 둘 다 개발자가 "동기 코드 쓰듯" 짜도 런타임이 알아서 스케줄링
Node의 약점: CPU-bound 작업 또는 동기 블로킹이 들어오면 이벤트 루프 전체가 정지. Go/Elixir는 한 goroutine이 느려도 다른 것들은 잘 돈다.
Node의 강점: 생태계(npm 2M+ 패키지), 프론트/백 같은 언어, V8의 빠른 async 성능.
9. 이벤트 루프 블록 — 진단과 대응
9.1 Event Loop Lag 측정
const { monitorEventLoopDelay } = require('perf_hooks')
const h = monitorEventLoopDelay({ resolution: 20 })
h.enable()
setInterval(() => {
console.log('p99 delay (ms):', (h.percentile(99) / 1e6).toFixed(2))
h.reset()
}, 5000)
프로덕션에서 p99 event loop delay가 100ms 넘으면 사용자 체감 느림이 시작된다. Datadog, New Relic, APM 도구들은 기본 metric으로 제공한다.
9.2 CPU 프로파일링
$ node --inspect app.js
# chrome://inspect → Profiler → CPU profile
또는 0x 같은 flame graph 도구:
$ npx 0x app.js
V8 flame graph로 어느 JS 함수가 이벤트 루프를 오래 잡는지 한눈에 보인다.
9.3 블로킹 패턴 5가지 흔한 범인
- 큰 JSON parse/stringify (수 MB 이상)
- 해결: 스트리밍 JSON 파서(JSONStream, stream-json)
- 동기 파일 API 사용 (
fs.readFileSync,crypto.randomBytesSync)- 해결: async 버전 사용
- 정규식 catastrophic backtracking
- 해결: RE2(Google) 바인딩 사용, 또는 정규식 재작성
- 무거운 bcrypt on event loop
- 해결: worker thread로 분리
- 긴 for 루프 over 1M 엔트리
- 해결: 배치로 나눠
setImmediate로 양보
- 해결: 배치로 나눠
10. V8 — JS가 실제로 돌아가는 곳
10.1 컴파일 파이프라인 (2024~2025 기준)
Source
↓ Parser
AST
↓ Ignition (bytecode compiler)
Bytecode ──── (Interpreter 실행) ────┐
│ │
│ (hot function 감지) │
↓ │
Sparkplug (baseline JIT, 2021) │
↓ │
Maglev (mid-tier JIT, 2023) │
↓ │
TurboFan (optimizing JIT) │
↓ │
Machine code ────────────────────────┘
tiered JIT: 차가운 코드는 인터프리터, 따뜻한 코드는 Sparkplug, 뜨거운 코드는 TurboFan으로 올라간다. Maglev는 2023년 Chrome M117에 들어온 중간 단계로 컴파일 비용을 줄인다.
10.2 Hidden Class (Shape)
JS는 동적 타입이지만 V8은 객체마다 hidden class를 부여해 C++ vtable처럼 관리한다.
function Point(x, y) {
this.x = x
this.y = y
}
const p1 = new Point(1, 2) // Shape A
const p2 = new Point(3, 4) // Shape A (재사용!)
p2.z = 5 // Shape B로 전이
교훈: 객체 생성 후 property를 추가/삭제하면 shape가 바뀌어 최적화가 깨진다(deoptimization). Performance-critical 코드에선 property 순서와 타입을 고정하자.
10.3 GC — Orinoco
- Young generation (Scavenger): 새로 할당된 객체, 빠른 Minor GC
- Old generation (Mark-Sweep-Compact): 오래 살아남은 객체, Incremental/Concurrent 마킹으로 pause 최소화
--max-old-space-size=4096 옵션으로 old space 상한 조절. Node 기본값은 1.4GB (64bit) 정도. Docker 컨테이너 메모리 제한을 안 인식해서 OOM 나는 경우가 흔하다(Node 18+는 cgroup 감지 개선).
--expose-gc + global.gc()로 수동 GC 호출은 거의 항상 나쁜 아이디어다. V8의 자동 스케줄링이 거의 항상 더 똑똑하다.
11. libuv 내부 자료구조
libuv는 OS 독립성을 위해 다양한 자료구조를 쓴다:
| 용도 | 자료구조 |
|---|---|
| Timer | min-heap (삽입 O(log n), 최소 조회 O(1)) |
| Pending queue | intrusive doubly linked list (queue.h 매크로) |
| Active handles | 같은 intrusive list |
| Thread pool tasks | FIFO queue + condition variable |
| DNS resolution | ares (c-ares) + worker pool |
uv_loop_t 구조체 안에 위 자료들이 모여 있고, uv_run(loop, UV_RUN_DEFAULT)가 바로 6 phase를 순회하는 함수다. uv_run(loop, UV_RUN_ONCE)로 한 틱만 돌릴 수도 있고, 이는 테스트나 임베딩 시나리오에서 쓴다.
12. Stream API — backpressure의 핵심
12.1 왜 스트림이 중요한가
// ❌ 2GB 파일을 통째로 메모리에
const data = fs.readFileSync('/big.csv')
processAndUpload(data)
이러면 프로세스 OOM. 스트림은 청크(기본 16KB) 단위로 처리한다.
// ✅ 청크 단위
fs.createReadStream('/big.csv')
.pipe(csvParser())
.pipe(uploader)
.on('finish', () => console.log('done'))
12.2 Backpressure
producer가 consumer보다 빠르면 메모리가 터진다. Node stream은 **highWaterMark(기본 16KB)**를 넘으면 .write()가 false를 반환해서 producer에게 "멈춰"를 알린다. consumer가 drain되면 'drain' 이벤트가 발생하고, producer가 다시 쓰기 시작.
pipeline()(Node 10+) 또는 stream/promises의 await pipeline(...)이 이 메커니즘을 자동으로 처리해준다. 직접 .on('data') 쓰는 것보다 항상 안전하다.
12.3 Web Streams API
Node 18+에서는 WHATWG Web Streams도 표준으로 지원한다. Response.body처럼 브라우저와 동일한 API.
const res = await fetch(url)
for await (const chunk of res.body) {
process(chunk)
}
Fetch, Undici, Bun 등 최신 HTTP 스택은 Web Streams 우선이다. Node 전통의 Readable/Writable과 상호 변환은 Readable.fromWeb(...) / .toWeb()로 가능.
13. Cluster와 확장
13.1 Cluster 모듈
const cluster = require('cluster')
const os = require('os')
if (cluster.isPrimary) {
for (let i = 0; i < os.cpus().length; i++) cluster.fork()
} else {
require('./app') // 각 worker가 HTTP 서버 실행
}
모든 worker가 같은 TCP 포트를 share한다. Linux SO_REUSEPORT를 커널이 지원하면 커널이 connection을 라운드로빈 분배. 아니면 primary가 accept해서 분배(성능 낮음).
13.2 PM2, node cluster vs reverse proxy
PM2는 cluster를 래핑해서 프로세스 매니저(auto restart, log 통합) 기능 추가. 하지만 현대 클라우드 배포에서는 컨테이너 1개 = 1 Node 프로세스로 두고 Kubernetes/ECS가 스케일링을 처리하는 방식이 더 간단하다.
13.3 "Primary가 죽으면?"
Cluster primary가 OOM/크래시되면 worker들은 고아(orphan)가 된다. 운영에선 외부 supervisor(systemd, k8s)가 전체 프로세스 트리를 관리하도록 하는 편이 안전하다.
14. AbortController — 취소의 표준
Node 15+는 Web 표준 AbortController를 코어에서 지원한다.
const controller = new AbortController()
const { signal } = controller
fetch('/api', { signal })
fs.readFile('/big', { signal }, cb)
setTimeout(() => controller.abort(), 5000)
2025년 기준 거의 모든 비동기 API가 signal을 받는다. timeout/cancellation을 위해 custom promise race를 짜던 시대는 끝났다.
함정: abort된 후에도 Promise가 reject될 뿐 실제 resource 정리는 자동이 아닐 수 있다. 네트워크 요청의 경우 underlying socket이 close되지만, fs 작업은 이미 시작된 I/O는 끝까지 진행될 수 있다.
15. Diagnostics — 프로덕션 관찰
15.1 --inspect
$ node --inspect=0.0.0.0:9229 app.js
Chrome DevTools가 붙어서 breakpoint, profiler, heap snapshot 제공. 프로덕션에서는 방화벽/보안 그룹으로 외부 노출 차단 필수.
15.2 Heap Snapshot
const v8 = require('v8')
v8.writeHeapSnapshot('/tmp/heap.heapsnapshot')
Chrome DevTools Memory 탭에서 로드해서 leak 분석. detached DOM node는 브라우저 개념이지만 Node도 Array, Object, Closure retention 추적 가능.
15.3 Tracing (async_hooks, AsyncLocalStorage)
async_hooks는 비동기 자원 생성/실행/종료를 훅할 수 있다. AsyncLocalStorage는 그 위에 만들어진 "요청별 context" 저장소:
const { AsyncLocalStorage } = require('async_hooks')
const als = new AsyncLocalStorage()
app.use((req, res, next) => {
als.run({ reqId: crypto.randomUUID() }, next)
})
logger.info('processing') // 내부에서 als.getStore().reqId 참조
Python의 contextvars, Go의 context.Context에 해당. OpenTelemetry Node가 이걸 기반으로 trace를 전파한다.
15.4 Diagnostic Channel
Node 16+의 diagnostics_channel은 APM 도구와 Node 사이의 표준 hook point. http.client.request, http.server.request 같은 채널에 subscribe해서 모든 요청을 일관되게 관찰할 수 있다.
16. 보안 이슈와 런타임 정책
16.1 Permissions Model (Node 20+)
$ node --permission \
--allow-read=./data \
--allow-write=./logs \
app.js
Deno에서 영감 받은 권한 모델. fs/child_process/worker_threads 등에 제한을 둔다. 아직 실험적이지만 supply chain 공격(악성 npm 패키지) 완화에 유용.
16.2 npm 공격 표면
- typosquatting:
react-dom→reactdom같은 이름 - dependency confusion: 내부 패키지명과 public npm 이름 충돌
- post-install scripts:
npm install만 해도 임의 코드 실행
대응:
npm ci+ lockfile 강제npm install --ignore-scripts(필요할 때만 script 허용)- Socket, Snyk, Dependabot으로 스캔
- 내부 레지스트리(Artifactory, Verdaccio) 활용
16.3 Prototype Pollution
// 위험: user input으로 __proto__ 설정
Object.assign(obj, JSON.parse(userInput))
// {"__proto__": {"isAdmin": true}}
Node 22+는 Object.create(null)로 만든 객체나 hasOwn() 사용을 권장. 라이브러리 선택 시 prototype pollution CVE 이력 확인.
17. 현대 Node 생태계 — Bun, Deno와의 관계
17.1 Bun
- Zig로 작성된 new runtime
- JavaScriptCore(Safari 엔진) 기반 — V8이 아님
- libuv 대신 직접 구현한 event loop
- 시작 시간, HTTP 서버 성능 등에서 Node 대비 2~4배 빠른 벤치 다수
- Node 호환 모드 (built-in
node_modules대체) - 단점: 생태계 호환성, 프로덕션 트랙 레코드 부족
17.2 Deno
- Ryan Dahl(Node 창시자)의 "Node에서 후회한 점"을 고친 런타임
- V8 + Rust로 재구현, tokio 기반 async
- 기본 TypeScript, 권한 모델, URL import
- Deno 2 (2024)에서 Node 호환성 대폭 강화 (npm:, node: import)
- Fresh, Oak 프레임워크
17.3 Node의 대응
- Built-in test runner (Node 20+
node:test) - Built-in
--watchmode (nodemon 대체) --env-file(dotenv 대체)- Permission model
- Web Streams, fetch, WebSocket 표준화
결론: Node는 여전히 가장 성숙한 서버 런타임이지만, Bun과 Deno의 압력으로 빠르게 웹 표준과 DX를 개선하고 있다. 2025년 시점에서는 "Node가 기본, Bun은 실험/빠른 프로토타입, Deno는 TS 중심 신규 프로젝트" 정도가 현실적인 구도다.
18. 실전 체크리스트
프로덕션에 나가기 전
-
--max-old-space-size를 컨테이너 메모리에 맞게 설정 - Event loop lag 모니터링 (APM 또는
perf_hooks) -
UV_THREADPOOL_SIZE를 CPU 코어 수에 맞춤 (디폴트 4는 작은 편) - Graceful shutdown: SIGTERM에
server.close()+ in-flight 요청 처리 - Log rotation (pino + pino-roll 또는 외부 log shipper)
- HTTP keep-alive 설정 (Node 기본은 짧음)
-
unhandledRejection,uncaughtExceptionhandler 등록 - Health check endpoint + readiness probe (k8s)
- Container 안에서 PID 1 문제 인지 (init 프로세스 또는
--init) - npm audit / Socket 스캔 CI에 통합
성능 체크
- Sync API(
fs.readFileSync등) 제거 - 큰 JSON → streaming parser 고려
- Tight loop +
await→Promise.all로 병렬화 - CPU-bound 작업 → Worker Threads
- 데이터베이스 pool 크기 = 서비스 동시성에 맞게 (너무 크면 DB 부하, 너무 작으면 대기)
- HTTP 클라이언트: undici (native), agent 공유
마무리 — 이벤트 루프를 머릿속에 그리기
Node.js를 잘 쓰려면 이벤트 루프를 구체적인 그림으로 상상할 수 있어야 한다. 다음을 한 번에 떠올릴 수 있는가:
- HTTP 요청이 들어왔을 때 어느 phase에서 콜백이 실행되는가 (poll)
res.json(data)안의 JSON.stringify가 블록되면 어떻게 되는가 (전체 루프 정지)await db.query(...)시점에 이벤트 루프는 뭘 하는가 (다른 I/O 처리)process.nextTick을 반복 호출하면 왜 무한루프인가 (다음 phase로 못 넘어감)- Worker Thread와 Cluster Worker는 어떻게 다른가 (같은 프로세스 vs 다른 프로세스)
이걸 모두 그릴 수 있으면 **"왜 내 API가 느린가"**의 디버깅이 훨씬 쉬워진다. 프로파일러 flame graph를 봐도 "아, 여기서 루프가 200ms 잡혔네 → stream으로 바꿔야겠다"가 자연스럽게 나온다.
Node.js는 단순한 JS 런타임이 아니라 V8 + libuv + OS 커널의 3층 협연이다. 그리고 그 중심에는 20년간 진화한 이벤트 루프가 있다. 매일 npm run dev를 치면서도 이 내부를 모르면, 성능 문제나 이상 동작이 생겼을 때 결국 추측으로 땜질하게 된다.
다음 글에서는 WebSocket과 Server-Sent Events의 내부 구조 — 실시간 통신 프로토콜이 TCP 위에서 어떻게 동작하고, Socket.IO 같은 라이브러리가 어떤 fallback을 쓰고, 10만 동시 연결을 버티기 위한 설계 패턴(pub/sub, sticky session, Redis adapter)까지 파고든다. 이벤트 루프와 스트림을 이해했다면, 실시간은 그 위에 쌓인 또 하나의 층이다.
현재 단락 (1/356)
Node.js 공식 문서 첫 페이지에는 "Node.js uses an event-driven, non-blocking I/O model that makes it lightweigh...