Node.js 23 Worker Threads로 CPU 집약적 작업 분산 처리하기

문제 상황

이미지 리사이징 API를 운영하던 중, 동시 요청이 10개만 넘어가도 응답 시간이 급격히 증가하는 현상이 발생했다. Sharp 라이브러리로 이미지를 처리하는 동안 메인 스레드가 블로킹되면서 다른 요청들이 대기하는 구조였다.

Worker Threads 도입

Node.js 23의 Worker Threads를 사용해 이미지 처리 작업을 별도 스레드로 분리했다.

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

(async () => {
  const { buffer, width, height } = workerData;
  const resized = await sharp(buffer)
    .resize(width, height)
    .toBuffer();
  parentPort.postMessage(resized);
})();
// main.js
const { Worker } = require('worker_threads');
const os = require('os');

class WorkerPool {
  constructor(maxWorkers = os.cpus().length) {
    this.maxWorkers = maxWorkers;
    this.queue = [];
    this.activeWorkers = 0;
  }

  async exec(workerData) {
    if (this.activeWorkers >= this.maxWorkers) {
      await new Promise(resolve => this.queue.push(resolve));
    }

    this.activeWorkers++;
    return new Promise((resolve, reject) => {
      const worker = new Worker('./worker.js', { workerData });
      
      worker.on('message', (result) => {
        resolve(result);
        this.activeWorkers--;
        if (this.queue.length > 0) {
          this.queue.shift()();
        }
      });
      
      worker.on('error', reject);
    });
  }
}

const pool = new WorkerPool();

app.post('/resize', async (req, res) => {
  const result = await pool.exec({
    buffer: req.file.buffer,
    width: 800,
    height: 600
  });
  res.send(result);
});

결과

  • 동시 요청 20개 처리 시 평균 응답 시간: 3200ms → 950ms
  • CPU 코어를 효율적으로 활용 (8코어 기준 utilization 35% → 78%)
  • 메인 스레드는 요청 라우팅과 간단한 작업만 처리

주의사항

Worker 생성 비용이 크기 때문에 Pool 패턴으로 재사용하는 것이 중요했다. 매 요청마다 Worker를 생성했을 때는 오히려 성능이 저하되었다. 또한 Worker 간 데이터 전달 시 직렬화 비용이 발생하므로, 큰 객체보다는 Buffer나 SharedArrayBuffer를 사용하는 것이 효율적이었다.