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
모듈의 기본 작동 원리는 마스터 프로세스(