Node.js에서 하위 명령의 종료 코드는 0인데 결과 문자열이 덜 모였다면, exit 이벤트에서 결과를 확정하는 코드부터 찾는다. 프로세스가 끝난 시점과 그 프로세스의 표준 입출력 스트림이 닫힌 시점은 다를 수 있다. 출력을 수집하는 래퍼는 스트림을 계속 읽으면서 close 이후에 종료 상태와 수집 결과를 함께 판정해야 한다.
대상은 Node.js 기반 AI 도구에서 별도 명령을 실행하는 어댑터와 일반 서버 코드다. 특정 MCP SDK의 구현에 관한 설명은 아니다. Node.js v24.22.1 공식 문서를 대조했고, 예제는 Linux·Node.js v24.22.1에서 실행했다. 확인일은 2026년 11월 6일 KST다. 아래 실험은 짧은 합성 문자열만 출력하며 외부 서비스나 실제 업무 데이터에 접근하지 않는다.
exit에서 끝난 것은 무엇인가
Node.js의 exit 문서는 이 이벤트가 발생해도 stdio 스트림이 열려 있을 수 있다고 설명한다. close 문서는 프로세스 종료와 stdio 스트림의 닫힘을 함께 다룬다. 여러 프로세스가 같은 스트림을 공유할 수 있다는 점이 두 이벤트를 구분하는 이유다.
예를 들어 도구가 실행한 명령이 다시 자식 프로세스를 만들고 stdout을 물려준다고 하자. 중간 명령은 먼저 끝나도 마지막 프로세스가 같은 출력 통로를 계속 쓸 수 있다. 이때 중간 명령의 exit에서 문자열을 반환하면 이후에 도착할 출력이 반환값에서 빠진다. 출력량이 작아도 생길 수 있으므로 버퍼 크기부터 늘릴 문제는 아니다.
여기서 말하는 child.on('exit', ...)는 ChildProcess 객체의 이벤트다. 현재 Node.js 프로그램 자체가 끝날 때의 process.on('exit', ...)와 혼동하지 않는다. 또한 close가 왔다고 성공으로 처리하지 말고 종료 코드, 종료 신호와 오류를 함께 확인한다.
두 프로세스가 같은 stdout을 쓰는 작은 재현
다음을 exit-close-demo.mjs로 저장하고 node exit-close-demo.mjs로 실행한다. 직접 실행한 branch는 head를 쓰고 끝난다. 그 아래의 leaf는 같은 stdout을 이어받아 잠시 뒤 tail을 쓴다. unref()는 이 실험에서 branch가 leaf의 종료를 기다리지 않게 하기 위한 장치이지, 운영 작업을 분리하라는 권장이 아니다.
import { spawn } from 'node:child_process';
import assert from 'node:assert/strict';
const branch = `
const { spawn } = require('node:child_process');
const leaf = spawn(process.execPath, ['-e',
"setTimeout(() => process.stdout.write('tail'), 250)"
], { stdio: ['ignore', 'inherit', 'inherit'] });
leaf.on('error', (error) => {
console.error(error.message);
process.exitCode = 1;
});
leaf.unref();
process.stdout.write('head');
`;
const child = spawn(process.execPath, ['-e', branch], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let error;
const events = [];
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
child.on('error', (value) => { error = value; });
child.on('exit', (code, signal) => {
events.push('exit');
console.error('exit', { code, signal, stdout });
});
child.on('close', (code, signal) => {
events.push('close');
assert.equal(error, undefined);
assert.equal(code, 0);
assert.equal(signal, null);
assert.equal(stderr, '');
assert.equal(stdout, 'headtail');
assert.deepEqual(events, ['exit', 'close']);
console.error('close', { code, signal, stdout });
console.error('PASS: full stdout and event order');
});
실행 결과는 다음과 같았다. 종료 코드가 0으로 관측된 순간에는 head만 있었지만, 출력 통로가 닫혔을 때는 headtail까지 모였다.
exit { code: 0, signal: null, stdout: 'head' }
close { code: 0, signal: null, stdout: 'headtail' }
PASS: full stdout and event order
250ms는 차이를 보기 위한 대기값이다. 모든 운영체제와 부하에서 exit 시점의 문자열이 반드시 head라는 보장은 없다. 따라서 위 검사는 최종 문자열과 exit·close의 순서를 확인하되, 중간 문자열을 고정된 정답으로 단정하지 않는다. 직접 실행한 프로세스의 종료 코드가 그 아래 모든 프로세스의 성공을 대표하지도 않는다. 이 예제에서는 최종 문자열 검사로 leaf의 출력까지 확인한다.
작은 결과는 계속 모으고 close에서 확정한다
아래는 고정된 합성 코드만 실행하는 수집 예제다. stdout과 stderr를 동시에 읽고, 프로세스·스트림 오류를 보관한 뒤 close에서 한 번 판정한다. 읽기 스트림의 end는 소비할 데이터가 더 없다는 신호다. 수집 함수는 두 스트림의 readableEnded도 확인해 중간에 닫힌 출력을 정상 결과와 구별한다.
collect-check.mjs로 저장하고 node collect-check.mjs로 실행한다. source에는 예제에 적힌 고정 문자열만 사용한다. 사용자 입력이나 모델 응답을 그대로 코드로 실행하는 인터페이스가 아니다.
import { spawn } from 'node:child_process';
import assert from 'node:assert/strict';
function collectFixture(source) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, ['-e', source], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let failure;
const remember = (error) => { failure ??= error; };
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
child.stdout.on('error', remember);
child.stderr.on('error', remember);
child.on('error', remember);
child.once('close', (code, signal) => {
if (failure) return reject(failure);
if (code !== 0 || signal !== null) {
return reject(new Error(`exit=${code}, signal=${signal}`));
}
if (!child.stdout.readableEnded || !child.stderr.readableEnded) {
return reject(new Error('output ended prematurely'));
}
resolve({ stdout, stderr });
});
});
}
const ok = await collectFixture(
"process.stdout.write(JSON.stringify({ ok: true, count: 2 }))"
);
assert.deepEqual(JSON.parse(ok.stdout), { ok: true, count: 2 });
await assert.rejects(
collectFixture("process.stdout.write('partial'); process.exitCode = 2"),
/exit=2/
);
const invalid = await collectFixture("process.stdout.write('not-json')");
assert.throws(() => JSON.parse(invalid.stdout), SyntaxError);
console.error('PASS: valid JSON, nonzero exit, invalid JSON');
실행 결과는 PASS: valid JSON, nonzero exit, invalid JSON이었다. 세 검사는 서로 다른 조건을 확인한다. 정상 JSON은 끝까지 모아 해석하고, 종료 코드 2의 부분 출력은 성공 반환값으로 채택하지 않으며, 종료 코드 0이라도 JSON 형식이 틀리면 파싱 단계에서 거부한다. 실제 도구에서는 파싱 이후 필수 필드와 값의 범위도 검사해야 한다.
이 함수는 출력이 작고 스스로 종료하는 합성 예제를 위한 코드다. 무제한 출력, 종료하지 않는 프로세스, 손자 프로세스의 수명 제어와 취소 정책을 구현하지 않았다. 운영용으로 확장하려면 출력 바이트 예산과 시간 제한, 오류 뒤 정리 절차를 별도로 설계한다. 단순히 spawn으로 바꾼 뒤 문자열을 계속 붙이면 메모리 사용량은 계속 늘어난다.
close로 바꿔도 남는 세 가지 문제
출력을 읽지 않는 문제. close를 기다리기만 하고 stdout이나 stderr를 소비하지 않으면 출력 통로가 막힐 수 있다. 읽기를 시작한 뒤 종료를 기다리는 순서를 유지한다. 한 채널만 결과로 쓴다고 다른 채널의 소비를 생략하지 않는다.
출력 이후의 비동기 처리. data 리스너 안에서 별도 저장 작업을 시작했다면 프로세스의 close만으로 그 저장 완료를 판정할 수 없다. 파일이나 변환 스트림으로 전달하는 구조에서는 해당 스트림 체인의 완료도 기다린다. pipeline 문서는 스트림 연결의 완료와 오류 처리를 다룬다. 프로세스 종료와 파이프라인 완료는 함께 확인할 별도 조건이다. 이 글에서는 파일 저장 파이프라인을 실행하지 않았다.
생산자가 이미 버린 출력. 하위 Node.js 코드가 출력을 쓰자마자 process.exit()를 호출했다면, 부모가 close까지 기다려도 이미 손실된 바이트를 되살릴 수 없다. process.exit 문서는 이 호출이 아직 진행 중인 stdout 쓰기를 끝내기 전에 프로세스를 종료시킬 수 있다고 경고한다. 명시적 즉시 종료가 필요한 이유를 확인하고, 일반적인 종료 코드 설정은 process.exitCode와 자연스러운 종료를 검토한다. 이는 별도의 원인 후보이며 위 실험에서 출력 손실을 재현한 것은 아니다.
운영 코드에서는 다음 순서로 좁힌다
먼저 실행 경로에서 resolve(stdout), JSON 파싱, 결과 파일 확정이 어느 이벤트에 연결됐는지 찾는다. 다음으로 합성 입력에서 exit·close의 시각, 종료 코드·신호, 수집한 바이트 수를 나란히 기록한다. 실제 로그에는 인증값이나 고객 원문 대신 필요한 상태와 크기만 남긴다. stdout·stderr가 각각 소비되고 있는지도 확인한다.
출력 확정이 빨랐다면 close 이후의 판정으로 옮기고 회귀 검사에 지연 출력, 비정상 종료, 잘못된 JSON을 넣는다. 이어서 실행파일 시작 실패와 스트림 오류, 강제 종료, 출력 상한, 하위 프로세스가 더 오래 살아 있는 조건을 별도로 시험한다. 이 글에서 실제로 실행한 범위는 위의 공유 stdout 재현과 세 가지 수집·파싱 검사다. 그 밖의 운영 장애나 Windows·macOS 동작을 검증했다고 보지는 않는다.
파이프 쓰기 자체가 실패했다면 EPIPE와 프로세스 수명 진단에서 종료 주체와 쓰기 방향을 먼저 구분한다. 그 글에서도 exit·close를 나누지만, 여기서는 정상 종료 코드가 반환된 뒤에도 출력이 더 도착하는 상황을 실제 합성 코드로 검사했다.
출력 오류에 maxBuffer가 함께 보이면 출력 수집 한도 진단을 대조한다. 이번 글은 언제 결과를 확정하는가에 초점을 둔다. close는 수집 경계를 바로잡는 단서이고, 업무 성공은 종료 상태·결과 형식·실제 산출물까지 확인한 뒤 판단해야 한다.