본문 바로가기
빌드노트 검색

빌드노트

Node exec에서 로그가 잘리고 maxBuffer 오류가 날 때

Node exec의 ERR_CHILD_PROCESS_STDIO_MAXBUFFER를 CLI의 종료 코드와 구별한다. stdout·stderr의 작은 한도와 충분한 한도, spawn 스트림 소비를 실제 비교하고 버퍼 크기를 정하거나 로그 처리 방식을 바꾸는 기준을 설명한다.

Node.js의 exec()로 빌드 CLI를 실행했는데 stdout maxBuffer length exceeded나 stderr maxBuffer length exceeded가 나온다면, 부모 코드의 콜백에서 error.code부터 확인한다. ERR_CHILD_PROCESS_STDIO_MAXBUFFER는 부모가 모으던 출력이 한도를 넘었다는 뜻이다. CLI가 작업에 실패해 반환한 종료 코드와 구별해야 한다.

exec()는 출력을 모았다가 콜백에 전달한다. Node.js 22.23.2의 exec 문서 (새 탭에서 열림)에 따르면 기본 maxBuffer는 1024 * 1024, 즉 1MiB다. 한도를 넘으면 출력이 잘리고 자식 프로세스가 종료될 수 있다. 출력 전체가 꼭 필요하고 최대 크기를 예상할 수 있으면 한도를 정해 늘린다. 계속 늘어나는 빌드 로그라면 spawn()으로 stdout과 stderr를 소비하는 쪽을 검토한다.

종료 코드 대신 버퍼 오류가 들어오는 경우

기존 부모 파일에서 exec()에 넘긴 maxBuffer와 콜백의 error.code, error.message를 함께 본다. 옵션이 없다면 기본 한도가 적용된다. 오류 코드가 위 문자열일 때는 로그 마지막 줄을 CLI의 최종 오류로 단정하지 않는다. 이미 잘린 출력일 수 있기 때문이다.

이 차이를 Windows 11 Pro, PowerShell 7.6.6, Node.js 22.23.2에서 별도로 실행했다. 실제 빌드를 돌린 결과가 아니라, 같은 자식이 정해진 양만 출력하는 합성 예제다. 기본 한도를 넘기려고 큰 로그를 만들지 않고 한도를 1KiB로 낮췄다.

직접 비교하려면 Node.js가 설치된 새 빈 폴더에 다음 두 파일을 저장한다. 먼저 emit.cjs는 지정한 출력으로 ASCII 문자 8,192바이트를 쓰고 자연 종료한다. 인자를 생략하면 stdout, stderr를 주면 stderr다.

const target = process.argv[2] === 'stderr' ? process.stderr : process.stdout;
target.write(Buffer.alloc(8192, 'x'));

다음 exec-check.cjs는 받은 바이트 수와 종료 상태를 확인한다. encoding: 'buffer'를 지정했으므로 콜백의 두 출력은 문자열이 아닌 Buffer다. 아래 명령 문자열에는 알려진 Node 실행 경로와 고정된 파일명·출력 이름만 넣는다.

const { exec } = require('node:child_process');
const target = process.argv[2] === 'stderr' ? 'stderr' : 'stdout';
const maxBuffer = Number(process.argv[3] ?? 1024);
const command = `"${process.execPath}" emit.cjs ${target}`;
const child = exec(command, { maxBuffer, encoding: 'buffer', windowsHide: true },
  (error, stdout, stderr) => {
    console.log({
      code: error?.code ?? null,
      stdoutBytes: stdout.length, stderrBytes: stderr.length,
    });
    if (error) process.exitCode = 1;
  });
child.on('close', (code, signal) => console.log({ closeCode: code, signal }));

두 파일이 있는 폴더에서 아래 명령을 실행한다. 파일 내용은 그대로 두고 출력 위치와 버퍼 크기만 바꾼다.

node exec-check.cjs stdout 1024
node exec-check.cjs stderr 1024
node exec-check.cjs stdout 16384
node exec-check.cjs stderr 16384

직접 실행한 결과는 다음과 같았다. code는 콜백의 오류 코드이고, closeCode는 별도로 받은 close 이벤트의 값이다.

  • stdout·한도 1,024: code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER', stdoutBytes: 1024, stderrBytes: 0.
  • stderr·한도 1,024: 같은 오류 코드, stdoutBytes: 0, stderrBytes: 1024.
  • stdout·한도 16,384: code: null, stdoutBytes: 8192, stderrBytes: 0.
  • stderr·한도 16,384: code: null, stdoutBytes: 0, stderrBytes: 8192.

이 예제의 부모는 콜백 오류가 있으면 종료 코드를 1로 설정하므로 앞의 두 명령은 1, 뒤의 두 명령은 0으로 끝났다. 콜백에서 오류를 출력하기만 하면 부모가 성공 상태로 끝날 수 있으므로, 실제 CI에서도 실패 전달을 빠뜨리지 않는다.

작은 한도의 두 실행에서는 closeCode: null, signal: 'SIGTERM'이 나왔고, 충분한 한도에서는 closeCode: 0, signal: null이었다. exec()가 띄운 셸의 상태이므로 이 값만으로 실제 CLI의 모든 자손까지 종료됐다고 판단할 수는 없다. 종료 타이밍에 따라 close 값이 달라지더라도 버퍼 오류와 부분 출력을 함께 확인하는 것이 핵심이다.

