Node.js 워커 스레드로 CPU 집약 작업 병렬화하기

문제 상황

이미지 리사이징 API를 운영 중인데, 한 번에 10개 이상의 이미지를 처리할 때 응답 시간이 30초를 넘어 타임아웃이 발생했다. Sharp 라이브러리로 순차 처리하다 보니 메인 스레드가 완전히 블로킹되어 다른 요청도 처리할 수 없는 상태였다.

워커 스레드 도입

Node.js 12부터 안정화된 worker_threads 모듈을 사용해 이미지 처리를 병렬화했다.

const { Worker } = require('worker_threads');
const os = require('os');

class WorkerPool {
  constructor(workerPath, poolSize = os.cpus().length) {
    this.workerPath = workerPath;
    this.poolSize = poolSize;
    this.workers = [];
    this.queue = [];
    this.initWorkers();
  }

  initWorkers() {
    for (let i = 0; i < this.poolSize; i++) {
      const worker = new Worker(this.workerPath);
      worker.on('message', (result) => {
        worker.busy = false;
        this.processQueue();
      });
      this.workers.push(worker);
    }
  }

  async execute(data) {
    return new Promise((resolve, reject) => {
      const task = { data, resolve, reject };
      this.queue.push(task);
      this.processQueue();
    });
  }

  processQueue() {
    if (this.queue.length === 0) return;
    const worker = this.workers.find(w => !w.busy);
    if (!worker) return;

    const task = this.queue.shift();
    worker.busy = true;
    worker.once('message', task.resolve);
    worker.once('error', task.reject);
    worker.postMessage(task.data);
  }
}

워커 스레드 파일은 다음과 같이 작성했다.

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

parentPort.on('message', async ({ buffer, width, height }) => {
  try {
    const resized = await sharp(buffer)
      .resize(width, height)
      .toBuffer();
    parentPort.postMessage({ success: true, buffer: resized });
  } catch (error) {
    parentPort.postMessage({ success: false, error: error.message });
  }
});

결과

  • 10개 이미지 처리 시간: 28초 → 8초
  • 메인 스레드 블로킹 제거로 다른 API 응답성 유지
  • CPU 코어를 효율적으로 활용 (8코어 환경에서 거의 선형 성능 향상)

주의사항

워커 스레드는 생성 비용이 있어서 풀을 미리 만들어두는 게 중요했다. 매 요청마다 워커를 생성하면 오히려 더 느려진다. 그리고 메모리 사용량도 늘어나므로 풀 크기 조정이 필요했다. 프로덕션에서는 CPU 코어 수와 동일하게 설정했다.