Node.js는 싱글 스레드인데, 어떻게 수만 건의 동시 요청을 처리할 수 있을까요?

멀티스레드 없이 고성능을 내는 구조는 사실 Node.js만의 발명이 아닙니다. Redis도 같은 전략을 씁니다. 핵심은 "CPU가 빠르면, 진짜 병목은 I/O 대기"라는 사실에 있습니다.

개념 정의

Node.js 는 Chrome V8 엔진 위에서 동작하는 서버사이드 JavaScript 런타임입니다. 핵심은 비동기 논블로킹 I/O 모델 로, 하나의 스레드가 이벤트 루프를 돌면서 수천 개의 연결을 동시에 관리합니다.

3개의 핵심 구성 요소

Node.js는 크게 세 가지로 이루어져 있습니다.

PLAINTEXT
┌─────────────────────────────────────┐
│          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와 같은 원리

이 부분이 면접에서 가장 많이 혼동되는 지점입니다. 단계별로 살펴보겠습니다.

  1. JavaScript 실행은 CPU 바운드 작업입니다. V8이 이를 매우 빠르게 처리합니다.
  2. 서버 애플리케이션에서 시간이 오래 걸리는 건 대부분 I/O 대기 입니다 — DB 쿼리, 파일 읽기, 네트워크 호출.
  3. I/O 대기 시간 동안 스레드가 블로킹되면 자원 낭비입니다. 멀티스레드는 이 낭비를 스레드를 늘려서 해결합니다.
  4. Node.js는 다른 접근을 합니다. I/O 요청을 OS에 위임하고, 완료 알림이 올 때까지 다른 작업을 처리 합니다.
  5. 이 "위임 → 다른 일 → 완료 시 콜백 실행" 사이클을 이벤트 루프 가 관리합니다.

Redis가 싱글 스레드로 초당 수십만 건을 처리하는 원리와 동일합니다. "CPU가 빠르면 진짜 병목은 I/O 대기" — 이 대기를 논블로킹으로 처리하면 멀티스레드가 필요 없습니다.

이벤트 루프의 6단계

이벤트 루프는 무한히 반복되는 사이클입니다. 각 사이클(tick)은 6개의 페이즈를 순서대로 실행합니다.

순서페이즈처리하는 콜백
1timerssetTimeout, setInterval 콜백
2pending callbacksOS 레벨 콜백 (TCP 에러 등)
3idle, prepare내부용 (직접 사용 안 함)
4pollI/O 콜백 (파일 읽기, 네트워크 등)
5checksetImmediate 콜백
6close callbackssocket.on('close')

** 핵심은 poll 페이즈입니다.** 대부분의 I/O 콜백이 여기서 실행됩니다. poll 큐가 비어 있으면 새로운 I/O 이벤트가 올 때까지 대기하거나, timers나 check 페이즈에 예약된 작업이 있으면 넘어갑니다.

다음 코드로 실행 순서를 확인할 수 있습니다.

JS
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 환경 변수로 조절할 수 있습니다.

BASH
UV_THREADPOOL_SIZE=8 node server.js

파일 시스템 작업이 많은 서버라면 기본값 4개가 병목이 될 수 있습니다.

주의할 점

CPU 집약적 작업이 이벤트 루프를 막는다

이벤트 루프는 하나의 스레드에서 돌기 때문에, CPU를 오래 점유하는 작업은 전체 서버를 멈춥니다.

JS
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()은 현재 페이즈가 끝나기 전에 즉시 실행됩니다. 재귀적으로 호출하면 이벤트 루프가 다음 페이즈로 넘어가지 못합니다.

JS
// 이벤트 루프가 영원히 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 바운드 애플리케이션
댓글 로딩 중...