노드JS에서 멀티스레딩 완벽 정복! 성능 향상의 비밀 풀기

노드JS에서 멀티스레딩 완벽 정복! 성능 향상의 비밀 풀기

노드JS(Node.js)는 기본적으로 싱글 스레드(Single-Threaded)로 동작합니다.

 

즉, 논블로킹 I/O 작업을 처리하는 데는 매우 효율적이지만, CPU 집약적인 작업(CPU-intensive tasks)을 수행할 때는 성능 병목 현상이 발생할 수 있습니다.

 

이를 해결하기 위해 노드JS는 여러 스레드를 활용할 수 있는 다양한 방법을 제공합니다.

 

이번 글에서는 왜 멀티스레딩이 필요하고, 이를 구현하는 3가지 주요 방법(Child Process, Worker Threads, Cluster)을 자세히 살펴보겠습니다.

1. 왜 노드JS에서 서브 스레드가 필요한가요?

노드JS는 이벤트 루프(Event Loop) 기반의 싱글 스레드 모델로 설계됐습니다.

 

덕분에 파일 읽기, 네트워크 요청과 같은 논블로킹 I/O 작업은 매우 효율적으로 처리됩니다.

 

하지만 대규모 계산 작업과 같은 CPU 집약적인 작업은 이벤트 루프를 차단해 전체 애플리케이션 성능을 저하시킵니다.

 

서브 스레드를 활성화하면 다음과 같은 문제를 해결할 수 있는데요.

  • 논블로킹 유지: 메인 스레드에서 외부 명령어를 직접 실행하면 이벤트 루프가 차단될 수 있습니다. 서브 스레드에서 이를 처리하면 메인 스레드는 여전히 비동기 작업을 처리할 수 있습니다.
  • 멀티코어 CPU 활용: child_processworker_threads를 사용하면 여러 CPU 코어에서 병렬 작업을 수행할 수 있습니다. 특히 무거운 계산 작업에 유리합니다.
  • 격리 및 보안 강화: 서브 스레드에서 작업을 분리하면, 해당 작업에서 오류가 발생하더라도 메인 프로세스가 영향을 받지 않아 애플리케이션 안정성이 높아집니다.
  • 유연한 데이터 처리: IPC(Inter-Process Communication)를 통해 작업 결과를 메인 프로세스로 안전하게 전달할 수 있습니다.

간단히 말해, 싱글 스레드의 한계를 극복하고 CPU 자원을 최대한 활용하기 위해 멀티스레딩이 필요합니다.

2. 자식 프로세스(Child Process)로 멀티스레딩 구현하기

child_process 모듈은 외부 명령어 실행이나 독립적인 Node.js 모듈 실행을 위해 사용됩니다.

 

주요 메서드로는 spawn(), exec(), fork()가 있는데요, 각각의 특징을 알아보겠습니다.

spawn(): 스트림 방식으로 데이터 처리

spawn()장시간 실행되는 명령어대용량 출력을 처리할 때 적합합니다.

 

데이터를 스트림으로 처리하기 때문에 메모리 부담이 적습니다.

 

기본 문법:

const { spawn } = require('child_process');
const child = spawn(command, [args], [options]);
  • command: 실행할 명령어 (예: touch, ls)
  • args: 명령어 인자 배열 (예: ['moment.txt'])
  • options: 작업 디렉토리(cwd), 환경 변수(env) 등을 설정 가능

예제:

const { spawn } = require('child_process');
const path = require('path');

const touch = spawn('touch', ['moment.txt'], {
  cwd: path.join(process.cwd(), './m'), // 현재 디렉토리 아래 m 폴더에서 실행
});

touch.on('close', (code) => {
  if (code === 0) {
    console.log('파일 생성 성공!');
  } else {
    console.error(`에러 발생, 종료 코드: ${code}`);
  }
});

 

위 코드는 ./m 디렉토리에 moment.txt 파일을 비동기로 생성합니다.

exec(): 결과값을 메모리에 버퍼링

exec()출력 데이터가 작은 경우 유용합니다.

 

모든 결과를 메모리에 저장하기 때문에 대용량 데이터에는 적합하지 않습니다.

 

기본 문법:

const { exec } = require('child_process');
exec(command, [options], callback);
  • callback: (error, stdout, stderr)를 반환

예제:

const { exec } = require('child_process');
const path = require('path');

