Worker Threads로 CPU 집약 작업 처리하기
Node.js worker_threads 사용법과 예제 코드를 통해 CPU 집약 작업을 메인 이벤트 루프에서 안전하게 분리하는 방법과 성능·안정성 고려사항을 정리한 기술 설명
목차
소개
Node.js는 단일 스레드 이벤트 루프 모델을 사용한다. I/O 중심 애플리케이션에는 이 구조가 매우 효율적이다. 그러나 암호화, 이미지 처리, 대량 계산처럼 CPU를 많이 쓰는 작업은 메인 스레드에서 실행하면 이벤트 루프를 막아 전체 응답성이 떨어진다. worker_threads 모듈은 이런 문제를 해결하기 위한 스레드 기반 병렬 처리 수단을 제공한다. 이 글에서는 Node.js worker_threads 사용법, 간단한 예제, 성능 및 안정성 고려사항을 쉽게 살펴본다.
worker_threads가 필요한 이유
이벤트 루프 차단 문제
동일한 스레드에서 무거운 계산을 하면 비동기 콜백과 네트워크 응답이 지연된다. 짧은 블로킹 작업도 누적되면 전체 처리량이 저하된다.
worker_threads의 장점
- 계산 작업을 별도 스레드로 분리해 메인 루프를 보호한다.
- 스레드 간 데이터 전달은 메시지 또는 SharedArrayBuffer로 가능하다.
- 프로세스 생성보다 가볍고 빠르다.
동작 원리 요약
worker_threads는 동일 프로세스 내에서 여러 스레드를 생성한다. 각 워커는 자체 이벤트 루프와 V8 컨텍스트를 갖는다. 메인 스레드는 Worker를 생성하고, 메시지 포트(parentPort)를 통해 데이터와 결과를 주고받는다. 큰 데이터를 주고받을 때는 transferable 객체(예: ArrayBuffer)나 SharedArrayBuffer를 사용하면 복사 비용을 줄일 수 있다.
간단한 예제
아래 예제는 메인 스레드에서 워커를 생성해 CPU 집약 작업(예: 피보나치 계산)을 오프로드하는 구조를 보여준다.
main.js
const { Worker } = require('worker_threads')
function runWorker(input) {
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js')
worker.postMessage(input)
worker.once('message', result => resolve(result))
worker.once('error', err => reject(err))
worker.once('exit', code => {
if (code !== 0) reject(new Error('Worker stopped with exit code ' + code))
})
})
}
async function main() {
console.time('total')
const inputs = [40, 41, 42]
const results = await Promise.all(inputs.map(n => runWorker(n)))
console.log('results', results)
console.timeEnd('total')
}
main().catch(console.error)
worker.js
const { parentPort } = require('worker_threads')
function fib(n) {
if (n <= 1) return n
return fib(n - 1) + fib(n - 2)
}
parentPort.on('message', (n) => {
const result = fib(n)
parentPort.postMessage({ n, result })
})
설계 및 성능 고려사항
스레드 수 결정
워커를 무작정 많이 만들면 컨텍스트 스위칭 비용과 메모리 사용량이 증가한다. 일반적으로 CPU 코어 수를 기준으로 워커 수를 조정한다. 고정 워커 풀을 만들어 요청을 큐로 처리하면 안정적이다.
데이터 전달 방식
postMessage는 구조분해 복사를 사용하므로 큰 객체를 전송하면 비용이 크다. ArrayBuffer를 transferable로 넘기거나 SharedArrayBuffer를 사용하면 복사 없이 메모리를 공유할 수 있다. 다만 동기화와 레이스 컨디션을 주의해야 한다.
에러와 종료 처리
- Worker에서 발생한 에러는 메인으로 전달된다. 반드시 error 이벤트를 처리해야 한다.
- 예기치 않은 종료(exit) 시 재시작 로직이나 대체 경로를 마련해 가용성을 확보한다.
실무 적용 팁
- 작업 단위를 너무 작게 나누면 오버헤드가 커진다. 적절한 작업 크기를 실험으로 찾아야 한다.
- I/O와 CPU 작업은 분리해서 설계한다. I/O는 메인 루프나 비동기 방식으로, CPU는 워커로 처리한다.
- 프로파일링 도구로 스레드별 CPU 사용량을 모니터링해 병목을 찾는다.
오류 예외 처리 예시
아래는 워커에서 발생한 에러를 안전하게 처리하는 패턴이다.
worker.once('error', err => {
console.error('Worker error', err)
// 재시작 또는 실패 응답 처리
})
worker.once('exit', code => {
if (code !== 0) {
console.error('Worker exited with code', code)
// 재시작 로직
}
})
결론
worker_threads는 Node.js에서 CPU 집약 작업을 안전하게 분리하는 효율적인 방법이다. 적절한 워커 수, 데이터 전달 방식, 에러 처리 전략을 설계하면 응답성 유지와 처리량 향상을 동시에 달성할 수 있다. 처음 접하는 개발자라면 간단한 워커 예제부터 시작해 단계적으로 워커 풀과 공유 버퍼를 도입하는 방식을 권한다. 이 과정에서 프로파일링을 병행하면 최적화를 빠르게 진행할 수 있다.