Node.js 아키텍처 — V8, libuv, 이벤트 루프의 동작 원리
Node.js는 싱글 스레드인데, 어떻게 수만 건의 동시 요청을 처리할 수 있을까요?
멀티스레드 없이 고성능을 내는 구조는 사실 Node.js만의 발명이 아닙니다. Redis도 같은 전략을 씁니다. 핵심은 "CPU가 빠르면, 진짜 병목은 I/O 대기"라는 사실에 있습니다.
개념 정의
Node.js 는 Chrome V8 엔진 위에서 동작하는 서버사이드 JavaScript 런타임입니다. 핵심은 비동기 논블로킹 I/O 모델 로, 하나의 스레드가 이벤트 루프를 돌면서 수천 개의 연결을 동시에 관리합니다.
3개의 핵심 구성 요소
Node.js는 크게 세 가지로 이루어져 있습니다.
┌─────────────────────────────────────┐
│ JavaScript 코드 │
├─────────────────────────────────────┤
│ V8 엔진 (JS → 기계어 컴파일) │
├─────────────────────────────────────┤
│ Node.js 바인딩 (C++ 래퍼) │
├─────────────────────────────────────┤
│ libuv (이벤트 루프 + 스레드 풀) │
├─────────────────────────────────────┤
│ OS 커널 (epoll / kqueue / IOCP) │
└─────────────────────────────────────┘
**V8 엔진 **: JavaScript 코드를 기계어로 JIT 컴파일합니다. 인터프리터가 아니라 컴파일러이기 때문에 실행 속도가 빠릅니다.
**Node.js 바인딩 **: JavaScript에서 파일 시스템, 네트워크 같은 OS 기능을 사용할 수 있게 V8과 libuv를 연결하는 C++ 래퍼입니다.
libuv: 비동기 I/O를 처리하는 C 라이브러리입니다. 이벤트 루프를 구현하고, OS별로 다른 비동기 API(Linux의 epoll, macOS의 kqueue)를 추상화합니다.
왜 싱글 스레드로 빠른가 — Redis와 같은 원리
이 부분이 면접에서 가장 많이 혼동되는 지점입니다. 단계별로 살펴보겠습니다.
- JavaScript 실행은 CPU 바운드 작업입니다. V8이 이를 매우 빠르게 처리합니다.
- 서버 애플리케이션에서 시간이 오래 걸리는 건 대부분 I/O 대기 입니다 — DB 쿼리, 파일 읽기, 네트워크 호출.
- I/O 대기 시간 동안 스레드가 블로킹되면 자원 낭비입니다. 멀티스레드는 이 낭비를 스레드를 늘려서 해결합니다.
- Node.js는 다른 접근을 합니다. I/O 요청을 OS에 위임하고, 완료 알림이 올 때까지 다른 작업을 처리 합니다.
- 이 "위임 → 다른 일 → 완료 시 콜백 실행" 사이클을 이벤트 루프 가 관리합니다.
Redis가 싱글 스레드로 초당 수십만 건을 처리하는 원리와 동일합니다. "CPU가 빠르면 진짜 병목은 I/O 대기" — 이 대기를 논블로킹으로 처리하면 멀티스레드가 필요 없습니다.
이벤트 루프의 6단계
이벤트 루프는 무한히 반복되는 사이클입니다. 각 사이클(tick)은 6개의 페이즈를 순서대로 실행합니다.
| 순서 | 페이즈 | 처리하는 콜백 |
|---|---|---|
| 1 | timers | setTimeout, setInterval 콜백 |
| 2 | pending callbacks | OS 레벨 콜백 (TCP 에러 등) |
| 3 | idle, prepare | 내부용 (직접 사용 안 함) |
| 4 | poll | I/O 콜백 (파일 읽기, 네트워크 등) |
| 5 | check | setImmediate 콜백 |
| 6 | close callbacks | socket.on('close') 등 |
** 핵심은 poll 페이즈입니다.** 대부분의 I/O 콜백이 여기서 실행됩니다. poll 큐가 비어 있으면 새로운 I/O 이벤트가 올 때까지 대기하거나, timers나 check 페이즈에 예약된 작업이 있으면 넘어갑니다.
다음 코드로 실행 순서를 확인할 수 있습니다.
setTimeout(() => console.log('1. timer'), 0);
setImmediate(() => console.log('2. immediate'));
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('3. timer in I/O'), 0);
setImmediate(() => console.log('4. immediate in I/O'));
});
I/O 콜백 안에서는 setImmediate가 항상 setTimeout보다 먼저 실행됩니다. poll 페이즈 다음이 check 페이즈이기 때문입니다. 반면 최상위 레벨에서는 1번과 2번의 순서가 보장되지 않습니다.
libuv의 스레드 풀 — "진짜" 싱글 스레드는 아니다
"Node.js = 싱글 스레드"는 반만 맞는 말입니다.
JavaScript 실행 은 싱글 스레드가 맞습니다. 하지만 libuv는 내부적으로 스레드 풀(기본 4개) 을 가지고 있습니다. OS가 비동기 API를 제공하지 않는 작업은 이 스레드 풀에서 처리합니다.
| 처리 방식 | 작업 유형 |
|---|---|
| OS 비동기 API (epoll/kqueue) | 네트워크 I/O, TCP/UDP 소켓 |
| libuv 스레드 풀 | 파일 시스템, DNS lookup, 압축 |
스레드 풀 크기는 UV_THREADPOOL_SIZE 환경 변수로 조절할 수 있습니다.
UV_THREADPOOL_SIZE=8 node server.js
파일 시스템 작업이 많은 서버라면 기본값 4개가 병목이 될 수 있습니다.
주의할 점
CPU 집약적 작업이 이벤트 루프를 막는다
이벤트 루프는 하나의 스레드에서 돌기 때문에, CPU를 오래 점유하는 작업은 전체 서버를 멈춥니다.
app.get('/heavy', (req, res) => {
// 피보나치 계산 — 이벤트 루프 블로킹!
const result = fibonacci(45);
res.json({ result });
});
app.get('/health', (req, res) => {
// /heavy 처리 중에는 이 요청도 응답 못 함
res.json({ status: 'ok' });
});
/heavy 요청 하나가 이벤트 루프를 점유하면, 다른 모든 요청이 대기합니다. 해결 방법은 worker_threads 모듈로 별도 스레드에서 실행하거나, 작업을 별도 프로세스로 분리하는 것입니다.
process.nextTick()의 기아(starvation) 문제
process.nextTick()은 현재 페이즈가 끝나기 전에 즉시 실행됩니다. 재귀적으로 호출하면 이벤트 루프가 다음 페이즈로 넘어가지 못합니다.
// 이벤트 루프가 영원히 nextTick만 처리
function recursive() {
process.nextTick(recursive);
}
recursive(); // ← I/O 콜백이 영원히 실행되지 않음
이런 경우 setImmediate()를 대신 사용하면 이벤트 루프가 정상적으로 순환합니다.
멀티스레드 비교 — 컨텍스트 스위칭 비용
멀티스레드 서버(Java의 Thread-per-request)와 비교하면 트레이드오프가 명확합니다.
| 항목 | 멀티스레드 (Java 등) | 이벤트 루프 (Node.js) |
|---|---|---|
| 동시성 모델 | 요청당 스레드 할당 | 하나의 스레드 + 비동기 I/O |
| 메모리 | 스레드당 ~1MB 스택 | 이벤트 루프 하나 |
| 컨텍스트 스위칭 | OS 레벨 오버헤드 | 없음 |
| CPU 집약 작업 | 자연스럽게 병렬 처리 | 이벤트 루프 블로킹 위험 |
| 적합한 워크로드 | CPU 바운드 | I/O 바운드 |
정리
| 항목 | 설명 |
|---|---|
| V8 엔진 | JavaScript를 기계어로 JIT 컴파일 |
| libuv | 이벤트 루프 구현 + OS별 비동기 API 추상화 + 스레드 풀 |
| 이벤트 루프 | 6개 페이즈를 순환하며 콜백을 실행 |
| 싱글 스레드의 의미 | JS 실행만 싱글 스레드, I/O는 OS/스레드풀에 위임 |
| 최대 약점 | CPU 집약 작업이 전체 서버를 블로킹 |
| 적합한 워크로드 | API 서버, 실시간 채팅 등 I/O 바운드 애플리케이션 |