기본 패턴
// cluster.js — primary가 코어 수만큼 worker를 fork한다
import cluster from "node:cluster";
import http from "node:http";
import { availableParallelism } from "node:os";
if (cluster.isPrimary) {
const numCPUs = availableParallelism();
console.log(`Primary ${process.pid}: ${numCPUs}개 worker 시작`);
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on("exit", (worker, code, signal) => {
console.warn(`Worker ${worker.process.pid} 종료 (${signal ?? code}) — 재시작`);
cluster.fork(); // 비정상 종료 시 즉시 교체
});
} else {
// 각 worker 프로세스가 독립적으로 HTTP 서버를 실행한다
http
.createServer((req, res) => {
res.end(`Worker ${process.pid}이 처리\n`);
})
.listen(3000);
console.log(`Worker ${process.pid} 시작`);
}설명
Node.js는 싱글 스레드라서 cluster 없이는 멀티코어 CPU를 활용하지 못한다
Node.js 프로세스는 기본적으로 단일 CPU 코어에서 실행된다. 8코어 서버를 운영해도 Node.js 단독으로는 나머지 7개 코어를 활용하지 못한다. cluster 모듈은 child_process.fork()를 사용해 동일한 서버 코드를 실행하는 자식 프로세스(worker)를 여러 개 생성한다. 각 worker는 독립된 V8 힙과 이벤트 루프를 가지므로 하나가 충돌해도 다른 worker는 계속 요청을 처리한다.
import { availableParallelism } from "node:os";
// CPU 코어 수에 맞춰 worker를 생성하는 것이 일반적인 출발점이다
// (I/O 위주 서버는 코어 수 × 1~2, CPU 위주는 코어 수와 동일하게)
const workers = availableParallelism();
console.log(`이 머신에서 최적 worker 수: ${workers}`);master-worker 구조에서 소켓은 primary가 수신하고 worker에 분배한다
cluster 모드에서 모든 worker가 동일한 포트에 listen()을 호출하지만, 실제로 OS 소켓을 점유하는 것은 primary 프로세스 하나다. 새 TCP 연결이 들어오면 primary가 이를 받아 worker 중 하나에 전달한다. Linux에서 기본값은 라운드로빈(round-robin) 방식이며, Windows에서는 OS 스케줄러가 담당한다. 라운드로빈은 연결을 순서대로 분배하므로 부하가 비교적 균등하게 분산된다.
import cluster from "node:cluster";
// 라운드로빈 스케줄링 명시 (Linux 기본값 — 명시하지 않아도 동작한다)
cluster.schedulingPolicy = cluster.SCHED_RR;
// OS 스케줄링으로 전환 (Windows 기본값)
// cluster.schedulingPolicy = cluster.SCHED_NONE;
if (cluster.isPrimary) {
cluster.fork();
cluster.fork();
// primary에서 worker에게 메시지 전송
for (const worker of Object.values(cluster.workers)) {
worker.send({ type: "config", value: "production" });
}
}worker_threads vs cluster — 메모리 공유가 필요하면 worker_threads, HTTP 서버 격리가 목적이면 cluster
두 API는 모두 병렬 처리를 제공하지만 적용 시나리오가 다르다. worker_threads는 같은 프로세스 내 스레드이므로 SharedArrayBuffer로 메모리를 공유할 수 있고, 스레드 생성 비용이 프로세스 fork보다 낮다. 반면 cluster는 별도 프로세스이므로 메모리가 완전히 격리되어 한 worker의 메모리 누수나 충돌이 다른 worker에 영향을 주지 않는다. HTTP 서버처럼 요청 단위로 완전 격리가 필요할 때는 cluster가 적합하다.
// 선택 기준 요약
//
// worker_threads 사용 시:
// - CPU 집약 계산 (이미지 처리, 암호화)
// - SharedArrayBuffer로 대용량 데이터 공유
// - 스레드 간 빠른 메시지 교환
//
// cluster 사용 시:
// - HTTP/TCP 서버의 수평 확장
// - 프로세스 완전 격리 (메모리 누수 방지)
// - 무중단 배포 (old worker를 종료하며 new worker로 교체)
import { Worker } from "node:worker_threads";
import cluster from "node:cluster";
// 두 기술을 함께 사용하는 패턴:
// cluster로 프로세스를 나누고, 각 worker 내에서 worker_threads로 CPU 작업 처리
if (cluster.isPrimary) {
cluster.fork();
} else {
const cpuWorker = new Worker("./compute.js", { workerData: { n: 1e7 } });
cpuWorker.on("message", (result) => console.log("계산 결과:", result));
}worker 프로세스 비정상 종료 시 자동 재시작 패턴
프로덕션 환경에서 worker가 처리되지 않은 예외나 메모리 부족으로 종료될 수 있다. cluster.on('exit') 이벤트로 이를 감지하고 즉시 새 worker를 fork하면 서비스 다운타임을 최소화할 수 있다. 단, 재시작 루프(crash loop)를 방지하기 위해 연속 재시작 횟수를 추적하는 것이 좋다.
import cluster from "node:cluster";
const MAX_RESTARTS = 10;
const RESTART_WINDOW_MS = 60_000; // 1분
const restartTimes = [];
if (cluster.isPrimary) {
cluster.fork();
cluster.on("exit", (worker, code, signal) => {
const isIntentional = worker.exitedAfterDisconnect;
if (isIntentional) return; // 의도적 종료 (무중단 배포 등)
// 재시작 루프 감지
const now = Date.now();
restartTimes.push(now);
const recent = restartTimes.filter((t) => now - t < RESTART_WINDOW_MS);
restartTimes.length = 0;
restartTimes.push(...recent);
if (recent.length >= MAX_RESTARTS) {
console.error(`1분 내 ${MAX_RESTARTS}회 재시작 — 치명적 오류 가능성. 중단.`);
process.exit(1);
}
console.warn(`Worker ${worker.process.pid} 비정상 종료 — 재시작 (${recent.length}/${MAX_RESTARTS})`);
cluster.fork();
});
}빠른 정리
| 상황 | 적합한 선택 |
|---|---|
| HTTP 서버 멀티코어 수평 확장 | cluster |
| CPU 집약 계산 병렬화 | worker_threads |
| 스레드 간 메모리 공유 필요 | worker_threads + SharedArrayBuffer |
| 프로세스 완전 격리 (메모리 누수 방지) | cluster |
| worker 비정상 종료 자동 복구 | cluster.on('exit') + cluster.fork() |
| 의도적 worker 종료 구분 | worker.exitedAfterDisconnect 확인 |
주의할 점
cluster.fork()는 현재 모듈 파일 전체를 새 프로세스로 다시 실행한다. cluster.isPrimary 분기가 없으면 worker도 fork()를 반복해 프로세스가 무한 증식한다.
// ❌ 잘못된 예: isPrimary 분기 없음 — 모든 프로세스가 fork를 실행
import cluster from "node:cluster";
import http from "node:http";
cluster.fork(); // worker도 이 줄을 실행 → 프로세스 폭발
http.createServer((req, res) => res.end("OK")).listen(3000);
// ✅ 올바른 예: isPrimary로 역할을 명확히 분리
import cluster from "node:cluster";
import http from "node:http";
if (cluster.isPrimary) {
cluster.fork(); // primary만 fork 실행
} else {
http.createServer((req, res) => res.end("OK")).listen(3000);
}