Node.js에서 다중 스레딩
Min-jun Kim
Dev Intern · Leapcell

Node.js에서는 단일 스레드 특성으로 인해 메인 스레드가 비 차단 I/O 작업을 실행하는 데 사용됩니다. 그러나 CPU 집약적인 작업을 실행할 때 단일 스레드에만 의존하면 성능 병목 현상이 발생할 수 있습니다. 다행히 Node.js는 스레드를 활성화하고 관리하는 여러 가지 방법을 제공하여 애플리케이션이 다중 코어 CPU를 활용할 수 있도록 합니다.
서브스레드를 활성화하는 이유?
Node.js에서 서브스레드를 활성화하는 주된 이유는 동시 작업을 처리하고 애플리케이션 성능을 개선하기 위함입니다. Node.js는 본질적으로 이벤트 루프, 단일 스레드 모델을 기반으로 하며, 이는 모든 I/O 작업(예: 파일 읽기/쓰기 및 네트워크 요청)이 비 차단임을 의미합니다. 그러나 CPU 집약적인 작업(예: 대규모 계산)은 이벤트 루프를 차단하여 애플리케이션의 전반적인 성능에 영향을 줄 수 있습니다.
서브스레드를 활성화하면 다음과 같은 문제를 해결하는 데 도움이 됩니다.
- 비 차단 작업: Node.js의 설계 철학은 비 차단 I/O를 중심으로 이루어집니다. 그러나 외부 명령이 메인 스레드에서 직접 실행되면 실행 프로세스가 메인 스레드를 차단하여 애플리케이션의 응답성에 영향을 줄 수 있습니다. 이러한 명령을 서브스레드에서 실행함으로써 메인 스레드는 비 차단 특성을 유지하여 다른 동시 작업에 영향을 주지 않도록 합니다.
- 시스템 리소스의 효율적인 사용: 자식 프로세스 또는 작업자 스레드를 사용함으로써 Node.js 애플리케이션은 다중 코어 CPU의 컴퓨팅 성능을 더 잘 활용할 수 있습니다. 이는 CPU 집약적인 외부 명령을 실행하는 데 특히 유용하며, Node.js의 메인 이벤트 루프에 영향을 주지 않고 별도의 CPU 코어에서 실행할 수 있습니다.
- 격리 및 보안: 서브스레드에서 외부 명령을 실행하면 애플리케이션에 추가적인 보안 계층이 더해집니다. 외부 명령이 실패하거나 충돌하는 경우 이 격리는 메인 Node.js 프로세스가 영향을 받지 않도록 보호하여 애플리케이션 안정성을 향상시키는 데 도움이 됩니다.
- 유연한 데이터 처리 및 통신: 서브스레드를 사용하면 외부 명령 출력을 메인 프로세스로 다시 전달하기 전에 유연하게 처리할 수 있습니다. Node.js는 프로세스 간 통신(IPC)을 구현하는 여러 가지 방법을 제공하여 데이터 교환을 원활하게 합니다.
서브스레드를 활성화하는 방법
다음으로 Node.js에서 서브스레드를 활성화하는 다양한 방법을 살펴보겠습니다.
자식 프로세스
Node.js의 child_process 모듈을 사용하면 메인 프로세스와 통신할 수 있는 자식 프로세스를 생성하여 시스템 명령 또는 다른 프로그램을 실행할 수 있습니다. 이는 CPU 집약적인 작업을 실행하거나 다른 애플리케이션을 실행하는 데 유용합니다.
spawn()
child_process 모듈의 spawn() 메서드는 지정된 명령을 실행하는 새 자식 프로세스를 만드는 데 사용됩니다. stdout 및 stderr 스트림이 있는 객체를 반환하여 자식 프로세스와 상호 작용할 수 있습니다. 이 메서드는 데이터를 한 번에 모두 버퍼링하는 대신 스트림으로 처리하므로 대량의 출력을 생성하는 장기 실행 프로세스에 적합합니다.
spawn() 함수의 기본 구문은 다음과 같습니다.
const { spawn } = require('child_process'); const child = spawn(command, [args], [options]);
command: 실행할 명령을 나타내는 문자열입니다.args: 모든 명령줄 인수를 나열하는 문자열 배열입니다.options: 자식 프로세스가 생성되는 방식을 구성하는 선택적 객체입니다. 일반적인 옵션은 다음과 같습니다.cwd: 자식 프로세스의 작업 디렉토리입니다.env: 환경 변수를 포함하는 객체입니다.stdio: 파이프 작업 또는 파일 리디렉션에 자주 사용되는 자식 프로세스의 표준 입력/출력을 구성합니다.shell:true인 경우 셸에서 명령을 실행합니다. 기본 셸은 Unix에서는/bin/sh, Windows에서는cmd.exe입니다.detached:true인 경우 자식 프로세스는 부모 프로세스와 독립적으로 실행되며 부모가 종료된 후에도 계속 실행될 수 있습니다.
spawn()을 사용하는 간단한 예는 다음과 같습니다.
const { spawn } = require('child_process'); const path = require('path'); // 'touch' 명령을 사용하여 'moment.txt'라는 파일 생성 const touch = spawn('touch', ['moment.txt'], { cwd: path.join(process.cwd(), './m'), }); touch.on('close', (code) => { if (code === 0) { console.log('파일이 성공적으로 생성되었습니다.'); } else { console.error(`파일 생성 오류, 종료 코드: ${code}`); } });
이 코드의 목적은 현재 작업 디렉토리의 m 하위 디렉토리에 moment.txt라는 빈 파일을 생성하는 것입니다. 성공하면 성공 메시지가 출력되고, 그렇지 않으면 오류 메시지가 표시됩니다.
exec()
child_process 모듈의 exec() 메서드는 지정된 명령을 실행하는 새 자식 프로세스를 생성하는 데 사용되며, 생성된 모든 출력을 버퍼링합니다. spawn()과 달리 exec()는 출력이 작은 시나리오에 더 적합합니다. 자식 프로세스의 stdout 및 stderr를 메모리에 저장하기 때문입니다.
exec()의 기본 구문은 다음과 같습니다.
const { exec } = require('child_process'); exec(command, [options], callback);
command: 실행할 명령으로, 문자열입니다.options: 실행 환경을 사용자 정의하는 선택적 매개변수입니다.callback:(error, stdout, stderr)를 인수로 받는 콜백 함수입니다.
options 객체에는 다음이 포함될 수 있습니다.
cwd: 자식 프로세스의 작업 디렉토리를 설정합니다.env: 환경 변수 객체를 지정합니다.encoding: 문자 인코딩입니다.shell: 실행에 사용되는 셸을 지정합니다(Unix에서는/bin/sh, Windows에서는cmd.exe).timeout: 시간 초과를 밀리초 단위로 설정합니다. 실행 시간이 이 시간을 초과하면 자식 프로세스가 종료됩니다.maxBuffer:stdout및stderr의 최대 버퍼 크기를 설정합니다(기본값:1024 * 1024또는 1MB).killSignal: 프로세스를 종료하는 데 사용되는 신호를 정의합니다(기본값:'SIGTERM').
콜백 함수는 다음을 받습니다.
error: 명령 실행이 실패하거나 0이 아닌 종료 코드를 반환하는 경우Error객체입니다. 그렇지 않으면null입니다.stdout: 명령의 표준 출력입니다.stderr: 표준 오류 출력입니다.
exec()를 사용하는 예는 다음과 같습니다.
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('파일이 성공적으로 생성되었습니다.'); });
이 코드를 실행하면 파일이 생성되고 적절한 출력이 표시됩니다.
fork()
child_process 모듈의 fork() 메서드는 프로세스 간 통신(IPC) 채널을 통해 부모 프로세스와 통신하는 새 Node.js 프로세스를 만드는 특수한 방법입니다. fork()는 Node.js 모듈을 별도로 실행할 때 특히 유용하며 다중 코어 CPU에서 병렬 실행하는 데 유용합니다.
fork()의 기본 구문은 다음과 같습니다.
const { fork } = require('child_process'); const child = fork(modulePath, [args], [options]);
modulePath: 자식 프로세스에서 실행할 모듈의 경로를 나타내는 문자열입니다.args: 모듈에 전달할 인수를 포함하는 문자열 배열입니다.options: 자식 프로세스를 구성하는 선택적 객체입니다.
options 객체에는 다음이 포함될 수 있습니다.
cwd: 자식 프로세스의 작업 디렉토리입니다.env: 환경 변수를 포함하는 객체입니다.execPath: 자식 프로세스를 만드는 데 사용되는 Node.js 실행 파일의 경로입니다.execArgv: Node.js 실행 파일에 전달되지만 모듈 자체에는 전달되지 않는 인수 목록입니다.silent:true인 경우 자식의stdin,stdout및stderr를 부모 프로세스로 리디렉션합니다. 그렇지 않으면 부모에서 상속합니다.stdio: 표준 입력/출력 스트림을 구성합니다.ipc: 부모 및 자식 프로세스 간의 통신을 위한 IPC 채널을 만듭니다.
fork()를 사용하여 생성된 자식 프로세스는 자동으로 IPC 채널을 설정하여 부모 및 자식 프로세스 간의 메시지 전달을 허용합니다. 부모는 child.send(message)를 사용하여 메시지를 보낼 수 있고, 자식 프로세스는 process.on('message', callback)을 사용하여 이러한 메시지를 수신할 수 있습니다. 마찬가지로 자식 프로세스는 process.send(message)를 사용하여 부모에게 메시지를 보낼 수 있습니다.
다음은 fork()를 사용하여 자식 프로세스를 만들고 IPC를 통해 통신하는 방법을 보여주는 예입니다.
index.js (부모 프로세스)
const { fork } = require('child_process'); const child = fork('./child.js'); child.on('message', (message) => { console.log('자식 프로세스의 메시지:', message); }); child.send({ hello: 'world' }); setInterval(() => { child.send({ hello: 'world' }); }, 1000);
child.js (자식 프로세스)
process.on('message', (message) => { console.log('부모 프로세스의 메시지:', message); }); process.send({ foo: 'bar' }); setInterval(() => { process.send({ hello: 'world' }); }, 1000);
이 예에서 부모 프로세스(index.js)는 child.js를 실행하는 자식 프로세스를 만듭니다. 부모 프로세스는 자식에게 메시지를 보내고, 자식은 메시지를 수신하여 기록한 다음 응답을 다시 보냅니다. 부모는 자식으로부터 받은 메시지도 기록합니다. 타이머는 주기적인 메시지 교환을 보장합니다.
fork()를 사용하면 각 자식 프로세스가 자체 V8 엔진과 이벤트 루프를 사용하여 별도의 Node.js 인스턴스로 실행됩니다. 즉, 너무 많은 자식 프로세스를 만들면 리소스 소비가 높아질 수 있습니다.
작업자 스레드
Node.js의 worker_threads 모듈은 단일 프로세스 내에서 여러 JavaScript 작업을 병렬로 실행하는 메커니즘을 제공합니다. 이를 통해 애플리케이션은 여러 프로세스를 생성하지 않고도 특히 CPU 집약적인 작업에 대해 다중 코어 CPU 리소스를 완전히 활용할 수 있습니다. worker_threads를 사용하면 성능이 크게 향상되고 복잡한 계산이 가능합니다.
작업자 스레드의 주요 개념:
- 작업자: JavaScript 코드를 실행하는 독립 스레드입니다. 각 작업자는 자체 V8 인스턴스, 자체 이벤트 루프 및 로컬 변수에서 실행되므로 메인 스레드 또는 다른 작업자와 독립적으로 작동할 수 있습니다.
- 메인 스레드: 작업자를 시작하는 스레드입니다. 일반적인 Node.js 애플리케이션에서 초기 JavaScript 실행 환경(이벤트 루프)은 메인 스레드에서 실행됩니다.
- 통신: 메인 스레드와 작업자는 메시지를 전달하여 통신합니다.
ArrayBuffer및 기타 전송 가능한 객체를 포함한 JavaScript 값을 보낼 수 있으므로 효율적인 데이터 전송이 가능합니다.
다음은 작업자를 만들고 메인 스레드와 작업자 간에 통신하는 방법을 보여주는 기본 예입니다.
const { Worker, isMainThread, parentPort } = require('worker_threads'); if (isMainThread) { // 메인 스레드 const worker = new Worker(__filename); worker.on('message', (message) => { console.log('작업자의 메시지:', message); }); worker.postMessage('Hello Worker!'); } else { // 작업자 스레드 parentPort.on('message', (message) => { console.log('메인 스레드의 메시지:', message); parentPort.postMessage('Hello Main Thread!'); }); }
이 예에서 index.js 파일은 메인 스레드 진입점과 작업자 스크립트 역할을 모두 합니다. isMainThread를 확인하여 스크립트는 메인 스레드에서 실행 중인지 또는 작업자로 실행 중인지 확인합니다. 메인 스레드는 동일한 스크립트를 실행하는 작업자를 만든 다음 작업자에게 메시지를 보냅니다. 작업자는 postMessage()를 통해 응답합니다.
worker_threads와 fork()의 차이점
개념:
worker_threads: 작업자 스레드를 사용하여 동일한 프로세스 내에서 JavaScript 코드를 병렬로 실행합니다.fork(): 자체 V8 인스턴스 및 이벤트 루프가 있는 별도의 Node.js 프로세스를 생성합니다.
통신:
worker_threads:MessagePort를 사용하여ArrayBuffer및MessageChannel을 포함한 JavaScript 값을 전송합니다.fork():process.send()및message이벤트를 통해 IPC(프로세스 간 통신)를 사용합니다.
메모리 사용량:
worker_threads: 메모리를 공유하여 중복 데이터 복사를 줄여 성능을 향상시킵니다.fork(): 각 포크된 프로세스에는 별도의 메모리 공간과 자체 V8 인스턴스가 있어 메모리 사용량이 더 높습니다.
최상의 사용 사례:
worker_threads: CPU 집약적인 계산 및 병렬 처리에 적합합니다.fork(): 독립 Node.js 애플리케이션 또는 격리된 서비스를 실행하는 데 적합합니다.
전반적으로 worker_threads 또는 fork()를 사용할지는 애플리케이션의 요구 사항에 따라 다릅니다. 엄격한 프로세스 격리가 필요한 경우 fork()가 더 나은 옵션일 수 있습니다. 그러나 효율적인 병렬 계산 및 데이터 처리가 필요한 경우 worker_threads가 더 나은 성능과 리소스 활용률을 제공합니다.
클러스터 (클러스터링)
Node.js의 cluster 모듈을 사용하면 동일한 서버 포트를 공유하는 자식 프로세스를 만들 수 있습니다. 이를 통해 Node.js 애플리케이션을 여러 CPU 코어에서 실행하여 성능과 처리량을 향상시킬 수 있습니다. Node.js는 단일 스레드이므로 비 차단 I/O 작업은 많은 동시 연결을 처리하는 데 적합합니다. 그러나 CPU 집약적인 작업 또는 여러 코어에 워크로드를 분산할 때 cluster 모듈을 사용하는 것이 특히 유용합니다.
cluster 모듈의 기본 작동 원리는 마스터 프로세스(

