Original article: Node.js Child Processes: Everything you need to know
spawn(), exec(), execFile(), fork() 사용법
업데이트: 이 글은 현재 필자의 책 "Node.js Beyond The Basics"의 일부입니다.
jscomplete.com/node-beyond-basics에서 Node에 대한 더 많은 정보와 이 글에 대한 갱신된 내용을 읽을 수 있습니다.
Node.js에서 단일 스레드(single thread), 논-블라킹(non-blocking - 타 작업 수행 허용) 성능은 단일 프로세스에서 확실히 뛰어납니다. 하지만 결국, 단일 CPU에 단일 프로세스는 어플리케이션의 늘어나는 작업량을 감당하기에는 충분하지 않습니다.
서버의 성능이 얼마나 좋은지에 상관없이 단일 스레드는 한정된 처리량만을 지원할 수 밖에 없습니다.
Node.js가 단일 스레드에서 동작하는 것이 다중 프로세스나 다중 기기의 이점을 얻을 수 없다는 것을 의미하지는 않습니다.
다중 프로세스를 사용하는 것이 Node 어플리케이션의 크기 조정을 하는데 가장 좋은 방법입니다. Node.js는 많은 Node를 가진 분산(distributed) 어플리케이션을 만들기 위해 설계되었습니다. 이름이 Node인 이유는 여기서 찾을 수 있습니다. 확장성(Scalability)은 Node.js에 이미 탑재가 되었으며 어플리케이션의 생애주기 동안은 생각할 필요가 없는 요소입니다.
이 글은 개인 Pluralsight Node.js 강의의 일부입니다. 이 웹페이지에 비슷한 내용을 비디오로 담았습니다.
이 글을 읽기 전에 Node.js 이벤트(event)와 스트림(stream)에 대한 이해가 필요합니다. 아직 두 요소에 대한 이해가 부족하시다면 이 글을 읽기 전에 다음의 두 글을 먼저 읽기를 권합니다.
스트림(Stream): 알아야 할 모든 것Node.js 스트림은 같이 사용하기 어려운 것으로 유명하지만 이해하는 게 더 어려운 걸로도 유명합니다. 하지만 좋은 소식이 있습니다...
자식 프로세스(Child Process) 모듈
자식 프로세스는 Node.js child_process
모듈을 통해 쉽게 만들어질 수 있으며 그 자식 프로세스들은 메시징 시스템(messaging system)을 통해 서로 쉽게 소통할 수 있습니다.
child_process
모듈은 자식 프로세스 안에서 모든 시스템 명령어를 실행함으로써 운영 체제 기능들을 접근하게 해줍니다.
자식 프로세스의 입력 스트림을 제어하고 이 입력에 대한 출력 스트림을 리슨(Listen)합니다(리슨은 어떠한 대상을 받아 처리할 준비를 하는 것으로 이해하시면 됩니다. - 역주). 또한, 기저 OS 명령어에 전달되는 인자(argument)들을 제어할 수 있고 그 명령어들의 출력을 이용해 우리가 원하는 무엇이든 할 수 있습니다. 예를 들면, 하나의 명령어에 대한 출력을 다른 명령어의 입력으로 연결시킬 수 있습니다(Linux에서 하는 것과 같습니다). 이 명령어들의 입/출력이 Node.js 스트림을 통해서 표현될 수 있기 때문입니다.
이 글에 사용되는 예시들은 모두 Linux가 바탕이라는 것을 알아두시길 바랍니다. 윈도우에서는 필자가 사용하는 명령어의 대체 명령어로 바꿔 주어야 합니다.
Node에서 자식 프로세스를 생성하는 방법은 다음과 같이 4개가 있습니다: spawn()
, fork()
, exec()
, execFile()
.
이 4개의 함수들 간의 차이와 언제 사용하는지에 대하여 알아보겠습니다.
Spawn으로 생성된 자식 프로세스
spawn
함수는 새 프로세스에서 한 명령어를 시작시키며 그 명령어에 어떤 인자든지 전달하기 위해 이 함수를 사용할 수 있습니다. 예를 들면, 여기 pwd
명령어를 실행할 새 프로세스를 스폰(spawn)하는 코드입니다.
const { spawn } = require('child_process');
const child = spawn('pwd');
spawn
함수는 간단히 child_process
모듈로부터 구조 분해(destructuring)하여 얻을 수 있으며 첫 번째 인자로 OS 명령어를 넣어 이 함수를 실행시킬 수 있습니다.
위 spawn
함수를 실행시켜 얻은 결과(위 child
객체)는 ChildProcess
인스턴스이며 이벤트 에미터(EventEmitter) API를 실행합니다. 이는 그 child
객체에 이벤트를 처리하는 핸들러(handler)를 등록시킬 수 있다는 것을 뜻합니다. 예를 들면, exit
이벤트 핸들러 등록하면 자식 프로세스가 종료될 때 뭔가를 할 수 있습니다.
child.on('exit', function (code, signal) {
console.log('child process exited with ' +
`code ${code} and signal ${signal}`);
});
위의 핸들러는 자식 프로세스의 종료 code
와 종료시킬 때 사용되는 signal
를 줍니다. 이 signal
변수는 보통 자식 프로세스가 종료될 때 null입니다.
ChildProcess
인스턴스에 핸들러를 등록할 수 있는 다른 이벤트로는 disconnect
, error
, close
그리고 message
가 있습니다.
disconnect
이벤트는 부모 프로세스가child.disconnect
함수를 직접 호출할 때 발생합니다(emitted).error
이벤트는 프로세스가 생성될 수 없거나 어떠한 이유로 멈출 때(killed) 발생합니다.close
이벤트는 자식 프로세스의stdio
스트림이 종료될 때 발생합니다.message
이벤트는 가장 중요한 이벤트입니다. 이 이벤트는 메시지를 보내기 위해 자식 프로세스가process.send()
함수를 사용할 때 발생합니다. 이 방법으로 부모/자식 프로세스 간에 소통을 할 수 있게 됩니다. 아래에서 이에 대한 예를 살펴보겠습니다.
모든 자식 프로세스는 세 개의 표준 stdio
스트림도 갖고 있는데 이 스트림들은 child.stdin
, child.stdout
, child.stderr
를 사용하여 접근할 수 있습니다.
그 스트림들이 종료될 때, 그것들을 사용하던 자식 프로세스가 close
이벤트를 발생시킵니다. 이 close
이벤트는 exit
이벤트와는 다른데 여러 자식 프로세스들이 같은 stdio
스트림을 공유할 지도 모르며 그 중 한 자식 프로세스가 종료(exit)된다는 것이 그 스트림들이 모두 종료(close)되는 것을 의미하지 않기 때문입니다.
모든 스트림이 이벤트 에미터(Event Emitter)이기 때문에 모든 자식 프로세스에 연결된 이 stdio
스트림들에서 다른 이벤트들을 리슨할 수 있습니다. 그렇지만 보통의 프로세스와 달리 자식 프로세스에서는 stdin
스트림이 쓰기가 가능한 스트림인 반면 stdout
/stderr
스트림들은 읽기가 가능한 스트림입니다. 이는 기본적으로 주 프로세스(main process)에서의 스트림들의 유형과 정반대입니다. 이 스트림들에 사용하는 이벤트들이 표준 이벤트들입니다. 가장 중요한 것은 읽기가 가능한 스트림에서는 명령어에 대한 출력과 명령어 실행 시 발생하는 에러가 담긴 data
이벤트를 리슨할 수 있다는 것입니다.
child.stdout.on('data', (data) => {
console.log(`child stdout:\n${data}`);
});
child.stderr.on('data', (data) => {
console.error(`child stderr:\n${data}`);
});
위의 두 핸들러는 두 로그(log)가 각각 주 프로세스 stdout
과 stderr
에 출력될 것입니다. 위에서 언급했던 spawn
함수를 실행할 때, pwd
명령어에 대한 결과가 출력되고 자식 프로세스는 에러가 일어나지 않았다는 의미인 code 0
과 함께 종료됩니다.
spawn
함수의 두번째 인자를 사용하여 spawn
함수에 의해 실행되는 명령어에 인자(리눅스 명령어에 넣을 인자 - 역주)를 넣을 수 있는데 이 때, 이 두번째 인자는 명령어에 전달되는 모든 인자의 배열입니다. 예를 들면, find
명령어를 현재 디렉토리(directory)에서 -type f
인자(파일 유형만 찾기)와 함께 실행시키려면 다음과 같이 하면 됩니다:
const child = spawn('find', ['.', '-type', 'f']);
명령어 실행 중 에러가 발생한다면, 예를 들어, 유효하지 않은 경로를 find
에 입력하면 child.stderr
, data
이벤트 핸들러가 동작이 되고 exit
이벤트 핸들러가 에러가 발생했다는 신호로 종료 code 1
을 알리게 될 것입니다. 이 에러 값들은 실제 호스트(host) OS와 에러 유형에 따라 달라집니다.
자식 프로세스 stdin
은 쓰기가 가능한 스트림입니다. 명령어에 입력을 보내기 위해 stdin
을 활용할 수 있습니다. 여느 쓰기가 가능한 스트림처럼 stdin
을 사용하는 데 가장 쉬운 방법은 pipe
함수를 사용하는 것입니다. 간단하게 읽기가 가능한 스트림을 쓰기가 가능한 스트림에 연결하면 됩니다. 주 프로세스 stdin
이 읽기가 가능한 스트림이므로 이를 자식 프로세스 stdin
스트림에 연결할 수 있습니다. 예를 들면 다음과 같습니다:
const { spawn } = require('child_process');
const child = spawn('wc');
process.stdin.pipe(child.stdin)
child.stdout.on('data', (data) => {
console.log(`child stdout:\n${data}`);
});
위 예시에서 자식 프로세스는 리눅스에서 줄, 단어와 글자들을 세는 wc
명령어를 호출합니다. 그런 다음에 주 프로세스 stdin
(읽기가 가능한 스트림)을 자식 프로세스 stdin
(쓰기 가능한 스트림)과 연결시킵니다. 이 조합의 결과는 우리가 타자를 칠 수 있는 표준 입력 모드를 얻는 것이며 Ctrl+D
를 누르면 우리가 입력한 것이 wc
명령어의 입력으로 사용될 것입니다.
또한, 리눅스 명령어에서 하는 것처럼 여러 프로세스의 표준 입/출력을 서로에게 연결시킬 수 있습니다. 예를 들면, 현재 디렉토리에 있는 모든 파일의 수를 파악하기 위해 find
명령어의 stdout
을 wc
명령어의 stdin
과 연결시킬 수 있습니다.
const { spawn } = require('child_process');
const find = spawn('find', ['.', '-type', 'f']);
const wc = spawn('wc', ['-l']);
find.stdout.pipe(wc.stdin);
wc.stdout.on('data', (data) => {
console.log(`Number of files ${data}`);
});
필자는 줄의 수만 세기 위해 wc
명령어에 -l
인자를 더했습니다. 위에 있는 코드가 실행이 되면 현재 디렉토리에 있는 모든 디렉토리 안의 파일들의 수를 출력하게 됩니다.
Shell 문법과 exec 함수
기본적으로 spawn
함수는 전달되는 명령어를 실행하기 위해 shell 을 생성하지 않습니다. 이게 shell을 생성하는 exec
함수보다 효율적인 점입니다. exec
함수는 다른 주요 차이점이 있습니다. 이 함수는 명령어에 의해 생성된 출력을 버퍼(buffer)에 저장 하고 이 전체 출력값을 콜백 함수에 보냅니다(spawn
함수는 대신에 스트림을 사용합니다).
이전에 살펴본 exec
함수에 적용된 find | wc
예시입니다.
const { exec } = require('child_process');
exec('find . -type f | wc -l', (err, stdout, stderr) => {
if (err) {
console.error(`exec error: ${err}`);
return;
}
console.log(`Number of files ${stdout}`);
});
exec
함수는 명령어 실행을 위해 shell을 사용하기 때문에 shell pipe 기능을 이용하여 shell 문법 을 바로 사용할 수 있습니다.
여기서 외부에서 제공되는 동적 입력을 실행한다면 shell 문법 사용이 보안 위험이 수반된다는 것을 알아두셔야 합니다. 사용자가 ;과 $같은 shell 문법 글자들을 사용하여 명령어 주입 공격을 쉽게 할 수 있게 됩니다.(예를 들면, command + ’; rm -rf ~’
)
exec
함수 출력을 버퍼에 저장하고 그 출력을 stdout
인자로 콜백 함수(exec
의 두 번째 인자)에 보냅니다. 이 stdout
인자가 우리가 출력하기를 원하는 명령어의 결과인 것입니다.
exec
함수는 shell 문법을 사용해야 하고 명령어로부터 예상되는 데이터의 크기가 작을 때 좋은 선택입니다.(exec
함수는 반환(return)하기 전에 전체 데이터를 버퍼에 저장한다는 것을 기억하시면 됩니다)
spwan
함수는 명령어로부터 예상되는 데이터의 크기가 클 때 더 좋은 선택이 됩니다. 이는 그 데이터가 표준 IO 객체와 함께 스트림이 될 것이기 때문입니다.
필요하다면 spwan으로 생성이 된 자식 프로세스가 그 부모 프로세스의 표준 IO 객체를 상속받게 할 수 있지만 가장 중요한 것은 spawn
함수가 shell 문법을 사용하도록 만들 수 있다는 것입니다. 다음은 spawn
함수에 적용된 같은 find | wc
명령어입니다.
const child = spawn('find . -type f | wc -l', {
stdio: 'inherit',
shell: true
});
stdio: 'inherit'
옵션(option)이 있기 때문에 코드를 실행하면 자식 프로세스는 주 프로세스의 stdin
, stdout
과 stderr
을 상속받습니다. 이것이 자식 프로세스의 데이터 이벤트 핸들러들이 주 프로세스의 process.stdout
스트림에서 작동하게 하고 이 스크립트(script)가 결과를 즉시 출력하게 만듭니다.
shell: true
옵션이 있기 때문에 exec
함수와 했던 것처럼 전달받은 명령어에서 shell 문법을 사용할 수 있었습니다. 하지만 이 코드로 spawn
함수가 주는 데이터 스트리밍의 이점 또한 살릴 수 있습니다. 일거양득이라고 할 수 있겠습니다.
shell
과 stdio
이외에도 child_process
함수에 마지막 인자로 사용할 수 있는 다른 좋은 옵션들이 있습니다. 예를 들면, 스크립트의 작업 디렉토리를 변경하기 위해 cwd
옵션을 사용할 수 있습니다. 예시로 myDownloads 폴더가 작업 디렉토리로 설정되고 shell을 사용하는 spawn
함수를 이용한 전체 파일의 수 세기 예제가 있습니다. cwd
옵션은 스크립트가 ~/Downloads
에 있는 모든 파일의 수를 셀 수 있게 해줍니다.
const child = spawn('find . -type f | wc -l', {
stdio: 'inherit',
shell: true,
cwd: '/Users/samer/Downloads'
});
사용이 가능한 다른 옵션은 새 자식 프로세스에 보이는 환경 변수를 특정하는 env
옵션이 있습니다. 이 옵션에 대한 기본값은 현재 프로세스 환경에 모든 명령어가 접근하게 해주는 process.env
입니다. 이 기본값을 덮어 씌우길 원한다면 간단하게 env
옵션으로 빈 객체를 전달하거나 유일한 환경 변수로 여기도록 새로운 값을 전달하면 됩니다.
const child = spawn('echo $ANSWER', {
stdio: 'inherit',
shell: true,
env: { ANSWER: 42 },
});
echo
명령어는 부모 프로세스 환경 변수에 접근할 수 없습니다. 예를 들면, 이 명령어는 $HOME
에 접근할 수 없지만 $ANSWER
에는 접근할 수 있는데 env
옵션을 통해 맞춤(custom) 환경 변수로 전달되었기 때문입니다.
이 단락에서 마지막으로 설명하려는 중요한 자식 프로세스 옵션은 자식 프로세스를 부모 프로세스와 독립적으로 실행시켜주는 detached
옵션입니다.
이벤트 루프가 끊임없이 분주한 timer.js
이라는 파일이 있다고 가정해봅시다.
setTimeout(() => {
// keep the event loop busy
}, 20000);
detached
옵션을 사용하여 백그라운드(background)에서 실행시킬 수 있습니다. (백그라운드 실행은 사용자의 개입없이 실행이 되는 것을 의미합니다. - 역주)
const { spawn } = require('child_process');
const child = spawn('node', ['timer.js'], {
detached: true,
stdio: 'ignore'
});
child.unref();
분리된(detached) 자식 프로세스의 정확한 동작은 OS에 따라 달라집니다. 윈도우에서는 분리된 자식 프로세스가 개별 console 창을 가지는 반면에 리눅스에서는 자식 프로세스가 새 프로세스 그룹과 세션(session)의 리더(leader)가 될 것입니다.
unref
함수가 분리된 프로세스에서 호출이 된다면 부모 프로세스는 자식 프로세스와 별개로 종료될 수 있습니다. 이는 자식 프로세스가 소요 시간이 긴 프로세스를 실행시킬 때 유용하지만 이 자식 프로세스를 백그라운드에서 계속 실행시키기 위해서는 자식 프로세스의 stdio
설정 역시 부모 프로세스와 독립적이어야 합니다.
위 예시는 부모 프로세스가 자식 프로세스가 백그라운드에서 계속 실행하는 동안 종료될 수 있도록 분리시키고 부모 stdio
파일 설명서를 무시하여 백그라운드에서 node 스크립트(timer.js)를 실행할 것입니다.
execFile 함수
만약 shell을 사용하지 않고 파일을 실행시켜야 한다면, execFile
함수를 사용하면 됩니다. 이 함수는 exec
함수와 동일하게 동작하지만 exec
함수를 조금 더 효율적으로 만들어주는 shell을 사용하지 않습니다. 윈도우에서는 .bat
이나 .cmd
같은 파일은 실행이 되지 않습니다. 이러한 파일들은 execFile
으로 실행시킬 수 없으며 exec
혹은 spawn
함수에 shell 사용 옵션을 true로 두어야 실행시킬 수 있습니다.
*Sync 함수
child_process
로부터 나온 spawn
, exec
과 execFile
함수들은 자식 프로세스가 종료될 때까지 기다리는 동기화 차단 버전들도 있습니다.
const {
spawnSync,
execSync,
execFileSync,
} = require('child_process');
이 동기화 버전들은 스크립팅(scripting) 작업이나 시작 프로세스 작업 시 유용할 수 있으나 이 경우들이 아니라면 피해야 합니다.
fork() 함수
fork
함수는 node 프로세스를 생성하기 위한 spawn
함수의 변형입니다. spawn
과 fork
함수의 가장 큰 차이는 fork
를 사용할 때 소통 통로가 자식 프로세스에 만들어지기 때문에 부모 프로세스와 복사된(forked) 프로세스 간에 메시지를 주고 받기 위해 전역(global) process
객체와 함께 복사된 프로세스에서 send
함수를 사용할 수 있다는 것입니다. 이것은 EventEmitter
모듈을 통해 구현할 수 있습니다. 다음 예시를 들어보겠습니다.
부모 파일, parent.js
:
const { fork } = require('child_process');
const forked = fork('child.js');
forked.on('message', (msg) => {
console.log('Message from child', msg);
});
forked.send({ hello: 'world' });
자식 파일, child.js
:
process.on('message', (msg) => {
console.log('Message from parent:', msg);
});
let counter = 0;
setInterval(() => {
process.send({ counter: counter++ });
}, 1000);
위의 부모 파일에서 (node
명령어와 함께 파일을 실행시키는)child.js
를 복사(fork)하고 난 다음에 message
이벤트를 리슨합니다. message
이벤트는 자식 프로세스가 process.send
를 사용할 때마다, 여기서는 매초마다 발생할 것입니다.
부모에서 자식으로 메시지를 전달하기 위해서 send
함수를 복사(forked)된 객체에서 실행시킬 있고 자식 스크립트에서 전역 process
객체에 있는 message
이벤트를 리슨할 수 있습니다.
위 parent.js
를 실행시킬 때, 복사된 자식 프로세스에 의해 출력이 될 { hello: 'world' }
객체를 우선 보내고 난 다음에 복사된 자식 프로세스가 매초마다 부모 프로세스에서 출력이 되는 카운터(counter) 증분값을 보낼 것입니다.
fork
함수에 대해 실례를 더 살펴보겠습니다.
두 개의 엔드포인트(endpoint)를 처리하는 http 서버를 가지고 있다고 가정하겠습니다. 이 중 한 엔드포인트(아래의 /compute
)는 과도한 계산이 이루어지며 완료까지 수 초가 걸립니다. 다음과 같이 for 루프를 통해서 모방해볼 수 있습니다.
const http = require('http');
const longComputation = () => {
let sum = 0;
for (let i = 0; i < 1e9; i++) {
sum += i;
};
return sum;
};
const server = http.createServer();
server.on('request', (req, res) => {
if (req.url === '/compute') {
const sum = longComputation();
return res.end(`Sum is ${sum}`);
} else {
res.end('Ok')
}
});
server.listen(3000);
이 프로그램은 큰 문제를 가지고 있습니다; /compute
엔드포인트에 요청이 오면, 긴 시간이 소요되는 for 루프 연산으로 이벤트 루프가 분주하기 때문에 서버가 다른 요청들을 처리할 수가 없게 됩니다.
장시간 연산의 성질에 영향을 받는 이 문제를 해결하는 몇가지 방법이 있지만 모든 연산에 통하는 해결책은 그 연산을 fork
를 이용해서 다른 프로세스로 이동시키는 것입니다.
우선, longComputation
함수 자체를 한 파일에 이동시키고 주 프로세스로부터 받은 메시지로 명령을 받을 때 해당 함수를 불러오게 합니다.
새롭게 생성한 compute.js
:
const longComputation = () => {
let sum = 0;
for (let i = 0; i < 1e9; i++) {
sum += i;
};
return sum;
};
process.on('message', (msg) => {
const sum = longComputation();
process.send(sum);
});
이제 주 프로세스 이벤트 루프에서 장시간 연산을 하는 대신에 compute.js
를 복사(fork)하고 서버와 복사된 프로세스 사이에 메시지를 주고 받는 메시지 인터페이스(interface)를 사용할 수 있습니다.
const http = require('http');
const { fork } = require('child_process');
const server = http.createServer();
server.on('request', (req, res) => {
if (req.url === '/compute') {
const compute = fork('compute.js');
compute.send('start');
compute.on('message', sum => {
res.end(`Sum is ${sum}`);
});
} else {
res.end('Ok')
}
});
server.listen(3000);
위의 코드로 /compute
요청이 일어날 때, 그 장시간 연산을 실행시키기 위해 메시지를 복사된(forked) 프로세스에 보냅니다. 주 프로세스의 이벤트 루프는 차단되지 않을 것입니다.
복사된 프로세스가 이 장시간 연산을 마무리하면, 해당 프로세스는 process.send
를 이용해서 부모 프로세스에 결과를 보낼 수 있게 됩니다.
부모 프로세스에서 복사된 자식 프로세스에 있는 message
이벤트를 리슨합니다. 이 이벤트를 받을 때, http로 요청을 보낸 사용자에게 보낼 sum
값을 대기시킵니다.
위의 코드는 물론, 복사할 수 있는 프로세스의 개수에 따라 제한되지만 우리가 코드를 실행하고 http를 통해 이 장시간 연산 엔드포인트에 요청을 보낼 때, 주 서버는 차단되지 않고 더 많은 요청을 받을 수 있습니다.
다음 기사의 주제인 Node의 cluster
모듈은 자식 프로세스 복사와 여느 시스템 상에서 만들 수 있는 많은 복사물들 간 요청의 부하 조절에 대한 생각에 기반을 둡니다.
그럼 이만 여기서 마무리 짓겠습니다. 읽어 주셔서 감사합니다! 다음에 또 뵙겠습니다!
React나 Node에 대하여 배우고 있나요? 제 책을 확인해보세요.
- Learn React.js by Building Games
- Node.js Beyond the Basics