NestJS Cluster Worker Threads

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 성능 최적화의 정석이다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux