Language/JS(Node.js)

[Node.js] 동시성 모델의 모든 것

Joonfluence 2026. 3. 2.

Spring/Java 환경에서 일하다가 NestJS/Node.js를 병행하게 되면서, 그동안 "그냥 되니까" 넘어갔던 Node.js의 내부 동작 원리를 제대로 공부하기로 했다. Week 1에서는 Java와 Node.js의 동시성 모델 차이, 이벤트 루프의 실체, 그리고 실무에서 주의할 점까지 정리한다.


1. 두 세계의 출발점: 요청을 어떻게 처리할 것인가

웹 서버의 본질은 간단하다. 요청이 들어오면 처리하고 응답한다. 문제는 요청이 동시에 수천 개씩 들어올 때다. Java와 Node.js는 이 문제를 완전히 다른 방식으로 풀었다.

Java: Thread-per-Request

Java의 전통적인 방식(Spring MVC + Tomcat)은 요청 하나당 스레드 하나를 할당한다.

Client A ──→ [Thread-1] ──→ DB 쿼리 (블로킹) ──→ 응답
Client B ──→ [Thread-2] ──→ DB 쿼리 (블로킹) ──→ 응답
Client C ──→ [Thread-3] ──→ DB 쿼리 (블로킹) ──→ 응답

각 스레드는 I/O 작업(DB 쿼리, 파일 읽기 등)을 기다리는 동안 블로킹된다. 아무것도 안 하면서 메모리만 차지하고 있는 것이다. 스레드 하나당 약 1MB의 스택 메모리를 사용하니, 동시 접속 1만 명이면 스레드만으로 10GB가 필요하다. 현실적이지 않다.

Node.js: 싱글스레드 + 이벤트 루프

Node.js는 정반대의 접근을 택했다. 메인 스레드는 단 하나다. 모든 JavaScript 코드는 이 하나의 스레드에서 실행된다.

Client A ──→ [Main Thread] DB 쿼리 위임 → 다른 일 처리
Client B ──→ [Main Thread] DB 쿼리 위임 → 다른 일 처리
Client C ──→ [Main Thread] DB 쿼리 위임 → 다른 일 처리
                              ↓
              DB 응답 오면 콜백 실행

I/O 작업을 OS 커널이나 libuv에 위임하고, 결과가 돌아오면 콜백으로 처리한다. 스레드 하나로 수천 개의 동시 접속을 처리할 수 있는 이유다.

한눈에 비교

관점 Java (Spring MVC) Node.js
기본 모델 멀티스레드 (Thread-per-Request) 싱글스레드 + 이벤트 루프
I/O 처리 스레드가 블로킹되며 대기 논블로킹, 이벤트 기반으로 위임
CPU-bound 멀티코어 활용 우수 메인 스레드 블로킹 위험
I/O-bound 스레드 수만큼 동시 처리 적은 리소스로 높은 동시성
메모리 스레드당 ~1MB 스택 싱글 스레드로 효율적
에러 격리 스레드 단위로 격리 uncaught exception → 프로세스 전체 사망

2. 이벤트 루프: Node.js의 심장

"싱글스레드인데 어떻게 동시에 여러 요청을 처리하지?" — 이 질문의 답이 이벤트 루프다.

페이즈 구조

이벤트 루프는 아래 6개 페이즈를 무한히 순환한다.

   ┌───────────────────────────┐
┌─>│         timers            │  ← setTimeout, setInterval 콜백
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  ← 시스템 레벨 콜백 (TCP 에러 등)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  ← 내부용
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │          poll             │  ← I/O 완료 콜백 처리의 핵심
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │          check            │  ← setImmediate 콜백
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     close callbacks       │  ← socket.on('close') 등
│  └─────────────┬─────────────┘
│                │
└────────────────┘

복잡해 보이지만 핵심은 간단하다. 이벤트 루프의 본질적인 역할은 "I/O 완료됐어?" 확인하고 콜백을 실행하는 것이고, 그 일을 하는 게 poll 페이즈다. 나머지 페이즈들은 타이머, 즉시실행, 리소스 정리 같은 특수 콜백의 실행 순서를 보장하기 위해 분리해놓은 것이다.

poll 페이즈: 왜 핵심인가

poll은 이벤트 루프에서 유일하게 블로킹(대기)까지 하는 페이즈다.

  1. poll 페이즈 진입
  2. OS에 epoll_wait() 호출 → "완료된 I/O 있어?"
  3. 있으면 → 해당 콜백 즉시 실행
  4. 없으면 → 조건부로 대기 (이때 CPU를 거의 사용하지 않음)

