클러스터 및 워커 스레드를 사용한 Node.js 애플리케이션 동시 확장
Lukas Schneider
DevOps Engineer · Leapcell

소개
Node.js는 단일 스레드, 이벤트 기반 아키텍처로 높은 동시성과 I/O 바운드 작업을 처리하는 데 탁월합니다. 그러나 CPU 바운드 작업을 처리할 때 또는 요청량이 단일 프로세스를 압도할 때 이러한 특성 자체가 병목 현상이 될 수 있습니다. 이러한 시나리오에서는 Node.js 애플리케이션을 효과적으로 확장하는 능력이 가장 중요합니다.
이 글은 Node.js 애플리케이션을 수직으로 확장하는 두 가지 주요 패턴, 즉 cluster 모듈을 사용한 멀티 프로세스와 worker_threads를 사용한 멀티 스레드에 대해 자세히 설명합니다.
이러한 패턴을 이해하는 것은 최신 멀티 코어 프로세서를 최대한 활용하는 강력하고 성능이 뛰어난 Node.js 서비스를 구축하는 데 중요합니다.
각 접근 방식이 단일 Node.js 프로세스의 한계를 어떻게 해결하는지 살펴보고 개발자가 특정 요구에 가장 적합한 전략을 선택할 수 있도록 지원합니다.
Node.js의 동시성 이해
확장 패턴에 대해 자세히 알아보기 전에 몇 가지 기본 개념을 명확히 해 보겠습니다.
- 프로세스: 자체 메모리 공간과 리소스를 가진 독립적인 실행 환경입니다. Node.js에서는 일반적인 애플리케이션이 단일 프로세스로 실행됩니다.
- 스레드: 프로세스 내의 실행 순서입니다. 여러 스레드가 단일 프로세스 내에 존재하며 해당 메모리 공간을 공유할 수 있습니다(Node.js의 메인 스레드는 JavaScript 실행에 대해 단일 스레드이지만).
- CPU 바운드 작업: 계산 집약적 작업(예: 복잡한 계산, 데이터 압축 또는 이미지 처리)과 같이 상당한 CPU 시간을 소비하는 작업입니다. 이러한 작업은 단일 스레드 환경에서 이벤트 루프를 차단할 수 있습니다.
- I/O 바운드 작업: 네트워크 요청, 데이터베이스 쿼리 또는 파일 시스템 액세스와 같이 외부 리소스를 기다리는 데 대부분의 시간을 소비하는 작업입니다. Node.js의 비동기 특성은 단일 스레드에서 이러한 작업을 잘 처리합니다.
- 이벤트 루프: Node.js의 비동기, 논블로킹 I/O의 핵심입니다. 실행할 작업과 실행할 콜백을 지속적으로 확인합니다. 오래 실행되는 CPU 바운드 작업은 이벤트 루프를 "차단"하여 애플리케이션이 응답하지 않게 만들 수 있습니다.
멀티 프로세스 클러스터 모듈로 확장
cluster 모듈은 동일한 서버 포트를 공유하는 자식 프로세스(워커)를 생성할 수 있도록 합니다. 이를 통해 들어오는 요청을 여러 Node.js 프로세스에 효과적으로 분산하여 애플리케이션이 모든 사용 가능한 CPU 코어를 활용할 수 있습니다.
작동 방식
cluster 모듈은 마스터-워커 모델에서 작동합니다.
- 마스터 프로세스: 이 프로세스는 워커 프로세스를 스폰하고 관리하는 역할을 합니다. 일반적으로 단일 포트에서 수신 대기한 다음 들어오는 연결을 워커에게 위임합니다.
- 워커 프로세스: 이들은 마스터와 동일한 포트를 공유하고 실제 클라이언트 요청을 처리하는 독립적인 Node.js 프로세스입니다. 각 워커는 자체 이벤트 루프와 메모리 공간을 포함하여 애플리케이션 코드의 별도 인스턴스를 실행합니다.
구현 예제
CPU 집약적인 작업을 수행하는 간단한 HTTP 서버로 설명해 보겠습니다.
server.js (마스터/워커 코드):
const cluster = require('cluster'); const http = require('http'); const numCPUs = require('os').cpus().length; if (cluster.isMaster) { console.log(`Master ${process.pid} is running`); // Fork workers. for (let i = 0; i < numCPUs; i++) { cluster.fork(); } cluster.on('exit', (worker, code, signal) => { console.log(`Worker ${worker.process.pid} died`); // 선택적으로, 고가용성을 보장하기 위해 여기서 워커를 다시 스폰할 수 있습니다. // cluster.fork(); }); } else { // Worker processes can share any TCP connection. // In this case, it is an HTTP server. http.createServer((req, res) => { // Simulate a CPU-bound task if (req.url === '/cpu') { let sum = 0; for (let i = 0; i < 1e9; i++) { sum += i; } res.writeHead(200); res.end(`Hello from Worker ${process.pid}! Sum: ${sum}\n`); } else { res.writeHead(200); res.end(`Hello from Worker ${process.pid}!\n`); } }).listen(8000); console.log(`Worker ${process.pid} started`); }
이것을 실행하려면 server.js로 저장하고 node server.js를 실행하세요. http://localhost:8000에 액세스하면 다른 워커 PID에서 처리하는 요청을 볼 수 있습니다. http://localhost:8000/cpu를 클릭하면 한 워커가 바빠지지만 다른 워커는 계속 요청을 처리할 수 있습니다.
사용 사례
- HTTP 요청 균형: REST API 또는 웹 서버의 경우 특히 그렇습니다.
- CPU 활용도 극대화: 범용 애플리케이션을 위해 여러 코어에 걸쳐 워크로드를 분산합니다.
- 내결함성 향상: 워커가 충돌하면 다른 워커가 요청을 계속 처리할 수 있습니다.
장단점
장점:
- 전체 CPU 활용: 사용 가능한 모든 코어를 활용합니다.
- 격리: 각 워커는 자체 메모리 공간을 가지므로 한 워커의 문제가 다른 워커에 직접적인 영향을 미치는 것을 방지합니다.
- 처리량 증가: 더 많은 동시 요청을 처리할 수 있습니다.
단점:
- 프로세스 간 통신(IPC) 오버헤드: 워커 간 데이터 공유는 명시적인 IPC가 필요하며 이는 공유 메모리보다 느릴 수 있습니다.
- 상태 관리 복잡성: 각 워커가 격리되어 있으므로 전역 애플리케이션 상태는 신중한 동기화 또는 외부 저장소(예: Redis)가 필요합니다.
- 메모리 소비 증가: 각 워커는 완전한 Node.js 프로세스이므로 RAM 사용량이 더 많습니다.
워커 스레드 모듈로 확장
Node.js v10.5.0에서 도입되고 v12.x부터 안정화된 worker_threads 모듈은 단일 Node.js 프로세스 내에서 여러 JavaScript 스레드를 실행할 수 있도록 합니다.
cluster 모듈과 달리 워커 스레드는 자체 V8 인스턴스와 이벤트 루프에 대한 격리된 복사본을 가지면서도 프로세스 메모리를 공유하며 메시징을 통해 통신합니다 (JavaScript 실행에 대해).
작동 방식
- 메인 스레드: 이는 기본 Node.js 실행 스레드입니다. 새 워커 스레드를 스폰할 수 있습니다.
- 워커 스레드: V8 엔진의 격리된 인스턴스와 자체 이벤트 루프를 실행하는 별도의 스레드입니다. 메인 스레드와 병렬로 JavaScript 코드를 실행할 수 있습니다. 메인 스레드와 워커 스레드 간의 통신은 메시지 전달 메커니즘 또는
SharedArrayBuffer개체 공유를 통해 발생합니다.
구현 예제
worker_threads를 사용하여 CPU 집약적 작업을 리팩터링해 보겠습니다.
main.js (메인 스레드):
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads'); const http = require('http'); if (isMainThread) { console.log(`Main thread ${process.pid} is running`); http.createServer((req, res) => { if (req.url === '/cpu') { const worker = new Worker('./worker-task.js', { workerData: { num: 1e9 } // Data to pass to the worker }); worker.on('message', (result) => { res.writeHead(200); res.end(`Hello from Main Thread! Sum: ${result}\n`); }); worker.on('error', (err) => { console.error(err); res.writeHead(500); res.end('Error processing request.\n'); }); worker.on('exit', (code) => { if (code !== 0) console.error(`Worker stopped with exit code ${code}`); }); } else { res.writeHead(200); res.end('Hello from Main Thread (non-CPU path)!\n'); } }).listen(8001); console.log('Server listening on port 8001'); }
worker-task.js (워커 스레드):
const { parentPort, workerData } = require('worker_threads'); if (parentPort) { // Ensure it's a worker thread context let sum = 0; for (let i = 0; i < workerData.num; i++) { sum += i; } parentPort.postMessage(sum); }
이것을 실행하려면 main.js와 worker-task.js를 같은 디렉토리에 저장하고 node main.js를 실행하세요. http://localhost:8001에 액세스합니다. http://localhost:8001/cpu를 클릭하면 계산이 워커 스레드로 오프로드되어 메인 스레드가 다른 요청을 처리할 수 있습니다.
사용 사례
- 단일 프로세스에서의 CPU 바운드 작업: 이미지 크기 조정, 비디오 인코딩, 데이터 처리, 암호화 작업 또는 이벤트 루프를 차단하는 무거운 데이터 조작과 같은 계산에 이상적입니다.
- 메인 스레드 응답성 유지: 데스크톱 애플리케이션(예: Electron)의 UI 응답성을 보장하거나 중요한 API 경로의 지연을 방지합니다.
- 논블로킹 작업의 병렬 실행: Node.js는 논블로킹 I/O에 탁월하지만
worker_threads는 완료 순서가 중요하지 않고 전체 실행 시간을 줄이려는 경우 여러 독립적인 I/O 작업의 병렬 처리에 사용할 수 있습니다.
장단점
장점:
- 이벤트 루프 차단 방지: 메인 스레드에서 CPU 집약적인 작업을 오프로드합니다.
- 낮은 메모리 오버헤드 (cluster 대비): 워커는 일부 프로세스 리소스를 공유하므로 별도의 프로세스보다 메모리 소비가 적습니다.
- 효율적인 메시지 전달:
postMessage를 통한 통신은 일반적으로 별도 프로세스 간의 IPC보다 효율적입니다. - 공유 메모리 (SharedArrayBuffer 통해): 스레드가 공유 메모리를 직접 조작해야 하는 고급 사용 사례를 허용하지만 주의 깊은 동기화가 필요합니다.
단점:
- 직접적인 HTTP 요청 균형 조절용이 아님:
worker_threads는 포트에서 직접 수신 대기하도록 설계되지 않았으며(워커는 가능하지만)cluster처럼 자동 로드 밸런싱을 수행하지 않습니다. 일반적으로 특정 CPU 집약적 작업을 오프로드하는 메인 스레드가 여전히 필요합니다. - 동시성 버그 가능성: 공유 메모리 (
SharedArrayBuffer통해)는 주의 깊은 동기화(예: 원자 작업 및 잠금) 없이 처리될 때 레이스 컨디션 및 데드락과 같은 복잡성을 야기합니다. - 워커당 여전히 단일 스레드 JavaScript 실행: 각 워커는 JavaScript를 순차적으로 실행합니다. 병렬성은 단일 워커 내에서 JavaScript가 병렬로 실행되는 것이 아니라 여러 워커가 병렬로 실행되는 것에서 비롯됩니다.
결론
cluster와 worker_threads 모두 Node.js 애플리케이션을 수직으로 확장하는 강력한 메커니즘을 제공하지만, 각각 다른 주요 목적을 수행합니다.
cluster 모듈은 들어오는 네트워크 요청을 여러 프로세스로 분산하여 I/O 바운드 또는 범용 웹 서버 워크로드에 대해 모든 CPU 코어를 효과적으로 활용하는 데 이상적입니다.
반대로 worker_threads는 메인 이벤트 루프에서 CPU 바운드 계산을 오프로드하여 단일 Node.js 프로세스 내에서 응답성과 지속적인 성능을 보장하는 데 탁월합니다.
강력한 Node.js 애플리케이션은 전체적인 요청 균형을 위해 cluster를 사용하고 특정 CPU 집약적 계산을 위해 각 워커 프로세스 내에서 worker_threads를 활용하는 하이브리드 접근 방식을 사용할 수도 있습니다.
이러한 패턴 중 선택하거나 조합하는 것은 궁극적으로 애플리케이션의 특정 성능 병목 현상과 아키텍처 요구 사항에 따라 달라집니다.

