Node.js 워커 스레드로 CPU 집약 작업 처리하기

문제 상황

사내 이미지 처리 API 서버에서 여러 썸네일을 동시에 생성할 때 응답 시간이 5초 이상 소요되는 이슈가 있었다. Sharp 라이브러리로 이미지를 리사이징하는 작업이 메인 스레드를 블로킹하면서 다른 요청까지 지연되는 상황이었다.

워커 스레드 도입

Node.js의 worker_threads 모듈을 사용해 CPU 집약적인 작업을 별도 스레드로 분리했다.

// 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 path = require('path');

function resizeImage(buffer, width, height) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(path.join(__dirname, 'worker.js'), {
      workerData: { buffer, width, height }
    });
    
    worker.on('message', resolve);
    worker.on('error', reject);
  });
}

워커 풀 구현

매 요청마다 워커를 생성하면 오버헤드가 크기 때문에 워커 풀을 만들어 재사용했다.

class WorkerPool {
  constructor(workerPath, poolSize = 4) {
    this.workers = [];
    this.queue = [];
    
    for (let i = 0; i < poolSize; i++) {
      this.workers.push({
        worker: new Worker(workerPath),
        busy: false
      });
    }
  }
  
  async execute(data) {
    const available = this.workers.find(w => !w.busy);
    
    if (available) {
      return this.runTask(available, data);
    }
    
    return new Promise((resolve) => {
      this.queue.push({ data, resolve });
    });
  }
  
  runTask(workerObj, data) {
    return new Promise((resolve, reject) => {
      workerObj.busy = true;
      workerObj.worker.postMessage(data);
      
      workerObj.worker.once('message', (result) => {
        workerObj.busy = false;
        resolve(result);
        
        if (this.queue.length > 0) {
          const { data, resolve } = this.queue.shift();
          this.runTask(workerObj, data).then(resolve);
        }
      });
    });
  }
}

결과

  • 평균 응답 시간: 5.2초 → 1.8초
  • 동시 처리 용량: CPU 코어 수만큼 병렬 처리 가능
  • 메인 스레드 블로킹 해소로 다른 API 요청 지연 없음

워커 스레드는 CPU 집약 작업에 효과적이지만, 워커 간 데이터 전송 비용이 있으므로 작은 작업에는 오히려 역효과가 날 수 있다. 벤치마크로 임계점을 찾는 것이 중요했다.