다른 페이즈는 큐가 비면 바로 다음으로 넘어가지만, poll은 "할 일이 없으면 I/O 이벤트가 올 때까지 기다린다." Node.js 프로세스가 유휴 상태에서 CPU 0%인 이유가 바로 이것이다. poll에서 OS 레벨로 잠들어 있는 것이다.

단, 무한정 대기하지는 않는다. setImmediate가 예약되어 있거나 timer가 만료됐으면 대기 없이 바로 해당 페이즈로 이동한다.

poll의 이름은 어디서 왔나

"polling"(반복적으로 확인하기)과는 다르다. Linux 시스템 콜 poll()에서 온 이름이다. poll()은 여러 파일 디스크립터를 감시하다가 이벤트가 발생한 것을 알려주는 시스템 콜이다. polling이 "됐어? 됐어?" 반복 확인하며 CPU를 소모하는 busy-wait이라면, poll() 시스템 콜은 OS에게 맡기고 잠드는 것이다. 이름은 비슷하지만 동작은 정반대다.

I/O 요청과 수확의 분리

중요한 구분이 있다. I/O 요청을 보내는 것은 libuv이고, 완료된 I/O를 수거하는 것이 poll 페이즈다.

1. http.get('https://api.com/data', callback)
        ↓
2. libuv가 OS 커널에 I/O 등록 → 바로 리턴
        ↓
3. 이벤트 루프는 다른 작업 계속 처리
        ↓
4. 루프 돌면서 poll 페이즈 도달
        ↓
5. epoll_wait() → "커널아, 끝난 거 있어?"
        ↓
6. "이 소켓 데이터 왔어" → callback 실행

poll이 요청을 보내는 게 아니다. libuv가 커널에 맡겨놓은 작업들의 완료 여부를 poll에서 확인하고 콜백을 실행하는 구조다. 이벤트 루프는 특별한 트리거 없이 매 바퀴마다 자동으로 poll을 거치며, 이 반복이 Node.js 비동기 I/O의 전부다.


3. libuv: 이벤트 루프의 실체

libuv는 Node.js의 비동기 I/O를 담당하는 C 라이브러리다. 이벤트 루프의 실제 구현체이면서, OS별 비동기 I/O API를 추상화하는 레이어이기도 하다. Linux의 epoll, macOS의 kqueue, Windows의 IOCP를 하나의 통합 인터페이스로 제공한다.

스레드 풀: Node.js에도 스레드가 있다

"Node.js는 싱글스레드"라고 하지만, 실제로는 libuv 내부에 스레드 풀이 존재한다.

네트워크 I/O(HTTP, TCP, DNS 등)는 OS 커널의 비동기 API를 직접 사용하므로 스레드 풀이 필요 없다. 하지만 파일 시스템 I/O, DNS lookup(dns.lookup()), 일부 crypto 작업 등은 OS 레벨에서 비동기 지원이 불완전하기 때문에 libuv 스레드 풀에서 처리한다.

기본 4개 스레드의 한계

기본 스레드 풀 크기는 4개다. 이 말은 동시에 10개의 파일 읽기 요청이 들어오면, 4개만 동시에 처리하고 6개는 큐에서 대기한다는 뜻이다.

// 프로세스 시작 전에 설정해야 유효
process.env.UV_THREADPOOL_SIZE = 16; // 기본 4, 최대 1024

대부분의 웹 서버는 네트워크 I/O 위주이므로 이 제한이 병목이 되는 경우는 드물다. 하지만 이미지 처리, 파일 변환 등 파일 I/O가 많은 서비스라면 조정을 고려할 수 있다.


4. 마이크로태스크와 매크로태스크

이벤트 루프의 각 페이즈 사이에는 마이크로태스크 큐가 끼어 있다. 페이즈 전환이 일어날 때마다 마이크로태스크 큐를 전부 비운 뒤 다음 페이즈로 넘어간다.

우선순위

동기 코드 (콜스택)
    ↓ 전부 실행 후
process.nextTick 큐
    ↓ 전부 비운 후
Promise 마이크로태스크 큐
    ↓ 전부 비운 후
매크로태스크 (setTimeout, setImmediate, I/O 콜백)

process.nextTick은 마이크로태스크 중에서도 가장 높은 우선순위를 가진다. Promise보다 먼저 실행된다.

실행 순서 퀴즈

