Node.js 성능 향상: I/O-bound에는 Async와 CPU-bound를 위한 Cluster API vs Worker Threads 학습 및 비교 테스트
Description: Node.js는 성능 향상을 위해 I/O-bound 작업에 Async를 사용하여 파일 입출력과 같은 대기 시간이 많은 작업을 효율적으로 처리합니다. CPU-bound 작업은 Cluster API를 통한 프로세스 분산 처리와 Worker Threads를 통한 스레드 기반 병렬 처리로 최적화할 수 있습니다. 작업 특성과 요구 사항에 따라 두 방식을 적절히 활용하거나 하이브리드 접근법을 사용하면 성능을 극대화할 수 있습니다
현재 노트: KR-010.40 a
상위 분류: KR-010.40 Node.js
글 작성 이유
Node 작업의 I/O bound 작업과 CPU-bound 작업의 다른 특성, 그리고 CPU-bound 작업중에서도 Cluster API와 Worker Threads의 차이에 대해 알아봅니다.
Node.js는 싱글 스레드 기반의 비동기 런타임 환경으로, I/O-bound 작업에서 높은 효율성을 제공합니다. 이는 Event Loop를 통해 비동기 작업을 처리하며, 네트워크 요청이나 파일 입출력 등 대기 시간이 많은 작업에서 성능을 극대화합니다.
하지만 CPU-bound 작업에서는 복잡한 연산으로 인해 이벤트 루프가 차단되어 병목 현상이 발생할 수 있습니다. 이를 해결하기 위해 Node.js는 두 가지 병렬 처리 방법을 제공합니다:
- Cluster API: 멀티프로세스를 활용하여 작업 분산 처리.
- Worker Threads: 멀티스레드를 사용하여 메모리를 공유하면서 병렬 작업 처리.
이 글에서는 Cluster API와 Worker Threads를 활용한 CPU-bound 작업 최적화, 그리고 Async를 통한 I/O-bound 작업 효율화를 설명하며, 각각의 사용 사례와 장단점을 소개합니다.
Node.js 성능 최적화의 세 가지 접근법
1. I/O-bound 작업 최적화를 위한 Async
I/O-bound 작업은 단순한 작업이지만, 데이터 전송 및 입출력 처리 과정에서 네트워크나 디스크 I/O 비용이 많이 발생하여 대기 시간이 큽니다. 대표적으로 다음과 같은 작업이 포함됩니다:
- 파일 입출력: 대규모 파일 읽기 및 쓰기.
- HTTP 요청: 외부 API 호출 및 웹 요청 처리.
Async는 이러한 I/O 작업을 Event Loop를 활용해 비동기적으로 처리하며, 블로킹을 방지하여 싱글 스레드 환경에서도 많은 요청을 동시에 처리할 수 있는 효율성을 제공합니다.
테스트 코드 예제 (I/O-bound 작업 최적화: Async vs Non-Async)
아래는 fs
모듈을 사용해서 100개의 라인을 읽을 때 비동기 방식과 동기 방식의 성능 차이를 비교합니다.
import fs from "fs/promises"
const asyncFileTest = async () => {
console.time('Async File Read');
const files = Array(100).fill('./test-file.txt');
const promises = files.map((file) => fs.readFile(file, 'utf-8'));
await Promise.all(promises);
console.timeEnd('Async File Read');
};
const syncFileTest = async () => {
console.time('Non-Async File Read');
const files = Array(100).fill('./test-file.txt');
for (const file of files) {
await fs.readFile(file, 'utf-8');
}
console.timeEnd('Non-Async File Read');
};
asyncFileTest();
syncFileTest();
결과
-
Async (비동기 방식):
Async는 100개의 명령을 한꺼번에 실행 요청하는 방식입니다.
100번의readFile
을 빠르게 요청한 후, Promise.all을 통해 모든 작업이 완료되었을 때 결과를 반환합니다.
이는 마치 100개의 주문을 동시에 접수하고, 여러 주방 설비가 이를 병렬로 처리한 뒤, 모든 주문이 완성되면 한꺼번에 내보내는 것과 같습니다. -
Non-Async (동기 방식):
Non-Async는 100개의 명령을 순차적으로 실행합니다.
1번 작업이 완료된 후에야 2번 작업을 시작하고, 이 과정이 반복됩니다.
이는 마치 100개의 주문을 하나씩 처리하며, 1번 주문이 끝난 뒤에야 2번 주문을 시작하는 방식입니다.
따라서 모든 작업이 끝나기까지 시간이 오래 걸리며, 주방 설비를 비효율적으로 사용하는 것과 같아 성능이 떨어집니다.
2. CPU-bound 작업 최적화를 위한 Cluster API와 Worker Threads
CPU-bound 작업은 계산 복잡도가 높은 작업으로, 주로 CPU 자원에 의존하며, 많은 연산을 요구합니다. 이 작업의 병목은 CPU 연산에 있으며, 이벤트 루프가 차단되어 다른 작업을 처리하지 못할 수 있습니다. 대표적으로 다음과 같은 작업이 포함됩니다:
- 수학적 연산: 소수 찾기, 행렬 곱셈, 대규모 수 계산.
- 데이터 암호화 및 복호화: 해시 생성, SSL/TLS 암호화 처리.
- 이미지/비디오 처리: 이미지 리사이즈, 변환, 압축.
Node.js는 싱글 스레드 환경에서 CPU 연산 집중 작업을 처리하기 어렵기 때문에, Cluster API와 Worker Threads를 활용해 CPU 작업을 분산 처리하여 성능을 최적화할 수 있습니다.
- Cluster API는 멀티프로세스 방식을 활용하여 각 프로세스가 독립적으로 작업을 수행.
- Worker Threads는 멀티스레드 방식을 사용하여 같은 프로세스 내에서 병렬 작업을 처리.
Cluster API 특징
- 독립적 프로세스 생성:
cluster.fork()
를 통해 새로운 워커 프로세스를 생성.- 각 프로세스는 독립적으로 실행되어 충돌 위험이 낮음.
- 작업 분산 처리:
- CPU-intensive 작업을 여러 프로세스에 분산하여 처리 속도를 향상.
- Node.js 기본 제공 라이브러리:
- 외부 의존성 없이 사용 가능하며, 설정이 간단.
테스트 코드 예제 (Cluster API):
import cluster from 'cluster';
import { cpus } from 'os';
const numCPUs = cpus().length;
const TEST_NUM = 1e9
// 싱글스레드 방식 계산
const singleThreadCompute = () => {
console.time('Single Thread Execution Time');
let sum = 0;
for (let i = 1; i <= TEST_NUM; i++) {
sum += i;
}
console.timeEnd('Single Thread Execution Time');
console.log(`[Single Thread] Total Computation: ${sum}`);
};
// Cluster 방식 계산
if (cluster.isPrimary) {
console.log(`[Primary] Starting performance comparison... Test Number is Sum of from 1 ${TEST_NUM.toExponential()}\n`);
// 1. 싱글스레드 계산
console.log('Running Single Thread Computation...');
singleThreadCompute();
// 2. Cluster 방식 계산
console.log('\nRunning Cluster-based Computation...');
console.time('Cluster Execution Time');
const range = TEST_NUM; // 총 계산 범위
const chunkSize = Math.ceil(range / numCPUs); // 워커별 작업 범위
let totalSum = 0;
let completedWorkers = 0;
for (let i = 0; i < numCPUs; i++) {
const start = i * chunkSize + 1;
const end = Math.min((i + 1) * chunkSize, range);
const worker = cluster.fork({ START: start, END: end });
worker.on('message', (msg) => {
totalSum += msg.partialSum;
console.log(`[Worker ${msg.pid}] Range: ${msg.start}-${msg.end}, Taken Time: ${msg.takenTime.toFixed(2)}ms`);
});
worker.on('exit', () => {
completedWorkers++;
if (completedWorkers === numCPUs) {
console.timeEnd('Cluster Execution Time');
console.log(`[Primary] Total Computation: ${totalSum}`);
}
});
}
} else {
// Worker에서 계산 수행
const start = parseInt(process.env.START, 10);
const end = parseInt(process.env.END, 10);
const startTime = Date.now();
let partialSum = 0;
for (let i = start; i <= end; i++) {
partialSum += i;
}
const endTime = Date.now();
const takenTime = endTime - startTime;
process.send({ pid: process.pid, start, end, partialSum, takenTime }); // Primary에 결과 전달
process.exit();
}
실제 테스트 결과
테스트 환경:
- 작업 크기: 1 to 10^7및 1 to 10^9
- 비교 항목: 싱글스레드와 Cluster API 방식의 성능 비교
결과: 작은 작업 크기 (10^7)
- 싱글스레드:
23ms
로 작업을 완료.- 작업 크기가 작아 오버헤드가 적고 효율적임.
- Cluster API:
161ms
로 더 느림.- 프로세스를 생성하고 통신하는 비용이 크기 때문.
결과: 큰 작업 크기 (10^9)
- 싱글스레드:
1.8초
가량 소요.- 이벤트 루프가 차단되어 시간이 많이 걸림.
- Cluster API:
237ms
로 훨씬 빠름.- 병렬 처리를 통해 작업을 여러 프로세스에 분산하여 수행.
테스트 결론
이렇듯 작업 크기가 작을 경우 싱글스레드가 더 효율적일 수 있습니다. 그러나 데이터가 커지거나, CPU 연산이 집중되는 작업의 경우 Cluster API를 통한 병렬 처리가 훨씬 빠르게 작업을 완료할 수 있습니다.
Tip:
작업의 성격과 크기에 따라 싱글스레드와 멀티프로세스/멀티스레드를 적절히 선택하세요. 특히:
- 작업 크기가 작거나 통신 오버헤드가 큰 경우: 싱글스레드 추천.
- 작업 크기가 크거나 CPU 자원이 충분히 활용 가능한 경우: Cluster API나 Worker Threads 추천.
3. CPU-bound 작업 최적화를 위한 Worker Threads
Worker Threads는 같은 프로세스 내에서 병렬 처리를 수행하며, 데이터를 공유 메모리로 전달하여 Cluster API보다 더 적은 오버헤드로 CPU-intensive 작업을 처리할 수 있습니다. Worker Threads는 특히 데이터 전송 비용이 큰 작업이나 같은 메모리 공간에서의 병렬 연산이 필요한 경우 효과적입니다.
Worker Threads 특징
- 동일한 프로세스 내 병렬 처리:
- Worker Threads는 동일한 프로세스 내에서 실행되어, 프로세스 간 통신보다 데이터 전달 비용이 적음.
- 메모리 공유:
- 메모리를 공유하여 작업 간 데이터 전송 효율성이 높음.
- Node.js 기본 제공 라이브러리:
- 추가 의존성 없이 간단하게 병렬 작업 설정 가능.
테스트 코드 예제 (Worker Threads)
아래 코드는 Worker Threads를 사용하여 병렬 작업을 처리하는 간단한 예제입니다.
메인 코드 (Main Thread)
import { Worker } from 'worker_threads';
const TEST_NUM = 1e9;
console.log(`Performance comparison... Test Number is Sum of from 1 ${TEST_NUM.toExponential()}\n`);
// 싱글스레드 방식 계산
const singleThreadCompute = () => {
console.time('Single Thread Execution Time');
let sum = 0;
for (let i = 1; i <= TEST_NUM; i++) {
sum += i;
}
console.timeEnd('Single Thread Execution Time');
console.log(`[Single Thread] Total Computation: ${sum}`);
};
// Worker Threads 방식 계산
const runWorker = () => {
console.time('Worker Threads Execution Time');
const worker = new Worker('./worker-task.js', { workerData: { start: 1, end: TEST_NUM } });
worker.on('message', (msg) => {
console.log(`[Main Thread] Result from worker: ${msg.partialSum}`);
console.timeEnd('Worker Threads Execution Time');
});
worker.on('error', (err) => {
console.error('[Main Thread] Worker Error:', err);
});
worker.on('exit', (code) => {
if (code !== 0) {
console.error(`[Main Thread] Worker exited with code ${code}`);
}
});
};
// 실행
console.log('Running Single Thread Computation...');
singleThreadCompute();
console.log('\nRunning Worker Threads Computation...');
runWorker();
워커 코드 (Worker Thread) worker-task.js
import { parentPort, workerData } from 'worker_threads';
const { start, end } = workerData;
let partialSum = 0;
for (let i = start; i <= end; i++) {
partialSum += i;
}
parentPort.postMessage({ partialSum });
테스트 환경:
- 작업 크기: 1 to 10^7 , 1 to 10^9
- 비교 항목: 싱글스레드와 Worker Threads 방식의 성능 비교
작은 작업 크기 (10^7)
- 싱글스레드:
- 25ms로 작업을 완료.
- 단일 스레드로 작업을 처리하며, 오버헤드가 없음.
- Worker Threads:
- 45ms로 작업을 완료.
- 스레드 생성 및 데이터 전달 오버헤드로 인해 더 느림.
큰 작업 크기 (10^9)
- 싱글스레드:
- 1.8초 소요.
- CPU 연산 집중 작업으로 이벤트 루프가 차단됨.
- Worker Threads:
- 532ms 소요.
- 병렬 처리를 통해 작업이 분산되며 성능 향상.
결과 해석
-
작업 크기가 작을 경우:
- Worker Threads는 스레드 생성과 데이터 전달 비용이 추가되어 싱글스레드보다 느립니다.
-
작업 크기가 클 경우:
- Worker Threads는 작업을 병렬로 나누어 처리하므로 작업 시간이 크게 단축됩니다.
- 동일 프로세스 내에서 실행되기 때문에 Cluster API보다 효율적일 수 있습니다.
종합 평가
I/O-bound vs CPU-bound 비교
작업 방식 | 적합한 처리 방식 | 설명 |
---|---|---|
I/O-bound | Async | 작업 자체는 간단하며 대기 시간이 많음. |
CPU-bound | Cluster, Worker Threads | 작업이 복잡하고 연산 집중적임. |
CPU-bound에서 Cluster API vs Worker Threads 비교
특징 | Cluster API | Worker Threads |
---|---|---|
메모리 사용 | 프로세스마다 별도 메모리 사용 | 메모리 공유로 효율적 |
데이터 전달 | IPC 통신 필요 | 프로세스 내부 메모리 공유 |
초기화 비용 | 프로세스 생성으로 비용 큼 | 스레드 생성으로 비용 적음 |
복잡도 | 상대적으로 간단 | 동기화 관리로 복잡 |
적합한 작업 | 독립적이고 충돌 없는 작업 | 데이터 공유가 필요한 작업 |
결론
Node.js의 성능 향상을 위해 작업의 성격에 따라 적합한 병렬 처리 방식을 선택하는 것이 중요합니다.
1. I/O-bound 작업
- Async 방식을 활용하여 대기 시간이 많은 작업(파일 입출력, 네트워크 요청 등)을 블로킹 없이 처리.
- 이벤트 루프를 최대한 활용하여 병렬적으로 작업 요청.
2. CPU-bound 작업
작업의 특성과 크기에 따라 다음을 선택:
- Cluster API:
- 대규모 독립적 작업에 적합.
- 작업 간 메모리 공유가 불필요하며 프로세스 충돌 가능성이 낮음.
- 다중 코어를 효과적으로 활용할 수 있음.
- Worker Threads:
- 데이터 공유 및 협력 작업이 필요한 경우 적합.
- IPC 비용이 없고, 같은 프로세스 내에서 스레드를 관리하여 더 낮은 오버헤드로 병렬 처리.
3. 하이브리드 접근법
Cluster API와 Worker Threads를 혼합하여 작업의 성격에 따라 적절히 병렬화를 적용하면, 성능과 자원 효율성을 동시에 얻을 수 있습니다.
참조
- Cluster API와 Worker Threads에 대해 자세히 설명 https://medium.com/@erickzanetti/optimizing-node-js-application-performance-with-cluster-and-worker-threads-59258400ca6f