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 코어 수와 동일하게 설정했다.