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

#기술스택 #Dev #Node

글 작성 이유

Node 작업의 I/O bound 작업과 CPU-bound 작업의 다른 특성, 그리고 CPU-bound 작업중에서도 Cluster APIWorker Threads의 차이에 대해 알아봅니다.

Node.js는 싱글 스레드 기반의 비동기 런타임 환경으로, I/O-bound 작업에서 높은 효율성을 제공합니다. 이는 Event Loop를 통해 비동기 작업을 처리하며, 네트워크 요청이나 파일 입출력 등 대기 시간이 많은 작업에서 성능을 극대화합니다.

하지만 CPU-bound 작업에서는 복잡한 연산으로 인해 이벤트 루프가 차단되어 병목 현상이 발생할 수 있습니다. 이를 해결하기 위해 Node.js는 두 가지 병렬 처리 방법을 제공합니다:

이 글에서는 Cluster API와 Worker Threads를 활용한 CPU-bound 작업 최적화, 그리고 Async를 통한 I/O-bound 작업 효율화를 설명하며, 각각의 사용 사례와 장단점을 소개합니다.


Node.js 성능 최적화의 세 가지 접근법

1. I/O-bound 작업 최적화를 위한 Async

I/O-bound 작업은 단순한 작업이지만, 데이터 전송 및 입출력 처리 과정에서 네트워크나 디스크 I/O 비용이 많이 발생하여 대기 시간이 큽니다. 대표적으로 다음과 같은 작업이 포함됩니다:

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();

결과


2. CPU-bound 작업 최적화를 위한 Cluster API와 Worker Threads

CPU-bound 작업은 계산 복잡도가 높은 작업으로, 주로 CPU 자원에 의존하며, 많은 연산을 요구합니다. 이 작업의 병목은 CPU 연산에 있으며, 이벤트 루프가 차단되어 다른 작업을 처리하지 못할 수 있습니다. 대표적으로 다음과 같은 작업이 포함됩니다:

Node.js는 싱글 스레드 환경에서 CPU 연산 집중 작업을 처리하기 어렵기 때문에, Cluster API와 Worker Threads를 활용해 CPU 작업을 분산 처리하여 성능을 최적화할 수 있습니다.

Cluster API 특징

  1. 독립적 프로세스 생성:
    • cluster.fork()를 통해 새로운 워커 프로세스를 생성.
    • 각 프로세스는 독립적으로 실행되어 충돌 위험이 낮음.
  2. 작업 분산 처리:
    • CPU-intensive 작업을 여러 프로세스에 분산하여 처리 속도를 향상.
  3. 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();
}

실제 테스트 결과

테스트 환경:

결과: 작은 작업 크기 (10^7)

결과: 큰 작업 크기 (10^9)

테스트 결론

이렇듯 작업 크기가 작을 경우 싱글스레드가 더 효율적일 수 있습니다. 그러나 데이터가 커지거나, CPU 연산이 집중되는 작업의 경우 Cluster API를 통한 병렬 처리가 훨씬 빠르게 작업을 완료할 수 있습니다.

Tip:

작업의 성격과 크기에 따라 싱글스레드와 멀티프로세스/멀티스레드를 적절히 선택하세요. 특히:


3. CPU-bound 작업 최적화를 위한 Worker Threads

Worker Threads는 같은 프로세스 내에서 병렬 처리를 수행하며, 데이터를 공유 메모리로 전달하여 Cluster API보다 더 적은 오버헤드로 CPU-intensive 작업을 처리할 수 있습니다. Worker Threads는 특히 데이터 전송 비용이 큰 작업이나 같은 메모리 공간에서의 병렬 연산이 필요한 경우 효과적입니다.


Worker Threads 특징

  1. 동일한 프로세스 내 병렬 처리:
    • Worker Threads는 동일한 프로세스 내에서 실행되어, 프로세스 간 통신보다 데이터 전달 비용이 적음.
  2. 메모리 공유:
    • 메모리를 공유하여 작업 간 데이터 전송 효율성이 높음.
  3. 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 });



테스트 환경:

작은 작업 크기 (10^7)

큰 작업 크기 (10^9)


결과 해석

  1. 작업 크기가 작을 경우:

    • Worker Threads는 스레드 생성과 데이터 전달 비용이 추가되어 싱글스레드보다 느립니다.
  2. 작업 크기가 클 경우:

    • 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 작업

2. CPU-bound 작업

작업의 특성과 크기에 따라 다음을 선택:

3. 하이브리드 접근법

Cluster API와 Worker Threads를 혼합하여 작업의 성격에 따라 적절히 병렬화를 적용하면, 성능과 자원 효율성을 동시에 얻을 수 있습니다.

참조