추가 대조에서는 자식이 stderr에 같은 8,192바이트를 쓰고 의도적으로 종료 코드 7을 반환하게 했다. 한도를 16,384로 두자 콜백의 error.code도 숫자 7, closeCode도 7이었고 출력은 전부 회수됐다. stderr에 썼다는 사실만으로 실패한 것은 아니다. 앞의 정상 종료 대조에서도 stderr는 차 있었고 콜백 오류는 없었다. 이 버전의 출력 수집 구현 (새 탭에서 열림)에서도 버퍼 초과 오류를 만드는 경로와 프로세스 종료 코드를 오류에 넣는 경로가 나뉘어 있다.

한도를 늘릴 때는 출력 크기를 기준으로 정한다

앞의 16,384는 이 예제의 8,192바이트를 담기 위한 값이다. 실제 빌드에 그대로 권장하는 크기가 아니다. 작은 결과를 반환하는 명령이고 출력 상한을 예상할 수 있다면, 해당 exec()의 옵션에서 예상 최대 바이트와 여유를 반영한다. 한도를 늘린 뒤 같은 입력에서 버퍼 오류가 사라졌는지, CLI의 종료 코드와 필요한 결과가 정상인지 다시 확인한다.

한도는 stdout과 stderr의 합계가 아니라 각각에 적용된다. 별도 대조에서 두 출력에 각각 8,192바이트를 쓰고 maxBuffer: 12288로 실행했을 때, 합계 16,384바이트를 오류 없이 받았다. 어느 한쪽도 12,288을 넘지 않았기 때문이다. 버퍼를 크게 잡으면 두 출력을 함께 보관하는 부모의 메모리 부담도 고려해야 한다.

문자 수로 한도를 계산해서도 안 된다. maxBuffer와 Unicode 설명 (새 탭에서 열림)의 단위는 바이트다. 직접 계산한 '가'.repeat(400)은 문자열 길이가 400이지만 UTF-8로는 1,200바이트였다. 위 예제는 잘림을 정확한 바이트로 비교하려고 Buffer를 받았다. 실제 코드에서 이 설정을 쓰면 문자열이 필요한 지점에서 디코딩해야 한다. 오류 때 회수된 1,024바이트는 원래 출력량이 아니라 남은 일부이므로, 그 숫자에 조금만 더해 전체 로그 크기를 추정할 수는 없다.

로그 전체를 모을 필요가 없다면 spawn으로 소비한다

로그가 길어지는데 콜백에서 전체 문자열을 사용할 필요가 없다면, 수신하는 대로 처리하는 구조가 맞는다. 다음 stream-check.cjs는 앞의 emit.cjs를 그대로 실행하되 양쪽 출력을 읽고 바이트 수만 누적한다. 출력 내용은 보관하지 않는다.

const { spawn } = require('node:child_process');
const target = process.argv[2] === 'stderr' ? 'stderr' : 'stdout';
const child = spawn(process.execPath, ['emit.cjs', target], {
  windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
});
let stdoutBytes = 0, stderrBytes = 0;
child.stdout.on('data', chunk => { stdoutBytes += chunk.length; });
child.stderr.on('data', chunk => { stderrBytes += chunk.length; });
child.on('error', error => { console.error(error); process.exitCode = 1; });
child.on('close', (code, signal) => {
  console.log({ stdoutBytes, stderrBytes, closeCode: code, signal });
  if (code !== 0 || signal !== null) process.exitCode = 1;
});

같은 폴더에서 두 출력 방향을 각각 실행해 누락 없이 소비되는지 확인한다.

node stream-check.cjs stdout
node stream-check.cjs stderr

첫 실행은 stdoutBytes: 8192, stderrBytes: 0, 두 번째는 stdoutBytes: 0, stderrBytes: 8192였고 둘 다 closeCode: 0, signal: null이었다. 비동기 spawn()에는 exec()처럼 콜백용 전체 출력을 모으는 maxBuffer 옵션을 붙이지 않는다. stdout만 읽고 stderr를 방치하지도 않는다. Node의 파이프 설명 (새 탭에서 열림)에 따르면 파이프 자체의 용량도 제한되어 있어, 소비하지 않으면 자식의 쓰기가 대기할 수 있다. 종료와 출력 스트림 닫힘을 함께 보는 시점은 close 이벤트 (새 탭에서 열림)다.

이 예제는 내용을 버리고 개수만 센다. 로그를 남겨야 하는 코드에서는 파일이나 기존 로그 수집기로 이어 보내고, 저장 대상이 느릴 때의 처리와 쓰기 실패도 다뤄야 한다. 받은 조각을 배열에 계속 쌓으면 spawn()으로 바꿔도 전체 로그를 메모리에 모으게 된다. 반대로 exec()에 data 리스너만 추가해도 내부 수집은 계속되므로 버퍼 한도가 없어지는 것은 아니다.

실행한 범위는 Windows의 짧은 합성 출력과 비동기 API다. Linux·macOS, 실제 빌드 CLI, 느린 파일 저장 대상, 손자 프로세스의 종료 여부와 대용량 메모리 사용량은 검증하지 않았다. 16KiB로 오류가 사라진 결과를 다른 빌드의 적정 한도나 성능 보장으로 확대하지 않는다.

← 전체 글 보기