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

문제 상황

이미지 업로드 서비스에서 대용량 이미지를 여러 크기로 리사이징하는 기능을 추가했다. Sharp 라이브러리를 사용했는데, 리사이징 작업 중에 다른 요청들이 대기하는 현상이 발생했다.

app.post('/upload', async (req, res) => {
  const buffer = req.file.buffer;
  
  // 이 작업이 CPU를 점유하는 동안 다른 요청 블로킹
  const thumbnail = await sharp(buffer)
    .resize(200, 200)
    .toBuffer();
    
  const medium = await sharp(buffer)
    .resize(800, 800)
    .toBuffer();
});

부하 테스트 결과 동시 요청 10개 처리 시 평균 응답 시간이 8초까지 늘어났다.

워커 스레드 도입

Node.js의 worker_threads 모듈을 사용해 리사이징 작업을 별도 스레드로 분리했다.

// workers/image-processor.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');

function resizeImage(buffer, width, height) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./workers/image-processor.js', {
      workerData: { buffer, width, height }
    });
    
    worker.on('message', resolve);
    worker.on('error', reject);
  });
}

app.post('/upload', async (req, res) => {
  const buffer = req.file.buffer;
  
  const [thumbnail, medium] = await Promise.all([
    resizeImage(buffer, 200, 200),
    resizeImage(buffer, 800, 800)
  ]);
  
  // S3 업로드 등 후속 처리
});

결과

동시 요청 10개 기준 평균 응답 시간이 2.3초로 줄었다. 워커 스레드가 CPU 코어를 활용하면서 메인 스레드는 다른 요청을 계속 받을 수 있게 되었다.

다만 워커 생성 비용이 있어서, 실제로는 워커 풀을 만들어 재사용하는 방식으로 개선할 예정이다. Piscina 같은 라이브러리를 검토 중이다.

참고사항

  • 워커는 메모리를 공유하지 않아 데이터 전달 시 직렬화 비용 발생
  • Buffer는 transferList로 전달하면 복사 없이 소유권 이전 가능
  • CPU 코어 수만큼만 워커를 생성하는 게 효율적