const command = `touch ${path.join('./m', 'moment.txt')}`;
exec(command, { cwd: process.cwd() }, (error, stdout, stderr) => {
  if (error) {
    console.error(`명령어 실행 실패: ${error}`);
    return;
  }
  if (stderr) {
    console.error(`에러 출력: ${stderr}`);
    return;
  }
  console.log('파일 생성 완료!');
});

 

spawn()과 달리 exec()간단한 작업에 적합합니다.

fork(): 독립적인 Node.js 프로세스 생성

fork()child_process 중에서도 오직 Node.js 전용입니다.

 

별도의 V8 인스턴스를 실행하며 IPC(프로세스 간 통신)를 지원합니다.

 

기본 문법:

const { fork } = require('child_process');
const child = fork(modulePath, [args], [options]);

 

예제 (상위 프로세스: index.js):

const { fork } = require('child_process');
const child = fork('./child.js');

child.on('message', (msg) => {
  console.log('자식 프로세스로부터 메시지:', msg);
});

child.send({ hello: 'world' });

 

자식 프로세스 (child.js):

process.on('message', (msg) => {
  console.log('부모 프로세스로부터 메시지:', msg);
  process.send({ foo: 'bar' });
});

 

이렇게 fork()를 사용하면 별도의 Node.js 인스턴스가 실행되며, 메시지 이벤트로 자유롭게 데이터를 주고받을 수 있습니다.

3. Worker Threads로 효율적인 병렬 처리

worker_threads하나의 프로세스 내에서 여러 스레드를 병렬로 실행할 수 있게 해줍니다.

 

별도의 프로세스를 만들지 않기 때문에 메모리 공유가 가능하고 속도도 빠릅니다.

 

핵심 개념:

  • Worker: 독립적인 V8 인스턴스에서 실행되는 자바스크립트 스레드
  • Main Thread: 최초의 Node.js 이벤트 루프가 동작하는 메인 스레드
  • 통신 방식: postMessage()parentPort로 데이터를 주고받음

예제:

const { Worker, isMainThread, parentPort } = require('worker_threads');

if (isMainThread) {
  const worker = new Worker(__filename);
  worker.on('message', (msg) => console.log('워커로부터:', msg));
  worker.postMessage('안녕, 워커!');
} else {
  parentPort.on('message', (msg) => {
    console.log('메인 스레드로부터:', msg);
    parentPort.postMessage('반가워, 메인!');
  });
}

 

이 코드는 같은 파일 내에서 메인 스레드와 워커 스레드를 구분해 실행합니다.

 

worker_threads vs fork():

  • worker_threads: 가벼운 병렬 처리, 메모리 공유 가능 (CPU 집약적 작업에 적합)
  • fork(): 완전한 독립 프로세스 생성, 메모리 중복 발생 (격리된 작업에 적합)

4. Cluster 모듈로 멀티코어 서버 활용하기

cluster 모듈은 여러 CPU 코어에서 Node.js 서버를 동시에 실행할 수 있게 해줍니다.

 

특히 HTTP 서버 확장에 유용합니다.

 

작동 원리:

  1. 마스터 프로세스가 여러 워커 프로세스를 생성
  2. 각 워커는 동일한 서버 코드를 실행 (포트 공유 가능)
  3. 마스터는 요청을 워커들에게 분배

예제:

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
  console.log(`마스터 프로세스 ${process.pid} 실행 중`);

  for (let i = 0; i < numCPUs; i++) {
    cluster.fork(); // 워커 생성
  }

  cluster.on('exit', (worker) => {
    console.log(`워커 ${worker.process.pid} 종료됨`);
  });
} else {
  http.createServer((req, res) => {
    res.end('Hello World\n');
  }).listen(8000);
  console.log(`워커 ${process.pid}가 서버 실행 중`);
}

 

이렇게 하면 여러 코어에서 병렬로 요청을 처리하므로 서버 성능이 비약적으로 향상됩니다.

5. 정리: 무엇을 선택할까?

  • CPU 집약적 계산: worker_threads
  • 외부 명령 실행: child_process.spawn() 또는 exec()
  • 독립적인 Node.js 작업: fork()
  • 서버 확장 및 부하 분산: cluster

노드JS는 싱글 스레드지만, 멀티스레딩을 적절히 활용하면 성능 한계를 뛰어넘을 수 있습니다.