console.log('1: sync');
setTimeout(() => console.log('2: timeout'), 0);
setImmediate(() => console.log('3: immediate'));
process.nextTick(() => console.log('4: nextTick'));
Promise.resolve().then(() => console.log('5: promise'));
console.log('6: sync end');

정답:

1: sync
6: sync end
4: nextTick     ← 마이크로태스크 (최우선)
5: promise      ← 마이크로태스크 (nextTick 다음)
2: timeout      ← 매크로태스크 (이 둘의 순서는 비결정적)
3: immediate    ← 매크로태스크

동기 코드가 먼저 실행되고, 마이크로태스크(nextTick → Promise)를 전부 비운 뒤, 매크로태스크(setTimeout, setImmediate)가 실행된다.

setTimeout(fn, 0)setImmediate의 순서는 메인 모듈에서는 비결정적이다. 하지만 I/O 콜백 내부에서는 setImmediate가 항상 먼저 실행된다. I/O 콜백은 poll 페이즈에서 실행되고, 바로 다음이 check 페이즈(setImmediate)이기 때문이다.

const fs = require('fs');
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
  // 항상 immediate → timeout 순서
});

실무에서 이 순서가 중요한가?

솔직히, 대부분의 실무 코드는 async/await으로 순서를 명시적으로 제어하기 때문에 페이즈 순서를 신경 쓸 일이 거의 없다. setTimeout vs setImmediate의 순서에 의존하는 코드를 작성하고 있다면, 그건 설계가 잘못된 것이다.

하지만 이벤트 루프의 동작 원리를 이해하는 것은 다른 가치가 있다. 왜 async/await이 다른 코드를 블로킹하지 않는지 이해할 수 있고, 디버깅할 때 콜백 실행 순서를 추론할 수 있으며, 아래에서 다룰 nextTick 남용 문제도 이해할 수 있다.


5. nextTick의 위험성: 이벤트 루프 Starvation

process.nextTick이 마이크로태스크 중 최우선이라는 특성이 양날의 검이 된다. nextTick이 재귀적으로 계속 쌓이면, 마이크로태스크 큐가 영원히 비워지지 않아 이벤트 루프가 다음 페이즈로 넘어가지 못한다. poll에 도달하지 못하니 I/O 콜백도 실행되지 않는다. 이것이 이벤트 루프 starvation이다.

데모 1: I/O 먹통 (Starvation)

// setTimeout과 setImmediate를 등록해둠
setTimeout(() => console.log('timeout 실행됨!'), 0);
setImmediate(() => console.log('immediate 실행됨!'));

// nextTick 재귀
let count = 0;
function recursiveNextTick() {
  count++;
  if (count % 1_000_000 === 0) {
    console.log(`nextTick ${count.toLocaleString()}번 실행... 아직도 poll 못 감`);
  }
  process.nextTick(recursiveNextTick);
}
recursiveNextTick();

실행 결과: 1천만 번이 지나도 setTimeout과 setImmediate는 실행되지 않는다. nextTick 큐가 비워지지 않아 이벤트 루프가 timers 페이즈조차 넘어가지 못하기 때문이다.

이 상태에서 HTTP 서버를 띄워도 요청 처리가 불가능하다. 프로세스는 살아있지만 사실상 먹통이다. PM2 같은 프로세스 매니저도 프로세스가 살아있으니 재시작을 해주지 않는다. 모니터링에서도 잡히지 않고 조용히 죽어있는, 실무에서 가장 무서운 장애 유형이다.

데모 2: 힙 메모리 초과 (크래시)

const bigChunks = [];
for (let i = 0; i < 500_000; i++) {
  process.nextTick(() => {
    bigChunks.push(Buffer.alloc(1024 * 64)); // 64KB per callback
  });
}

실행 결과:

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

한 번에 대량의 nextTick을 등록하고 각 콜백이 메모리를 잡고 있으면, 힙 메모리가 초과되어 프로세스가 크래시된다.

데모 3: setImmediate는 안전하다

setTimeout(() => console.log('timeout 실행됨!'), 100);

let count = 0;
function recursiveImmediate() {
  count++;
  if (count > 5_000_000) {
    console.log('5백만 번 실행하는 동안 I/O 정상 동작!');
    process.exit(0);
  }
  setImmediate(recursiveImmediate);
}
recursiveImmediate();

실행 결과: setTimeout도 정상 실행되고, setImmediate도 5백만 번 문제없이 동작한다. setImmediate는 check 페이즈에서 실행되므로 매 루프마다 poll(I/O)을 거칠 수 있기 때문이다.

