Node.js 워커 스레드로 CPU 집약 작업 처리하기
문제 상황
사내 이미지 처리 API 서버에서 여러 썸네일을 동시에 생성할 때 응답 시간이 5초 이상 소요되는 이슈가 있었다. Sharp 라이브러리로 이미지를 리사이징하는 작업이 메인 스레드를 블로킹하면서 다른 요청까지 지연되는 상황이었다.
워커 스레드 도입
Node.js의 worker_threads 모듈을 사용해 CPU 집약적인 작업을 별도 스레드로 분리했다.
// 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);
})();
// main.js
const { Worker } = require('worker_threads');
const path = require('path');
function resizeImage(buffer, width, height) {
return new Promise((resolve, reject) => {
const worker = new Worker(path.join(__dirname, 'worker.js'), {
workerData: { buffer, width, height }
});
worker.on('message', resolve);
worker.on('error', reject);
});
}
워커 풀 구현
매 요청마다 워커를 생성하면 오버헤드가 크기 때문에 워커 풀을 만들어 재사용했다.
class WorkerPool {
constructor(workerPath, poolSize = 4) {
this.workers = [];
this.queue = [];
for (let i = 0; i < poolSize; i++) {
this.workers.push({
worker: new Worker(workerPath),
busy: false
});
}
}
async execute(data) {
const available = this.workers.find(w => !w.busy);
if (available) {
return this.runTask(available, data);
}
return new Promise((resolve) => {
this.queue.push({ data, resolve });
});
}
runTask(workerObj, data) {
return new Promise((resolve, reject) => {
workerObj.busy = true;
workerObj.worker.postMessage(data);
workerObj.worker.once('message', (result) => {
workerObj.busy = false;
resolve(result);
if (this.queue.length > 0) {
const { data, resolve } = this.queue.shift();
this.runTask(workerObj, data).then(resolve);
}
});
});
}
}
결과
- 평균 응답 시간: 5.2초 → 1.8초
- 동시 처리 용량: CPU 코어 수만큼 병렬 처리 가능
- 메인 스레드 블로킹 해소로 다른 API 요청 지연 없음
워커 스레드는 CPU 집약 작업에 효과적이지만, 워커 간 데이터 전송 비용이 있으므로 작은 작업에는 오히려 역효과가 날 수 있다. 벤치마크로 임계점을 찾는 것이 중요했다.