Node.js 24 Worker Threads로 CPU 집약 작업 최적화

문제 상황

사내 이미지 처리 API가 동시 요청 5개만 들어와도 응답 시간이 급격히 느려지는 문제가 있었다. Sharp 라이브러리로 이미지 리사이징을 하는데, CPU 집약적인 작업이 메인 스레드를 블로킹하면서 다른 요청까지 지연시켰다.

Worker Threads 도입

Node.js 24에서 안정화된 Worker Threads를 활용해 이미지 처리 로직을 분리했다.

// worker.js
const { parentPort, workerData } = require('worker_threads');
const sharp = require('sharp');

sharp(workerData.buffer)
  .resize(workerData.width, workerData.height)
  .toBuffer()
  .then(result => parentPort.postMessage({ success: true, data: result }))
  .catch(err => parentPort.postMessage({ success: false, error: err.message }));
// main.js
const { Worker } = require('worker_threads');
const os = require('os');

class WorkerPool {
  constructor(size = os.cpus().length) {
    this.size = size;
    this.queue = [];
    this.workers = [];
  }

  async process(buffer, width, height) {
    return new Promise((resolve, reject) => {
      const worker = new Worker('./worker.js', {
        workerData: { buffer, width, height }
      });

      worker.on('message', (result) => {
        worker.terminate();
        result.success ? resolve(result.data) : reject(new Error(result.error));
      });

      worker.on('error', reject);
    });
  }
}

결과

  • 동시 처리량: 5 req/s → 15 req/s
  • P95 응답 시간: 3.2초 → 1.1초
  • CPU 사용률: 단일 코어 100% → 8코어 평균 65%

메인 스레드가 블로킹되지 않아 헬스체크나 다른 경량 API도 안정적으로 동작하게 되었다.

주의사항

Worker 생성 비용이 있어서 매 요청마다 새로 만들면 오히려 느려진다. 실제로는 Worker Pool을 구현하거나 Piscina 같은 라이브러리 사용을 권장한다. 또한 Worker 간 데이터 전달은 직렬화되므로 큰 객체 전달 시 오버헤드를 고려해야 한다.