정리

패턴 증상 원인
nextTick 재귀 서버 먹통 (응답 불가) 이벤트 루프 starvation
nextTick 대량 + 메모리 프로세스 크래시 heap out of memory
setImmediate 재귀 정상 동작 poll을 막지 않음

Node.js 공식 문서에서도 setImmediate 사용을 권장하는 이유가 여기 있다.


6. UncaughtException: 싱글스레드의 대가

Java vs Node.js의 에러 격리

Java에서 하나의 스레드에서 예외가 발생하면, 그 스레드만 죽는다. 다른 스레드는 영향받지 않고 정상 작동한다.

Node.js에서 처리되지 않은 예외(uncaught exception)가 발생하면, 프로세스 자체가 죽는다. 싱글스레드이므로 메인 스레드가 곧 프로세스다. 해당 시점에 처리 중이던 모든 요청이 함께 사라진다.

// ❌ 이렇게 하면 안 됨 — 에러 무시하고 계속 실행
process.on('uncaughtException', (err) => {
  console.log('에러 났지만 무시할게~'); // 상태가 불확실한 채로 계속 동작
});

// ✅ 올바른 패턴 — 로그 남기고 graceful shutdown
process.on('uncaughtException', (err) => {
  logger.error('Uncaught Exception:', err);
  server.close(() => {
    process.exit(1);
  });
  setTimeout(() => process.exit(1), 5000); // 안전장치
});

이것이 Node.js에서 PM2, Cluster 모듈 같은 프로세스 매니저가 필수인 이유다.

CSR React 앱에서는?

"React는 클라이언트에서 돌아가니까 서버 에러랑 무관하지 않나?" — 절반만 맞다.

CSR이라도 Node.js 프로세스가 서빙에 관여하면 문제가 된다. Express로 정적 파일을 서빙하고 있다면, 프로세스가 죽으면 새 유저가 접속 자체를 못 한다. API 프록시가 같은 프로세스에 있다면 API 호출도 전부 실패한다.

진짜 상관없는 경우는 Nginx나 CDN이 빌드된 정적 파일을 직접 서빙하는 구조다. 이때 React 앱은 Node.js와 완전히 무관하게 브라우저에서 동작한다.

SSR(Next.js 등)이라면 매 요청이 서버를 거치므로 uncaught exception은 곧 전체 서비스 장애다. 핵심은 "CSR이냐 SSR이냐"가 아니라 "Node.js 프로세스가 서빙에 관여하느냐"다.


7. Java의 진화: Virtual Threads (Project Loom)

Java도 가만히 있지 않았다. Java 21에서 Virtual Threads가 정식 도입되었다.

기존 Java 스레드는 OS 스레드와 1:1 매핑되어 생성 비용이 크고, 동시에 수만 개를 만들 수 없었다. Virtual Threads는 JVM이 관리하는 경량 스레드로, 생성 비용이 극도로 낮다. 기존 블로킹 코드(Thread.sleep(), JDBC 호출 등)를 수정하지 않고도 높은 동시성을 달성할 수 있다.

이는 Node.js 이벤트 루프의 장점(적은 리소스로 높은 동시성)을 Java의 방식(친숙한 블로킹 코드)으로 흡수한 형태다. 비동기 코드를 작성할 필요 없이 동기 코드 그대로 수만 개의 동시 요청을 처리할 수 있다는 점에서, Java 동시성 모델의 큰 전환점이다.


핵심 키워드 정리

  • 이벤트 루프: Node.js의 심장. 6개 페이즈를 순환하며 비동기 작업을 관리
  • poll 페이즈: I/O 완료 콜백을 수거하는 핵심 페이즈. 유일하게 블로킹 대기 가능
  • libuv: 이벤트 루프의 C 구현체. OS별 비동기 API 추상화 + 스레드 풀 제공
  • 마이크로태스크: nextTick, Promise. 페이즈 전환 전에 전부 비워짐
  • 이벤트 루프 Starvation: nextTick 남발로 poll에 도달하지 못하는 장애
  • UncaughtException: 싱글스레드의 대가. 하나의 에러가 전체 프로세스를 죽임
  • Virtual Threads: Java 21의 경량 스레드. 동시성 처리의 패러다임 변화

Week 2에서는 Worker Threads, Cluster 모듈을 활용한 멀티코어 전략과, Spring WebFlux의 리액티브 모델과 Node.js의 비동기 모델을 비교해볼 예정이다.

반응형

댓글