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)에 대한 이해가 필요합니다. 아직 두 요소에 대한 이해가 부족하시다면 이 글을 읽기 전에 다음의 두 글을 먼저 읽기를 권합니다.

Node.js 이벤트 구동 구조에 대한 이해대부분의 Node 객체 - HTTP 요청, 응답과 스트림(stream) 등 - 는 이벤트 에미터(EventEmitter)를 실행하는데...

스트림(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)가 각각 주 프로세스 stdoutstderr에 출력될 것입니다. 위에서 언급했던 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 명령어의 입력으로 사용될 것입니다.

Gif captured from my Pluralsight course — Advanced Node.js

또한, 리눅스 명령어에서 하는 것처럼 여러 프로세스의 표준 입/출력을 서로에게 연결시킬 수 있습니다. 예를 들면, 현재 디렉토리에 있는 모든 파일의 수를 파악하기 위해 find 명령어의 stdoutwc 명령어의 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, stdoutstderr을 상속받습니다. 이것이 자식 프로세스의 데이터 이벤트 핸들러들이 주 프로세스의 process.stdout 스트림에서 작동하게 하고 이 스크립트(script)가 결과를 즉시 출력하게 만듭니다.

shell: true 옵션이 있기 때문에 exec함수와 했던 것처럼 전달받은 명령어에서 shell 문법을 사용할 수 있었습니다. 하지만 이 코드로 spawn 함수가 주는 데이터 스트리밍의 이점 또한 살릴 수 있습니다. 일거양득이라고 할 수 있겠습니다.

shellstdio 이외에도 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)를 실행할 것입니다.

Gif captured from my Pluralsight course — Advanced Node.js

execFile 함수

만약 shell을 사용하지 않고 파일을 실행시켜야 한다면, execFile 함수를 사용하면 됩니다. 이 함수는 exec 함수와 동일하게 동작하지만 exec 함수를 조금 더 효율적으로 만들어주는 shell을 사용하지 않습니다. 윈도우에서는 .bat이나 .cmd 같은 파일은 실행이 되지 않습니다. 이러한 파일들은 execFile으로 실행시킬 수 없으며 exec 혹은 spawn 함수에 shell 사용 옵션을 true로 두어야 실행시킬 수 있습니다.

*Sync 함수

child_process로부터 나온 spawn, execexecFile 함수들은 자식 프로세스가 종료될 때까지 기다리는 동기화 차단 버전들도 있습니다.

const { 
  spawnSync, 
  execSync, 
  execFileSync,
} = require('child_process');

이 동기화 버전들은 스크립팅(scripting) 작업이나 시작 프로세스 작업 시 유용할 수 있으나 이 경우들이 아니라면 피해야 합니다.

fork() 함수

fork 함수는 node 프로세스를 생성하기 위한 spawn 함수의 변형입니다. spawnfork 함수의 가장 큰 차이는 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) 증분값을 보낼 것입니다.

Screenshot captured from my Pluralsight course — Advanced Node.js

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