child_process모듈의 execFile, exec, spawn, fork 이해하기
들어가기 전에
개발을 하다보면 멀티 쓰레드로 프로그래밍을 해야할 때가 있다. 노드는 기본적으로 싱글 쓰레드이다.
하지만 싱글 쓰레드가 기본인건 별개로 멀티 쓰레드를 지원하냐 안하냐는 매우 큰차이이다.
nodejs 10.5.0 버전 이후에서 부터는 worker_threads가 제공됐고, 12 LTS 부터 stable하게 이용할 수 있게 되었다.
하지만 node에서의 멀티 쓰레드 프로그래밍은 타 언어를 통한 멀티 쓰레드 프로그래밍에 비해 효율적이지 못하고 한다. (아무래도 기본적으로 싱글 쓰레드이기 때문에)
그래서 노드 프로젝트 중 멀티 쓰레딩이 필요한 경우에 다른 언어로 멀티 쓰레딩을 구현해서 노드에서 실행시키는 경우가 많다.
이때를 위한 것이 child_process 모듈이다.
아래 사이트에 child_process에 대한 설명이 잘되어 있어 이를 참고하여 기록을 남기려고 한다.
https://dzone.com/articles/understanding-execfile-spawn-exec-and-fork-in-node
Understanding execFile, spawn, exec, and fork in Node.js - DZone Web Dev
dzone.com
❓ execFile, exec, spawn, fork
노드에서 child_porcess모듈에는 외부 프로그램을 실행하기위해 4가지의 메서드가 존재한다.
- execFile
- spawn
- exec
- fork
이 메서드 4개는 모두 비동기이며 이 메서드를 실행시키면 ChildProcess class의 인스턴스인 객체들이 반환된다.
그리고 ChildProcess는 EvenEmitter API를 implement하고있다. 따라서 인스턴스가 disconnect되거나 error, close ,close, message등의 상황에 이벤트를 등록할 수 있다.
1. exec
child_process.exec(command[, options][, callback])
exec은 shell을 spawn하고 command를 실행하고 발생되는 아웃풋을 버퍼링한다.
여기서 shell은 리눅스의 경우 /bin/sh와 매핑되고 윈도우의 경우 cmd.exe와 매핑된다.
command실행이 완료되면 callback함수가 실행되고 2가지가 callback함수의 argument로 들어온다.
- command가 성공적으로 실행되었을때 buffered data
- command가 실패했을때 error (Error의 Instance)
일반적으로 POSIX 시스템 콜의 exec()은 해당 프로세스를 새로운 프로그램으로 덮어씌우지만 노드에서는 덮어씌우지 않고 command를 실행하기 위해 단순히 shell을 이용한다.
콜백함수의 parameter는 3가지로 이루어져 있다.
예제 코드
const exec = require('child_process').exec;
exec('for i in $( ls -LR ); do echo item: $i; done', (e, stdout, stderr)=> {
if (e instanceof Error) {
console.error(e);
throw e;
}
console.log('stdout ', stdout);
console.log('stderr ', stderr);
});
그럼 exec은 언제 사용해야 할까?
exec은 shell을 spawn하고 명령어를 입력하게 된다. 따라서 쉘의 기능인 pipe, redirects, backgrounding 기능이 필요할 때 사용하면 좋다.
2. execFile
execFile은 execFile이 실행한 애플리케이션이 종료되면 해당 애플리케이션의 output이 버퍼로 콜백함수로 들어가게 된다.
child_process.execFile(file[, args][, options][, callback])
위 메서드의 options에는 다양한 옵션들이 들어갈 수 있는데 이는 나중에 공식 문서에서 확인하고 필요할때 사용하길 바란다.
콜백함수의 parameter는 3가지로 이루어져 있다.
예시 코드
const execFile = require('child_process').execFile;
const child = execFile('node', ['--version'], (error, stdout, stderr) => {
if (error) {
console.error('stderr', stderr);
throw error;
}
console.log('stdout', stdout);
});
위 코드를 실행하면 최신 노드 버전이 출력되게 된다.
그럼 execFile은 언제 사용해야 할까?
execFile은 애플리케이션을 실행하고 단지 반환된 output만 필요할때 사용 된다. 예를 들어 PNG에서 JPG로 사진 포맷을 변경하는 ImageMagick같은 이미지 처리 애플리케이션을 실행할 때 사용하면 된다. 이때 우리는 그냥 이미지 처리가 성공했는지 실패했는지만 확인하면 된다.
execFile은 사용하면 안될 때가 있는데 바로 엄청난 양의 데이터를 외부 애플리케이션이 생성하여 실시간으로 우리가 그 생성된 데이터를 이용해야 될때 사용하면 안된다.
exec과의 차이는?
하위 쉘을 실행하는 대신에 지정한 파일을 직접 실행한다는 점을 제외하면 exec과 유사하다.
3. spawn
spawn은 새로운 프로세스에서 외부 프로그램을 실행하고(스폰 하고) I/O의 streaming interface를 반환한다.
child_process.spawn(command[, args][, options])
예제 코드1
const { spawn } = require('child_process');
const child = spawn('dir', ['C:\\empty'], { shell: true });
child.stdout.on('data', (data) => {
console.log(`stdout ${data}`);
});
child.on('exit', function (code, signal) {
console.log('child process exited with ' +
`code ${code} and signal ${signal}`);
});
예제 코드2
const spawn = require('child_process').spawn;
const fs = require('fs');
function resize(req, resp) {
const args = [
"-", // use stdin
"-resize", "640x", // resize width to 640
"-resize", "x360<", // resize height if it's smaller than 360
"-gravity", "center", // sets the offset to the center
"-crop", "640x360+0+0", // crop
"-" // output to stdout
];
const streamIn = fs.createReadStream('./path/to/an/image');
const proc = spawn('convert', args);
streamIn.pipe(proc.stdin);
proc.stdout.pipe(resp);
}
위 예제에서는 이미지 파일을 스트림으로 읽어와 이미지 변환 프로그램에 스트림을 통하여 보내주는 코드이다.
자식 프로세스에 이미지 스트림을 통하여 데이터를 전송한다. proc객체가 데이터를 계속 생성하는 동안 우리는 해당 데이터를 resp에 계속하여 받을 수있고 전체 데이터가 변환되기를 기다릴 필요가 없이 즉시 이미지를 볼 수 있다.
그럼 spawn은 언제 사용해야 할까?
spawn은 stream based object를 반환한다. 스트림은 큰 데이터를 다룰 때 주로 사용되는데 동영상 스트리밍이 한 예이다. 따라서 spawn은 많은 양의 데이터를 생성하는 애플리케이션을 다룰때 사용하면 좋다.
한문장으로 말하면 자식 프로세스에서 많은 양의 데이터를 부모 프로세스에 반환해야할 때 사용된다.
4. fork
child_process.fork(modulePath[, args][, options])
child_process.fork()는 새로운 Node.js 프로세스를 spawn할때 이용되는 child_process.spawn()의 스폐셜 케이스라고 한다. child_process.spawn()처럼 ChildProcess Object가 리턴되고 이 ChildProcess객체는 추가적으로 communication channel을 가지게 된다. 이 channel을 통하여 자식 프로세스와 부모 프로세스간의 메시지 교환이 가능하게 된다.
fork 메서드는 Node 프로세스간의 IPC 채널을 열어준다.
- 자식 프로세스에서 process.on('message)와 process.send('message to parent')로 데이터를 주고 받을 수 있다.
- 부모 프로세스에서 child.on('message')와 child.send('message to child')로 데이터를 주고 받을 수 있다.
각각의 프로세스는 각각의 메모리를 가지게 되고 각각의 V8 인스턴스는 시작하는데 30ms를 소모하고 10mb의 메모리를 차지한다.
예제 코드
//parent.js
const cp = require('child_process');
const n = cp.fork(`${__dirname}/sub.js`);
n.on('message', (m) => {
console.log('PARENT got message:', m);
});
n.send({ hello: 'world' });
//sub.js
process.on('message', (m) => {
console.log('CHILD got message:', m);
});
process.send({ foo: 'bar' });
그럼 fork은 언제 사용해야 할까?
노드는 기본적으로 싱글 쓰레드이다. CPU연산이 오래 걸리는 작업들을 싱글 쓰레드에서 수행하기엔 매우 부담이 된다.
그래서 CPU연산이 오래걸리는 작업을 그냥 하게 되면 서버는 더이상 요청을 못받게 되버린다.
그래서 이렇게 오래걸리는 작업들을 새로운 노드 프로세스를 생성하여 해당 프로세스에서 수행하게 하고 메인 노드 프로세스에서는 계속해서 요청을 받을 수 있는 상태를 유지할 수 있게 한다.
그럼 spawn과의 차이점은 뭘까?
노드에서 spawn은 위에서 말한 것 처럼 streaming interface를 생성하고 fork는 communication channel을 생성한다.
이 둘은 매우 유사하지만 각각 쓰이는 상황이 다르다.
- spawn은 binary/encoding된 형식으로 연속적으로 데이터를 전송하고 싶을 때 유용하다.(ex. 큰 용량의 파일)
- fork는 JSON이나 XML형식의 메시지를 보낼 때 유용하다.
즉 fork는 그 자체가 수행하는 작업이 메인 노드 프로세스와 독립적일 때 이용이 된다.
마치며
사실 4개의 개념이 모두 매우 유사하다.
하지만 각각의 상황에 맞게 잘 사용하는 것이 중요하다.
나는 현재 프로젝트에 사용자의 요청을 받아 zip파일을 생성하는 작업에 사용하려고 하는데 이는 부모 프로세스에서 자식 프로세스에 보내야 할 데이터 형식이 json데이터 뿐이고 또한 fork를 통해 생성된 노드 프로세스에서 수행하는 작업이 부모와 독립적으로 수행되는 zip파일 생성 작업이기 때문에 fork를 사용할 것 같다.