Node.js 멀티스레딩: Worker Threads로 CPU 집약적 작업 처리하기
문제 상황
사용자 업로드 이미지를 여러 사이즈로 리사이징하는 API를 운영 중이었다. Sharp 라이브러리를 사용해 동기적으로 처리했는데, 동시 요청이 5개만 넘어가도 응답 시간이 2초를 넘어갔다.
Node.js는 싱글스레드 기반이라 CPU를 많이 쓰는 작업이 메인 스레드를 블로킹하면 다른 요청도 모두 대기하게 된다.
Worker Threads 도입
Node.js 12부터 안정화된 Worker Threads를 사용하기로 했다. 별도 스레드에서 이미지 처리를 하고 결과만 받는 구조다.
// 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);
})();
// api.js
const { Worker } = require('worker_threads');
function resizeImage(buffer, width, height) {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js', {
workerData: { buffer, width, height }
});
worker.on('message', resolve);
worker.on('error', reject);
});
}
Worker Pool 구현
매 요청마다 Worker를 생성하면 오버헤드가 크다. 미리 생성한 Worker를 재사용하는 풀을 만들었다.
class WorkerPool {
constructor(workerScript, poolSize = 4) {
this.workers = [];
this.queue = [];
for (let i = 0; i < poolSize; i++) {
const worker = new Worker(workerScript);
this.workers.push({ worker, busy: false });
}
}
execute(data) {
return new Promise((resolve, reject) => {
const available = this.workers.find(w => !w.busy);
if (available) {
this.runWorker(available, data, resolve, reject);
} else {
this.queue.push({ data, resolve, reject });
}
});
}
runWorker(workerInfo, data, resolve, reject) {
workerInfo.busy = true;
workerInfo.worker.postMessage(data);
workerInfo.worker.once('message', (result) => {
workerInfo.busy = false;
resolve(result);
if (this.queue.length > 0) {
const next = this.queue.shift();
this.runWorker(workerInfo, next.data, next.resolve, next.reject);
}
});
}
}
결과
동시 요청 10개 기준으로 평균 응답 시간이 2.3초에서 0.7초로 줄었다. CPU 코어를 제대로 활용하면서도 메인 스레드는 막히지 않아 다른 API 엔드포인트도 정상 동작했다.
Worker Threads는 아직 생태계가 완전하지 않지만, CPU 집약적 작업에는 확실히 효과적이었다. 다만 데이터 직렬화 비용이 있어서 작은 작업에는 오히려 역효과일 수 있다는 점을 염두에 둬야 한다.