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

문제 상황

사용자 업로드 이미지를 여러 사이즈로 리사이징하는 API를 운영 중이었다. Sharp 라이브러리로 처리했는데, 동시 요청이 5개만 넘어가도 응답 시간이 3초 이상으로 늘어났다.

Node.js는 싱글 스레드 기반이라 CPU 집약적 작업이 이벤트 루프를 블로킹하는 게 원인이었다. Child Process로 분리하는 방법도 있지만, 프로세스 생성 오버헤드가 부담스러웠다.

Worker Threads 도입

Node.js 10.5에서 실험적으로 도입됐던 Worker Threads가 12.11 버전에서 stable로 전환됐다. 별도 스레드에서 작업을 실행하면서도 메모리를 공유할 수 있어 적합하다고 판단했다.

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

function resizeImage(buffer, sizes) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(
      path.resolve(__dirname, 'image-worker.js'),
      { workerData: { buffer, sizes } }
    );
    
    worker.on('message', resolve);
    worker.on('error', reject);
    worker.on('exit', (code) => {
      if (code !== 0) {
        reject(new Error(`Worker stopped with exit code ${code}`));
      }
    });
  });
}

워커 파일은 다음과 같이 구성했다.

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

async function process() {
  const { buffer, sizes } = workerData;
  const results = await Promise.all(
    sizes.map(size => 
      sharp(buffer)
        .resize(size.width, size.height)
        .toBuffer()
    )
  );
  parentPort.postMessage(results);
}

process().catch(err => {
  throw err;
});

결과

동시 요청 10개 기준으로 평균 응답 시간이 3.2초에서 0.8초로 줄었다. CPU 코어를 효율적으로 활용하면서 메인 스레드는 다른 요청을 처리할 수 있게 됐다.

워커 풀을 구현해 재사용하면 더 최적화할 수 있을 것 같지만, 현재 트래픽에서는 매번 생성하는 방식으로도 충분했다. 필요하면 나중에 개선하기로 했다.

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