Java 개발자가 Node.js를 파헤치다 — Week 2: 멀티코어 전략과 비동기 모델의 진화
Week 1에서 Node.js의 싱글스레드 + 이벤트 루프 모델을 이해했다. 이번 주에는 "싱글스레드의 한계를 어떻게 극복하는가"를 다룬다. Worker Threads, Cluster 모듈로 Node.js가 멀티코어를 활용하는 방법을 살펴보고, Java 진영의 Spring WebFlux와 Virtual Threads까지 비교하며 두 생태계의 비동기 모델이 어떻게 진화해왔는지 정리한다.
1. Worker Threads — Node.js도 멀티스레드가 된다
싱글스레드의 치명적 약점: CPU-bound
Week 1에서 Node.js의 싱글스레드 모델이 I/O-bound 작업에 효율적이라고 했다. 하지만 CPU를 많이 쓰는 작업에서는 이야기가 달라진다.
app.get('/heavy', (req, res) => {
const result = fibonacci(45); // CPU를 3초간 독점
res.json({ result });
});
app.get('/health', (req, res) => {
res.json({ status: 'ok' }); // 이것도 3초 뒤에야 응답
});
fibonacci(45) 계산 동안 이벤트 루프가 멈춘다. I/O는 OS에 위임할 수 있지만, CPU 연산은 메인 스레드가 직접 해야 하기 때문이다. 3초간 모든 요청이 대기한다.
Java라면? 요청마다 별도 스레드가 할당되니까 한 스레드에서 CPU를 오래 써도 다른 스레드의 요청 처리에 영향이 없다. 이 문제를 해결하기 위해 Node.js 10.5에서 Worker Threads가 도입됐다.
기본 구조
// main.js — 메인 스레드
const { Worker } = require('worker_threads');
app.get('/heavy', (req, res) => {
const worker = new Worker('./fibonacci-worker.js', {
workerData: { n: 45 }
});
worker.on('message', (result) => res.json({ result }));
worker.on('error', (err) => res.status(500).json({ error: err.message }));
});
// fibonacci-worker.js — 별도 스레드
const { workerData, parentPort } = require('worker_threads');
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
const result = fibonacci(workerData.n);
parentPort.postMessage(result);
fibonacci 계산은 별도 스레드에서 돌아가고, 메인 스레드의 이벤트 루프는 다른 요청을 계속 처리한다.
Java와의 결정적 차이: 메모리 공유 방식
Java에서 스레드는 같은 힙 메모리를 공유한다.
// Java — 같은 객체에 여러 스레드가 접근
List<String> sharedList = new ArrayList<>();
new Thread(() -> sharedList.add("from A")).start();
new Thread(() -> sharedList.add("from B")).start();
// → 동기화 안 하면 race condition 발생
Node.js Worker Threads는 메모리가 격리되어 있다.
// main.js
const worker = new Worker('./worker.js', {
workerData: { name: 'Junho' }
});
// worker.js
const { workerData } = require('worker_threads');
workerData.name = 'changed'; // 메인 스레드에 영향 없음
workerData는 복사(structured clone)되어 전달되고, 메인 스레드와 워커는 postMessage로만 통신한다. Race condition이 구조적으로 발생하지 않는다. SharedArrayBuffer를 쓰면 메모리 공유가 가능하지만, 바이트 배열 수준에서만 공유 가능하고 Atomics API로 원자적 연산을 해야 한다.
| Java 스레드 | Node.js Worker Threads | |
|---|---|---|
| 메모리 | 공유 (같은 힙) | 격리 (복사 전달) |
| 통신 | 직접 참조 | postMessage |
| 동기화 | 개발자 책임 (Lock, synchronized) | 구조적으로 불필요 |
| 위험성 | race condition, deadlock | 거의 없음 |
Node.js는 "안전하지만 제한적", Java는 "강력하지만 위험한" 설계를 택한 것이다.
실무에서 Worker Threads가 필요한 경우
커머스 플랫폼 기준으로 생각하면 대량 엑셀/CSV 생성(SKU 수만 건의 정산 데이터), 이미지 리사이징(상품 썸네일 생성), 대량 데이터 집계(대시보드용 통계 계산) 같은 케이스가 있다.
하지만 솔직히 대부분의 경우 Worker Threads까지 갈 필요가 없다. 무거운 연산은 DB나 BigQuery에서 처리하고, 엑셀 생성 같은 건 별도 큐(Bull/BullMQ)에 넣어 백그라운드로 처리하며, 정말 CPU-intensive하면 별도 서비스로 분리하는 게 일반적이다. Worker Threads는 "별도 서비스까지는 과하지만 메인 스레드를 막고 싶지는 않은" 중간 지점에서 유효하다.
Java라면? 그냥 스레드 풀에 던지면 된다.
ExecutorService executor = Executors.newFixedThreadPool(4);
@GetMapping("/export")
public CompletableFuture<byte[]> exportExcel() {
return CompletableFuture.supplyAsync(() -> {
return generateExcel(skuList);
}, executor);
}
요청마다 스레드가 따로 있으니까 별도 도구 자체가 필요 없다. 이것이 멀티스레드 모델의 강점이다.
2. Cluster 모듈 — 프로세스를 복제해서 멀티코어 활용
CPU 코어와 Node.js의 관계
현대 CPU는 하나의 칩 안에 여러 개의 코어를 가지고 있다. 코어 하나는 독립적으로 명령어를 실행할 수 있는 처리 장치다. 8코어 CPU는 물리적으로 8개의 작업을 동시에 실행할 수 있다.
주방에 비유하면 CPU는 주방 전체, 코어는 각자 도마와 칼과 버너를 갖춘 요리사 한 명이다. OS의 스레드 하나는 한 시점에 하나의 코어에서만 실행된다. Node.js는 메인 스레드가 하나이므로, 아무리 코어가 8개여도 JS 코드 실행은 사실상 1코어 수준이다.
8코어 서버에서 Node.js 프로세스 1개:
Core 1: Node.js 메인 스레드 (JS 실행) ← 이게 병목
Core 2: libuv 스레드 풀 (파일 I/O 등)
Core 3: libuv 스레드 풀
Core 4: V8 GC (가끔)
Core 5~8: 거의 유휴 상태
libuv 스레드 풀과 V8 GC가 다른 코어를 일부 사용하긴 하지만, 애플리케이션의 처리 능력(throughput)은 1코어에 묶여 있다. 나머지 코어는 거의 놀고 있는 셈이다.
Java는 스레드가 여러 개이므로 OS가 각 코어에 하나씩 배치할 수 있다. 8코어면 동시에 8개 스레드가 병렬 실행된다.
Cluster의 해결책: 프로세스 자체를 복제
같은 포트를 공유하는 프로세스를 여러 개 띄워서 모든 코어를 활용한다.
const cluster = require('cluster');
const http = require('http');
const os = require('os');
if (cluster.isMaster) {
const numCPUs = os.cpus().length;
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker) => {
console.log(`Worker ${worker.process.pid} 죽음 → 새로 생성`);
cluster.fork();
});
} else {
http.createServer((req, res) => {
res.end(`Worker ${process.pid} 처리`);
}).listen(3000);
}
Worker Threads vs Cluster
| Worker Threads | Cluster | |
|---|---|---|
| 단위 | 스레드 | 프로세스 |
| 메모리 | 같은 프로세스 내 | 완전히 격리 |
| 용도 | CPU-bound 작업 분리 | 멀티코어 활용, 트래픽 분산 |
| 포트 공유 | 불가 | 가능 |
Worker Threads는 "무거운 계산을 별도 스레드로 빼는 것"이고, Cluster는 "서버 자체를 여러 개 띄우는 것"이다. 목적이 완전히 다르다.
메모리 비용: 프로세스 8개 = 메모리 거의 8배
Cluster의 각 워커는 독립적인 V8 엔진, 힙 메모리, 이벤트 루프를 가진다. NestJS 앱 하나가 50MB라면, 8개 프로세스는 약 400MB다.
OS의 COW(Copy-On-Write) 최적화로 fork() 직후에는 메모리를 공유하지만, 운영 중에 각 프로세스가 독립적으로 데이터를 쌓으면서 결국 거의 N배에 수렴한다.
Java 멀티스레드와 비교하면 이게 Cluster의 가장 큰 단점이다. Java는 스레드끼리 힙을 공유하니까 메모리 효율이 훨씬 좋다.
실무에서는 PM2가 대신한다
pm2 start app.js -i max # CPU 수만큼 클러스터 모드로 실행
이 한 줄이 내부적으로 위의 Cluster 코드를 실행한다. 자동 재시작, 로그 관리, 모니터링까지 포함되어 있어서, 직접 cluster 모듈을 쓸 일은 거의 없다.
단, 무조건 코어 수만큼 띄우는 게 답은 아니다. 메모리가 4GB인 서버에서 프로세스 8개를 띄우면 OOM이 날 수 있다.
3. PM2 Cluster vs ECS: 스케일링의 레벨
PM2 Cluster와 ECS는 배타적이지 않다. 스케일링하는 레벨이 다르다.
PM2 Cluster: 하나의 서버 안에서 프로세스를 복제 (수직적 활용)
ECS: 서버(컨테이너) 자체를 복제 (수평적 확장)
PM2 Cluster:
┌────────── EC2 인스턴스 1대 ──────────┐
│ PM2 │
│ ├─ Worker 1 │
│ ├─ Worker 2 │
│ ├─ Worker 3 │
│ └─ Worker 4 │
└───────────────────────────────────────┘
ECS:
┌─ Task 1 ─┐ ┌─ Task 2 ─┐ ┌─ Task 3 ─┐
│ Container │ │ Container │ │ Container │
│ (Node.js) │ │ (Node.js) │ │ (Node.js) │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
└──────────────┼──────────────┘
Load Balancer (ALB)
핵심 판단 기준은 "서버 1대가 죽으면 서비스가 죽어도 되나?"다. 안 된다면 ECS 같은 오케스트레이션이 필요하다. 실무에서는 둘을 함께 쓰는 경우가 많다. ECS로 컨테이너를 여러 개 띄워 고가용성을 확보하고, 각 컨테이너 안에서 PM2 Cluster로 CPU 코어를 활용하는 구조다.
ECS의 두 가지 모드: EC2 vs Fargate
ECS 위에서 컨테이너를 실행하는 방식이 두 가지다.
EC2 모드는 EC2 인스턴스를 직접 띄우고 그 위에 컨테이너를 올린다. OS 업데이트, 디스크 관리, 보안 패치를 직접 해야 한다. 비용은 저렴하지만(특히 Reserved Instance) 운영 부담이 크다.
Fargate는 서버가 보이지 않는다. "vCPU 2개, 메모리 4GB짜리 컨테이너 3개 띄워줘" 하면 AWS가 알아서 어딘가에 띄워준다. 서버 관리 자체가 없어지는 대신 비용이 더 나간다.
Fargate에서 PM2를 쓸 때 주의할 점이 있다. vCPU 수보다 프로세스를 더 많이 띄우면 컨텍스트 스위칭 비용만 늘어나서 역효과다.
4. Node.js가 싱글스레드를 선택한 이유
여기서 한 발 물러서, 왜 Node.js가 처음부터 싱글스레드를 택했는지 짚고 넘어갈 필요가 있다.
2009년, Ryan Dahl이 해결하려던 문제는 C10K 문제 — 동시 접속 1만 개를 어떻게 처리할 것인가 — 였다. 당시 주류였던 Apache(Thread-per-Request)는 동시 접속이 늘어나면 스레드가 폭발적으로 증가했다. Dahl의 핵심 통찰은 이것이었다.
"웹 서버가 하는 일의 대부분은 I/O 대기다."
일반적인 웹 요청에서 CPU 연산은 약 5%, I/O 대기(DB 쿼리, API 호출, 파일 읽기)가 약 95%를 차지한다. 95%의 시간을 대기에 쓰면서 스레드를 통째로 점유하는 것은 낭비다. 대기하지 말고 다른 일 하다가, 결과 오면 그때 처리하면 된다. 이것이 이벤트 루프 모델의 출발점이다.
싱글스레드의 의도적 이점도 있다. 스레드가 하나이므로 race condition과 deadlock이 구조적으로 발생하지 않는다. 멀티스레드 프로그래밍의 동기화 복잡성이 사라지면서 개발 난이도가 크게 낮아진다. JavaScript를 선택한 이유도 이미 브라우저에서 싱글스레드 + 이벤트 기반으로 동작하고 있었고, 개발자들이 콜백 패턴에 익숙했기 때문이다.
구조적 한계가 아니라, "웹 서버 대부분은 I/O-bound다"라는 전제 하에 내린 합리적 선택이었다. 다만 그 전제가 안 맞는 경우(CPU-heavy)를 위해 나중에 Worker Threads와 Cluster가 추가된 것이다.
5. Spring WebFlux — Java가 Node.js의 비동기를 흡수하다
Java 진영에서도 "I/O 대기하면서 스레드 낭비하는 게 비효율적이다"라는 것을 인정했다. 2017년, Spring 5와 함께 Spring WebFlux가 등장했다.
같은 API를 세 가지로 구현
Spring MVC (블로킹)
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
User user = userRepository.findById(id); // 블로킹 대기
List<Order> orders = orderClient.getOrders(id); // 블로킹 대기
user.setOrders(orders);
return user;
}
Spring WebFlux (논블로킹)
@GetMapping("/user/{id}")
public Mono<User> getUser(@PathVariable Long id) {
return userRepository.findById(id)
.flatMap(user ->
orderClient.getOrders(id)
.collectList()
.map(orders -> {
user.setOrders(orders);
return user;
})
);
}
Node.js / NestJS (논블로킹)
@Get('/user/:id')
async getUser(@Param('id') id: number) {
const user = await this.userRepository.findOne(id);
const orders = await this.orderClient.getOrders(id);
user.orders = orders;
return user;
}
차이가 바로 보인다. Node.js의 async/await이 압도적으로 읽기 쉽다. 동기 코드처럼 읽히는데 내부적으로 논블로킹이다. WebFlux의 Mono, Flux, flatMap, collectList는 리액티브 스트림이라는 새로운 패러다임을 배워야 하고, 기존 JPA도 못 쓰고 R2DBC(리액티브 DB 드라이버)를 써야 한다.
WebFlux만의 강점: 멀티스레드 + 논블로킹
그럼에도 WebFlux가 존재하는 이유가 있다. 핵심 차이는 이벤트 루프 스레드 수다.
Node.js의 이벤트 루프 스레드는 1개다. WebFlux(Netty 기반)는 CPU 코어 수만큼 이벤트 루프 스레드를 가진다. Node.js의 장점(논블로킹)과 Java의 장점(멀티스레드)을 둘 다 가져간 구조다.
그 외에도 기존 Java/Spring 생태계(Security, Cloud 등) 안에서 비동기 전환이 가능하다는 점, 배압(Backpressure) — 소비자가 처리할 수 있는 만큼만 데이터를 받도록 제어하는 메커니즘 — 이 프레임워크 레벨에서 표준화되어 있다는 점이 강점이다.
| Spring MVC | Spring WebFlux | Node.js | |
|---|---|---|---|
| I/O 모델 | 블로킹 | 논블로킹 | 논블로킹 |
| 스레드 | 요청당 1개 | 이벤트 루프 N개 | 이벤트 루프 1개 |
| CPU 활용 | 자연스럽게 멀티코어 | 자연스럽게 멀티코어 | Cluster 필요 |
| 코드 스타일 | 동기 (직관적) | 리액티브 (러닝커브 높음) | async/await (직관적) |
| DB 드라이버 | JPA/Hibernate | R2DBC (제한적) | 대부분 논블로킹 기본 |
6. Virtual Threads — 그리고 판이 바뀌었다
Java 21에서 Virtual Threads가 정식 도입되면서 게임 체인저가 됐다.
설정 한 줄이면 끝
# application.yml
spring:
threads:
virtual:
enabled: true
기존 Spring MVC 코드 — @Service, @Repository, JPA, 블로킹 JDBC — 하나도 안 바꾸고 Tomcat이 Virtual Threads를 사용하게 된다. 각 요청이 경량 가상 스레드에서 처리되므로, 블로킹 코드 그대로 수만 개의 동시 요청을 처리할 수 있다.
WebFlux로 전환하려면 코드 전체를 리액티브로 재작성해야 했다. 설정 한 줄 vs 코드 전면 재작성. WebFlux의 입지가 애매해진 이유다.
WebFlux가 아직 유효한 영역
스트리밍과 실시간 데이터(SSE, WebSocket으로 연속적인 데이터를 흘려보내는 케이스), 배압 제어가 핵심인 대량 데이터 파이프라인, 그리고 이미 WebFlux로 구축되어 잘 돌아가고 있는 시스템. 이 외의 대부분의 경우, 새 프로젝트라면 Spring MVC + Virtual Threads가 더 합리적인 선택이다.
7. 그래서 어떤 기술을 선택해야 하나
Java + Spring이 압도적으로 강한 영역
게임 서버(실시간 물리 연산, 충돌 감지), 영상/미디어 처리(트랜스코딩, 인코딩), 금융 거래 시스템(초당 수십만 건 처리, 마이크로초 지연), ML 모델 서빙(행렬 연산), 빅데이터 처리(Hadoop, Spark, Kafka Streams가 전부 JVM 기반인 이유). 공통점은 CPU를 많이 쓰는 서버라는 것이다.
Node.js가 더 나은 선택인 경우
빠른 개발 속도가 필요한 MVP, 프론트엔드와 TypeScript로 언어를 통일하고 싶을 때, 가벼운 마이크로서비스(API Gateway, BFF, 알림 서비스), 실시간/이벤트 기반 서비스(채팅, 알림, WebSocket). Spring Boot 앱이 메모리 300
500MB를 기본으로 사용하는 반면, Node.js는 50
100MB면 충분하다는 점도 실무에서 유의미하다.
Node.js로 대규모 엔터프라이즈를 만들 수 있나?
만들 수 있다. Netflix, PayPal, LinkedIn, Uber가 Node.js로 핵심 서비스를 운영하고 있다. 다만 Java/Spring이 엔터프라이즈에 필요한 것들(트랜잭션 관리, 선언적 보안, 분산 시스템 패턴, 성숙한 ORM)을 프레임워크 레벨에서 내장하고 있는 반면, Node.js는 직접 조합하거나 제약을 감수해야 하는 부분이 있다. 규모가 커질수록 아키텍처 설계에 더 많은 신경을 써야 한다.
대규모 Node.js 시스템들은 거대한 모놀리스가 아니라, 작은 서비스 수백 개를 조합하는 마이크로서비스 구조로 운영된다.
현실적인 최적해
Spring + Java 21이 범용적으로 가장 강력한 조합인 것은 맞다. 하지만 팀 생산성까지 고려하면 Node.js가 더 나은 선택인 경우가 분명히 있다. 두 스택을 함께 쓰는 환경 — 무거운 도메인 로직은 Spring, 가벼운 I/O-bound 서비스는 NestJS — 이 오히려 현실적인 최적해일 수 있다.
핵심 키워드 정리
- Worker Threads: Node.js에서 CPU-bound 작업을 별도 스레드로 분리하는 메커니즘. 메모리 격리가 기본.
- Cluster 모듈: 같은 포트를 공유하는 프로세스를 복제하여 멀티코어를 활용. PM2가 내부적으로 사용.
- COW (Copy-On-Write): OS의 메모리 최적화. fork 직후에는 메모리를 공유하고, 수정 시에만 복사.
- Spring WebFlux: Java의 리액티브 논블로킹 프레임워크. 멀티스레드 + 논블로킹의 조합.
- Virtual Threads: Java 21의 경량 스레드. 블로킹 코드 그대로 고동시성 달성. WebFlux의 입지를 흔든 게임 체인저.
- ECS / Fargate: AWS의 컨테이너 오케스트레이션. Fargate는 서버 관리 없이 컨테이너만 실행.
- C10K 문제: 동시 접속 1만 개 처리 문제. Node.js 탄생의 직접적 배경.
Week 3에서는 V8 GC와 JVM GC의 메모리 관리 전략을 비교하고, Node.js 스트림 처리를 다룰 예정이다.
'Language > JS(Node.js)' 카테고리의 다른 글
| [Node.js] 비동기 프로그래밍 깊게 파기: Promise부터 동시성 제어까지 (0) | 2026.03.02 |
|---|---|
| [Node.js] 동시성 모델의 모든 것 (0) | 2026.03.02 |
| npm i (npm install)와 npm ci (npm clean-install) (0) | 2025.09.23 |
| NVM을 활용하여, Node Version을 관리하는 방법 (0) | 2023.04.22 |
| [Javascript] Array.prototype.forEach vs Array.prototype.Map (2) | 2022.04.03 |
댓글