워커 스레드로 Node.js 확장성 잠금 해제
Ethan Miller
Product Engineer · Leapcell

소개
수년 동안 개발자들은 Node.js의 논블로킹 I/O 모델과 이벤트 기반 아키텍처를 수용하여 강력한 확장성을 갖춘 웹 서버와 실시간 애플리케이션을 구축하는 데 탁월한 선택으로 인정받았습니다. 그러나 지속적인 과제는 단일 스레드 특성이었습니다. I/O 바운드 작업(데이터베이스 상호 작용 또는 네트워크 요청과 같음)에는 완벽하지만 CPU 바운드 작업(복잡한 계산, 데이터 압축 또는 이미지 처리와 같음)은 이벤트 루프를 차단하여 성능 병목 현상과 응답하지 않는 애플리케이션으로 이어질 수 있습니다. 이 제한으로 인해 개발자는 종종 집중적인 작업을 외부 서비스로 오프로드하거나 다른 언어를 고려해야 했습니다. 오늘날 Node.js에 worker_threads
가 등장함에 따라 마침내 이 단일 스레드 병목 현상에 작별을 고하고 단일 Node.js 프로세스 내에서 진정한 병렬 처리를 잠금 해제할 수 있습니다. 이 기사에서는 worker_threads
가 Node.js 애플리케이션이 CPU 집약적인 워크로드를 보다 효율적으로 처리하여 더 원활한 작동과 향상된 확장성을 보장하도록 지원하는 방법을 자세히 살펴봅니다.
워커 스레드로 단일 스레드 병목 현상 극복
worker_threads
의 중요성을 이해하려면 먼저 관련된 핵심 개념을 파악해야 합니다.
이벤트 루프: Node.js의 핵심은 모든 JavaScript 실행, 콜백 및 I/O 작업을 처리하는 단일 스레드인 이벤트 루프입니다. 이 스레드에서 CPU 집약적인 작업이 실행되면 이벤트 루프를 독점적으로 사용하여 완료될 때까지 다른 작업이 처리되지 못하게 됩니다. 이를 "이벤트 루프 차단"이라고 합니다.
스레드: 스레드는 스케줄러가 독립적으로 관리할 수 있는 가장 작은 프로그래밍된 명령 시퀀스입니다. 전통적으로 Node.js는 주로 단일 메인 스레드에서 실행되었습니다. worker_threads
는 동일한 Node.js 프로세스 내에서 추가 스레드를 생성하는 기능을 도입합니다.
워커 스레드: 메인 스레드와 달리 워커 스레드는 자체 V8 인스턴스와 이벤트 루프를 가진 격리된 환경에서 실행됩니다. 이러한 격리는 워커에서 실행되는 CPU 바운드 작업이 메인 스레드의 이벤트 루프를 차단하는 것을 방지하므로 중요합니다. 이들은 메시지 전달 메커니즘을 통해 서로 통신합니다.
작동 원리
worker_threads
의 핵심 원리는 CPU 집약적인 작업을 메인 스레드에서 별도의 워커 스레드로 오프로드하는 것입니다. 메인 스레드가 연산량이 많은 작업을 만나면 직접 실행하는 대신 워커 스레드를 생성합니다. 워커 스레드는 계산을 수행하고 완료되면 메시지를 통해 결과를 메인 스레드로 다시 보냅니다. 이를 통해 메인 스레드는 중단 없이 다른 요청을 계속 처리하여 애플리케이션 응답성을 유지할 수 있습니다.
구현 세부 정보 및 예시
대규모 범위 내에서 소수를 찾는 것과 같이 복잡하고 CPU 집약적인 계산을 수행하는 실용적인 예제를 통해 이를 설명해 보겠습니다.
먼저 worker_threads
없이 차단 작업이 어떻게 보이는지 살펴보겠습니다.
// main.js - worker_threads 없이 (차단됨!) function findPrimes(start, end) { const primes = []; for (let i = start; i <= end; i++) { let isPrime = true; if (i <= 1) { isPrime = false; } else { for (let j = 2; j <= Math.sqrt(i); j++) { if (i % j === 0) { isPrime = false; break; } } } if (isPrime) { primes.push(i); } } return primes; } const express = require('express'); const app = express(); const port = 3000; app.get('/blocking-prime', (req, res) => { console.log('Received blocking prime request'); const primes = findPrimes(2, 20000000); // This will block the event loop res.json({ count: primes.length, firstPrime: primes[0], lastPrime: primes[primes.length - 1] }); console.log('Finished blocking prime request'); }); app.get('/non-blocking', (req, res) => { console.log('Received non-blocking request'); res.send('This request is non-blocking'); console.log('Finished non-blocking request'); }); app.listen(port, () => { console.log(`Server listening at http://localhost:${port}`); });
/blocking-prime
을 누른 다음 즉시 /non-blocking
을 누르면 findPrimes
함수가 메인 스레드를 독점하고 있기 때문에 /non-blocking
이 응답하는 데 상당한 지연이 있음을 알 수 있습니다.
이제 worker_threads
를 사용하여 이를 리팩터링해 보겠습니다.
-
main.js
(메인 애플리케이션 파일):// main.js - worker_threads를 사용하여 const express = require('express'); const { Worker } = require('worker_threads'); const app = express(); const port = 3000; app.get('/worker-prime', (req, res) => { console.log('Received worker prime request on main thread'); // 새 워커 스레드 생성 const worker = new Worker('./prime_worker.js', { workerData: { start: 2, end: 20000000 } }); // 워커로부터 메시지 수신 대기 worker.on('message', (result) => { const { primes, duration } = result; res.json({ count: primes.length, firstPrime: primes[0], lastPrime: primes[primes.length - 1], duration: `${duration}ms` }); console.log('Finished worker prime request on main thread'); }); // 워커 오류 수신 대기 worker.on('error', (err) => { console.error('Worker error:', err); res.status(500).send('Error in worker thread'); }); // 워커 종료 수신 대기 worker.on('exit', (code) => { if (code !== 0) { console.error(`Worker stopped with exit code ${code}`); } }); }); app.get('/non-blocking', (req, res) => { console.log('Received non-blocking request on main thread'); res.send('This request is truly non-blocking now!'); console.log('Finished non-blocking request on main thread'); }); app.listen(port, () => { console.log(`Server listening at http://localhost:${port}`); });
-
prime_worker.js
(워커 스크립트):// prime_worker.js const { parentPort, workerData } = require('worker_threads'); function findPrimes(start, end) { const primes = []; for (let i = start; i <= end; i++) { let isPrime = true; if (i <= 1) { isPrime = false; } else { for (let j = 2; j <= Math.sqrt(i); j++) { if (i % j === 0) { isPrime = false; break; } } } if (isPrime) { primes.push(i); } } return primes; } const { start, end } = workerData; const startTime = process.hrtime.bigint(); const primes = findPrimes(start, end); const endTime = process.hrtime.bigint(); const duration = Number(endTime - startTime) / 1000000; // Convert nanoseconds to milliseconds // 결과 부모 스레드로 다시 보내기 parentPort.postMessage({ primes, duration });
이 설정으로 /worker-prime
을 누른 다음 즉시 /non-blocking
을 누르면 /non-blocking
요청이 거의 즉시 응답하는 것을 볼 수 있으며, 이는 소수 계산이 더 이상 메인 이벤트 루프를 차단하지 않음을 보여줍니다.
주요 worker_threads
구성 요소:
Worker
클래스: 메인 스레드에서 새 워커 스레드를 생성하는 데 사용됩니다. 생성자는 워커 스크립트의 경로와 워커에 전달되는workerData
객체를 선택적으로 가져옵니다.parentPort
(워커 내): 워커 스크립트 내에서 사용할 수 있는 객체로, 부모 스레드와의 통신 채널을 나타냅니다. 데이터를 다시 보내려면parentPort.postMessage()
를 사용합니다.workerData
(워커 내): 워커 스크립트 내에서 사용할 수 있는 객체로, 부모 스레드의workerData
옵션에서 전달된 데이터를 포함합니다.worker.on('message', ...)
(부모에서): 워커에서 보낸 메시지를 수신하는 메인 스레드의 이벤트 수신기입니다.worker.on('error', ...)
및worker.on('exit', ...)
: 강력한 오류 처리 및 워커 수명 주기 모니터링에 중요합니다.
애플리케이션 시나리오
worker_threads
는 CPU 바운드 문제를 겪고 있는 모든 Node.js 애플리케이션에 이상적입니다. 일반적인 사용 사례는 다음과 같습니다.
- 복잡한 수학적 계산: 데이터 분석, 과학 시뮬레이션, 금융 계산.
- 이미지 및 비디오 처리: 크기 조정, 워터마킹, 필터링, 인코딩/디코딩.
- 데이터 압축/압축 해제: 대용량 파일 압축/압축 해제.
- 해싱 및 암호화: 암호화 연산.
- 집중적인 데이터 구문 분석 및 변환: 대용량 CSV, JSON 또는 XML 파일 구문 분석.
- 머신 러닝 추론: 사전 훈련된 모델 실행.
이러한 작업을 워커 스레드로 오프로드함으로써 메인 이벤트 루프는 들어오는 요청 및 기타 I/O 작업을 처리하는 데 자유롭게 사용할 수 있으며 Node.js 애플리케이션의 전반적인 응답성과 처리량을 크게 향상시킵니다.
결론
Node.js worker_threads
는 게임 체인저로서 전통적인 단일 스레드 환경에서 CPU 바운드 작업을 처리하는 방식을 근본적으로 변화시킵니다. 진정한 병렬 처리를 가능하게 함으로써 개발자는 집중적인 계산을 위해 멀티 프로세스 아키텍처나 외부 서비스에 의존하지 않고도 더 강력하고 성능이 뛰어나며 확장 가능한 애플리케이션을 구축할 수 있습니다. worker_threads
를 채택하면 Node.js는 CPU 집약적인 워크로드에 대한 "단일 스레드 병목" 레이블을 벗겨내고 최신 애플리케이션 개발을 위한 훨씬 더 다재다능하고 강력한 선택이 될 수 있습니다.