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 코어 수만큼만 워커를 생성하는 게 효율적