Node.js 싱글 스레드의 한계
Node.js는 싱글 스레드 이벤트 루프로 동작한다. I/O 작업은 비동기로 효율적이지만, CPU 집약적 작업(이미지 처리, 암호화, 대용량 JSON 파싱)은 이벤트 루프를 블로킹해 전체 서버 응답이 멈출 수 있다. 또한 멀티코어 CPU에서 하나의 코어만 사용하므로 하드웨어 자원이 낭비된다.
이 문제를 해결하는 두 가지 접근법이 Cluster Mode(멀티 프로세스)와 Worker Threads(멀티 스레드)다. NestJS에서 두 가지를 실전적으로 적용하는 방법을 심화 정리한다.
Cluster Mode: 멀티 프로세스 확장
Node.js cluster 모듈은 마스터 프로세스가 여러 워커 프로세스를 fork하여 동일 포트에서 요청을 분산 처리한다. 각 워커는 독립된 V8 인스턴스와 메모리를 가진다.
// src/cluster.ts
import * as cluster from 'cluster';
import * as os from 'os';
const numCPUs = os.cpus().length;
export function bootstrap(workerFn: () => Promise<void>) {
if (cluster.isPrimary) {
console.log(`Master ${process.pid} starting ${numCPUs} workers`);
// CPU 코어 수만큼 워커 생성
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
// 워커 종료 시 자동 재시작
cluster.on('exit', (worker, code, signal) => {
console.warn(
`Worker ${worker.process.pid} died (${signal || code}). Restarting...`
);
cluster.fork();
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('Master received SIGTERM, shutting down workers...');
for (const id in cluster.workers) {
cluster.workers[id]?.process.kill('SIGTERM');
}
});
} else {
workerFn().catch((err) => {
console.error(`Worker ${process.pid} failed:`, err);
process.exit(1);
});
}
}
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { bootstrap } from './cluster';
bootstrap(async () => {
const app = await NestFactory.create(AppModule);
// Graceful shutdown 설정
app.enableShutdownHooks();
await app.listen(3000);
console.log(`Worker ${process.pid} listening on port 3000`);
});
이 방식으로 8코어 서버에서 8개의 NestJS 인스턴스가 동시에 요청을 처리한다. OS가 라운드 로빈으로 요청을 분배하므로 별도 로드밸런서가 필요 없다.
Cluster 모드 주의사항
| 이슈 | 원인 | 해결 |
|---|---|---|
| 메모리 격리 | 각 워커가 독립 메모리 | Redis로 공유 상태 관리 |
| 세션 불일치 | 요청이 다른 워커로 갈 수 있음 | Redis 세션 스토어 사용 |
| WebSocket 라우팅 | sticky session 필요 | Redis Adapter 사용 |
| Cron 중복 실행 | 모든 워커에서 스케줄러 동작 | 분산 락 또는 마스터에서만 실행 |
// Cron 중복 방지: 마스터 워커에서만 스케줄 실행
import * as cluster from 'cluster';
@Injectable()
export class TaskService {
private readonly isFirstWorker: boolean;
constructor() {
// worker.id === 1인 워커에서만 실행
this.isFirstWorker = !cluster.isPrimary && cluster.worker?.id === 1;
}
@Cron('0 */5 * * * *')
async cleanupExpiredTokens() {
if (!this.isFirstWorker) return; // 첫 번째 워커만 실행
await this.tokenRepository.deleteExpired();
}
}
분산 락을 사용한 더 견고한 방법은 Spring Scheduler 분산 락 글의 ShedLock 패턴을 참고할 수 있다.
Worker Threads: CPU 작업 오프로딩
Worker Threads는 같은 프로세스 내에서 별도 스레드를 생성해 CPU 집약 작업을 오프로딩한다. Cluster와 달리 메모리를 공유할 수 있고(SharedArrayBuffer), 스레드 간 통신이 가볍다.
// src/workers/hash.worker.ts
import { parentPort, workerData } from 'worker_threads';
import * as bcrypt from 'bcrypt';
async function run() {
const { password, rounds } = workerData;
const hash = await bcrypt.hash(password, rounds);
parentPort?.postMessage({ hash });
}
run().catch((err) => {
parentPort?.postMessage({ error: err.message });
});
// src/common/worker-thread.util.ts
import { Worker } from 'worker_threads';
import { join } from 'path';
export function runWorker<T>(
workerFile: string,
workerData: Record<string, any>,
): Promise<T> {
return new Promise((resolve, reject) => {
const worker = new Worker(join(__dirname, '..', 'workers', workerFile), {
workerData,
});
worker.on('message', (result) => {
if (result.error) {
reject(new Error(result.error));
} else {
resolve(result as T);
}
});
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker exited with code ${code}`));
}
});
});
}
// src/auth/auth.service.ts
@Injectable()
export class AuthService {
async hashPassword(password: string): Promise<string> {
// ❌ 메인 스레드에서 실행 → 이벤트 루프 블로킹
// const hash = await bcrypt.hash(password, 12);
// ✅ Worker Thread로 오프로딩 → 이벤트 루프 자유
const { hash } = await runWorker<{ hash: string }>(
'hash.worker.js',
{ password, rounds: 12 },
);
return hash;
}
}
Worker Thread Pool: Piscina 활용
매 요청마다 Worker를 생성하면 오버헤드가 크다. Piscina 라이브러리로 스레드 풀을 관리하면 워커를 재사용하고 작업 큐를 자동 관리한다.
npm install piscina
// src/workers/image-resize.worker.ts
import sharp from 'sharp';
// Piscina는 default export 함수를 워커로 실행
export default async function resize(
{ buffer, width, height }: { buffer: Buffer; width: number; height: number }
) {
const result = await sharp(buffer)
.resize(width, height, { fit: 'cover' })
.webp({ quality: 80 })
.toBuffer();
return { data: result, size: result.length };
}
// src/image/image.module.ts
import { Module, OnModuleDestroy } from '@nestjs/common';
import Piscina from 'piscina';
import { join } from 'path';
const PISCINA_TOKEN = 'PISCINA_IMAGE';
@Module({
providers: [
{
provide: PISCINA_TOKEN,
useFactory: () => {
return new Piscina({
filename: join(__dirname, '..', 'workers', 'image-resize.worker.js'),
minThreads: 2,
maxThreads: 4, // CPU 코어 수의 절반 권장
idleTimeout: 30_000, // 30초 유휴 시 스레드 종료
maxQueue: 100, // 대기열 제한 → 과부하 방지
});
},
},
ImageService,
],
exports: [ImageService],
})
export class ImageModule implements OnModuleDestroy {
constructor(@Inject(PISCINA_TOKEN) private pool: Piscina) {}
async onModuleDestroy() {
await this.pool.destroy();
}
}
// src/image/image.service.ts
@Injectable()
export class ImageService {
constructor(@Inject('PISCINA_IMAGE') private pool: Piscina) {}
async resizeImage(buffer: Buffer, width: number, height: number) {
// 풀 상태 모니터링
console.log({
completed: this.pool.completed,
queueSize: this.pool.queueSize,
utilization: this.pool.utilization, // 0~1 사이 활용률
});
if (this.pool.queueSize > 50) {
throw new ServiceUnavailableException('Image processing queue full');
}
return this.pool.run({ buffer, width, height });
}
}
Cluster + Worker Threads 조합
실전에서는 Cluster로 HTTP 요청을 분산하고, 각 워커 프로세스 내에서 Worker Threads로 CPU 작업을 오프로딩하는 조합이 최적이다.
┌─────────────────────────────────────────────┐
│ Master │
│ (프로세스 관리만) │
├─────────────┬──────────────┬────────────────┤
│ Worker 1 │ Worker 2 │ Worker 3 │
│ (Port 3000)│ (Port 3000) │ (Port 3000) │
│ │ │ │
│ ┌─────────┐ │ ┌─────────┐ │ ┌─────────┐ │
│ │Thread │ │ │Thread │ │ │Thread │ │
│ │Pool (4) │ │ │Pool (4) │ │ │Pool (4) │ │
│ │- resize │ │ │- resize │ │ │- resize │ │
│ │- hash │ │ │- hash │ │ │- hash │ │
│ └─────────┘ │ └─────────┘ │ └─────────┘ │
└─────────────┴──────────────┴────────────────┘
// 조합 적용 시 스레드 수 계산
// 8코어 서버 기준:
// - Cluster 워커: 4개 (코어의 절반)
// - 각 워커의 Thread Pool: 2~3개
// - 총 활성 스레드: 4 × 3 = 12 (코어 수의 1.5배)
// - 나머지 코어는 I/O, OS, GC가 사용
const CLUSTER_WORKERS = Math.ceil(os.cpus().length / 2);
const THREADS_PER_WORKER = Math.max(2, Math.floor(os.cpus().length / CLUSTER_WORKERS));
SharedArrayBuffer: 스레드 간 메모리 공유
// 메인 스레드: 공유 버퍼 생성
const sharedBuffer = new SharedArrayBuffer(1024 * 1024); // 1MB
const sharedArray = new Float64Array(sharedBuffer);
// 데이터 채우기
for (let i = 0; i < 1000; i++) {
sharedArray[i] = Math.random() * 100;
}
// Worker에 전달 (복사 없이 공유!)
const worker = new Worker('./stats.worker.js', {
workerData: { buffer: sharedBuffer, length: 1000 },
});
// stats.worker.ts: 공유 메모리 직접 접근
import { workerData, parentPort } from 'worker_threads';
const { buffer, length } = workerData;
const data = new Float64Array(buffer);
let sum = 0;
for (let i = 0; i < length; i++) {
sum += data[i];
}
const mean = sum / length;
// Atomics로 동시 접근 제어
const int32View = new Int32Array(buffer);
Atomics.add(int32View, 0, 1); // 원자적 증가
Atomics.wait(int32View, 0, 0); // 값이 0이면 대기
Atomics.notify(int32View, 0, 1); // 대기 중인 스레드 깨움
parentPort?.postMessage({ mean });
SharedArrayBuffer는 대용량 데이터(이미지 버퍼, 수치 배열)를 복사 없이 공유할 때 유용하다. 단, 동시 접근 시 Atomics로 동기화해야 한다.
PM2: 프로덕션 Cluster 관리
직접 Cluster 코드를 작성하는 대신, PM2를 사용하면 프로세스 관리·모니터링·로그 수집을 자동화할 수 있다.
// ecosystem.config.js
module.exports = {
apps: [{
name: 'nestjs-api',
script: 'dist/main.js',
instances: 'max', // CPU 코어 수만큼 자동
exec_mode: 'cluster',
max_memory_restart: '500M', // 메모리 초과 시 자동 재시작
// 환경별 설정
env_production: {
NODE_ENV: 'production',
PORT: 3000,
},
// 무중단 재시작 설정
wait_ready: true, // app.listen 후 ready 시그널 대기
listen_timeout: 10000,
kill_timeout: 5000,
// 로그 설정
error_file: './logs/error.log',
out_file: './logs/out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
merge_logs: true,
}],
};
// NestJS에서 PM2 ready 시그널 전송
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(3000);
// PM2에 ready 시그널 전송 → 무중단 재시작 지원
if (process.send) {
process.send('ready');
}
}
// PM2 명령어
// pm2 start ecosystem.config.js --env production
// pm2 reload nestjs-api # 무중단 재시작
// pm2 monit # 실시간 모니터링
// pm2 logs nestjs-api # 로그 확인
벤치마크: 싱글 vs Cluster vs Worker
| 시나리오 | 싱글 프로세스 | Cluster (4) | Cluster + Worker |
|---|---|---|---|
| 순수 I/O (DB 조회) | ~5,000 rps | ~18,000 rps | ~18,000 rps |
| bcrypt hash (12 rounds) | ~15 rps | ~60 rps | ~200 rps |
| 이미지 리사이즈 (2MB) | ~8 rps | ~30 rps | ~120 rps |
| JSON 파싱 (10MB) | ~20 rps | ~75 rps | ~250 rps |
I/O 위주 작업은 Cluster만으로 충분하지만, CPU 바운드 작업은 Worker Threads 조합이 3~4배 이상 향상된다. 이벤트 루프가 블로킹되지 않으므로 다른 요청의 응답 지연도 사라진다.
K8s 환경에서의 전략
# Kubernetes에서는 Cluster 대신 Pod 레플리카로 스케일링 권장
# resources.requests.cpu와 HPA가 Cluster 역할을 대체
apiVersion: apps/v1
kind: Deployment
spec:
replicas: 4 # Cluster 워커 대신 Pod 4개
template:
spec:
containers:
- name: api
resources:
requests:
cpu: "1000m" # Pod당 1 코어 → 싱글 프로세스로 충분
memory: "512Mi"
limits:
cpu: "1500m"
memory: "768Mi"
K8s에서는 Cluster Mode 없이 싱글 프로세스 + Pod 수평 확장이 일반적이다. HPA가 자동으로 Pod 수를 조절하므로 Cluster의 역할이 불필요하다. 단, Worker Threads는 K8s 환경에서도 여전히 유효하다 — Pod 내부에서 CPU 작업을 오프로딩하는 것은 스케일링과 별개의 문제다. K8s HPA 설정에 대한 자세한 내용은 K8s HPA 오토스케일링 심화 글을 참고하자.
마무리: 선택 기준
| 상황 | 권장 전략 |
|---|---|
| 베어메탈/VM + I/O 위주 | PM2 Cluster Mode |
| 베어메탈/VM + CPU 작업 혼재 | Cluster + Piscina Worker Pool |
| Kubernetes 환경 | 싱글 프로세스 + HPA + Worker Threads |
| 특정 CPU 작업만 오프로딩 | Piscina Worker Pool 단독 |
핵심은 이벤트 루프를 절대 블로킹하지 않는 것이다. 50ms 이상 걸리는 동기 작업은 Worker Thread로 오프로딩하고, 수평 확장은 Cluster(VM) 또는 HPA(K8s)에 맡기는 것이 NestJS 성능 최적화의 정석이다.