윈도우에서 Node.js 스크립트로 npm이나 npx를 호출하다가 node spawn EINVAL 에러를 만나면 대부분 당황합니다. 코드를 한 줄도 고치지 않았는데, Node 버전을 올린 다음부터 갑자기 죽기 때문입니다. 저도 자동 발행 스크립트에서 이걸 만나 30분 넘게 오타를 찾았습니다. 원인은 오타가 아니라 Node 자체의 보안 패치였습니다. 이 글에서는 왜 이런 일이 생기는지, 그리고 상황별로 어떤 해결책을 골라야 하는지 정리합니다.
문제 상황: 어제까지 되던 코드
아래 코드는 윈도우에서 오랫동안 잘 돌아갔습니다. 확장자 .cmd까지 붙여 줬으니 문제가 없어 보입니다.
// build.js — 어제까지 잘 돌던 코드
const { spawn } = require('node:child_process');
const child = spawn('npm.cmd', ['run', 'build']);
child.stdout.on('data', (d) => process.stdout.write(d));
child.on('close', (code) => console.log('종료 코드:', code));
그런데 Node를 최신 버전으로 올리고 다시 돌리면 이렇게 끝납니다.
node:events:495
throw er; // Unhandled error event
^
Error: spawn EINVAL
at ChildProcess.spawn (node:internal/child_process:421:11)
at spawn (node:child_process:761:9)
errno: -4071,
code: 'EINVAL',
syscall: 'spawn'
메시지에 파일 이름도, 경로도 없습니다. 그래서 경로 오타나 PATH 설정을 의심하며 시간을 버리게 됩니다.
원인: 2024년 4월 보안 패치
윈도우에서 npm, npx, yarn, tsc 같은 명령은 실제 실행 파일이 아니라 배치 파일 껍데기(.cmd)입니다. 그리고 배치 파일은 실행될 때 인자를 cmd.exe가 한 번 더 해석합니다. 이 성질 때문에 인자에 &나 |가 섞여 들어오면 의도하지 않은 명령이 따라 실행될 수 있었습니다. Node는 이걸 명령 주입 취약점(CVE-2024-27980)으로 처리했습니다.
패치 이후 Node는 shell 옵션 없이 .bat·.cmd 파일을 실행하려 하면 아예 거부합니다. 그 거부가 EINVAL로 나타나는 것입니다. 즉 이건 고장이 아니라 의도된 동작입니다. 해당 수정은 Node 18.20.2, 20.12.2, 21.7.3 이후 버전에 들어 있고, 그 뒤의 모든 메이저 버전에도 당연히 포함돼 있습니다.
ENOENT와 EINVAL은 다른 문제다
두 에러를 섞어 보면 진단이 어긋납니다. 윈도우의 spawn은 PATHEXT를 적용하지 않기 때문에 확장자를 생략하면 파일을 못 찾고, 확장자를 정확히 붙이면 이제는 실행을 거부합니다.
| 증상 | 코드 예 | 실제 의미 | 대응 |
|---|---|---|---|
spawn ENOENT | spawn(‘npm’, …) | 확장자가 없어 파일을 찾지 못함 | .cmd를 붙이거나 shell: true |
spawn EINVAL | spawn(‘npm.cmd’, …) | 배치 파일을 shell 없이 실행하려 함 | shell: true 또는 아래 다른 방법 |
spawn UNKNOWN | 드물게 발생 | 권한·바이러스 백신 간섭 가능성 | 실행 경로를 백신 예외에 추가 |
| 에러 없이 종료 코드 1 | shell 경유 실행 | 명령은 떴고 그 안에서 실패 | stdio로 실제 출력 확인 |
참고로 exec와 execSync는 원래부터 shell을 거치므로 이 에러가 나지 않습니다. 그래서 “exec는 되는데 spawn만 안 된다”는 현상이 흔합니다.
해결 1: shell: true — 가장 빠른 방법
고칠 곳이 한 줄이라 급할 때 제일 좋습니다. 다만 뒤에 설명할 조건이 있습니다.
const { spawn } = require('node:child_process');
// shell: true 를 주면 cmd.exe 를 거쳐 실행되므로 .cmd 도 통과한다.
const child = spawn('npm.cmd', ['run', 'build'], {
shell: true,
stdio: "inherit",
});
child.on('close', (code) => {
if (code !== 0) process.exit(code);
});
stdio: "inherit"를 같이 넣은 이유는, shell을 거치면 실패 원인이 자식 프로세스 쪽 출력에만 남기 때문입니다. 출력을 부모로 흘려 두지 않으면 종료 코드 1만 보이고 이유를 알 수 없습니다.
해결 2: .cmd 껍데기를 건너뛴다
npm.cmd는 그 자체로 하는 일이 없습니다. 안에서 node npm-cli.js를 부를 뿐입니다. 그렇다면 껍데기를 빼고 안쪽을 직접 부르면 배치 파일 문제가 처음부터 생기지 않습니다.
const { spawn } = require('node:child_process');
const path = require('node:path');
// npm.cmd 는 결국 node 로 npm-cli.js 를 실행하는 껍데기다.
// 껍데기를 건너뛰고 그 안쪽을 직접 부르면 .cmd 문제 자체가 사라진다.
// 윈도우 Node 설치본은 npm 을 node.exe 옆에 함께 담고 있다.
const npmCli = path.join(
path.dirname(process.execPath),
'node_modules', 'npm', 'bin', 'npm-cli.js'
);
const child = spawn(process.execPath, [npmCli, 'run', 'build'], {
stdio: 'inherit',
});
shell을 쓰지 않으니 인자 해석 단계가 하나 사라집니다. 인자에 사용자 입력이 섞이는 자동화라면 이 방법이 가장 안전합니다. 단, 위 경로는 Node 설치본에 딸려 오는 npm을 가리킵니다. nvm-windows나 별도 설치로 npm을 따로 관리한다면 npm.cmd가 실제로 어느 npm-cli.js를 부르는지 확인하고 경로를 맞추세요. 메모장으로 npm.cmd를 열어 보면 바로 보입니다.
해결 3: cross-spawn으로 분기를 없앤다
맥·리눅스와 윈도우를 함께 지원해야 하고 process.platform 분기를 코드 곳곳에 두기 싫다면, 이 문제를 전담하는 패키지를 쓰는 게 낫습니다.
// npm i cross-spawn
const spawn = require('cross-spawn');
// 확장자 탐색과 shell 분기를 내부에서 처리한다.
// 윈도우에서도 .cmd 를 붙이지 않고 그냥 이름만 넘기면 된다.
const child = spawn('npm', ['run', 'build'], { stdio: 'inherit' });
child.on('error', (err) => {
console.error('실행 실패:', err.message);
process.exit(1);
});
많은 빌드 도구가 내부적으로 이 패키지를 쓰고 있어서, 사실 프로젝트의 node_modules에 이미 들어 있을 가능성이 높습니다. 더 많은 기능(자동 에러 처리, 출력 파싱)이 필요하면 execa도 같은 문제를 해결해 줍니다.
에러를 조용히 놓치지 않기
여기서 한 가지 함정이 있습니다. spawn의 EINVAL은 try/catch로 잡히지 않습니다. 동기 예외가 아니라 error 이벤트로 오기 때문입니다. 핸들러를 달지 않으면 프로세스 전체가 죽습니다.
const { spawn, spawnSync } = require('node:child_process');
// 1) 비동기 spawn — EINVAL 은 예외가 아니라 error 이벤트로 온다.
// 이 핸들러가 없으면 'Unhandled error event' 로 프로세스가 통째로 죽는다.
const child = spawn('npm.cmd', ['run', 'build'], { shell: true });
child.on('error', (err) => {
if (err.code === 'EINVAL') {
console.error('배치 파일을 shell 없이 실행하려 했습니다. shell: true 를 확인하세요.');
} else if (err.code === 'ENOENT') {
console.error('명령을 찾을 수 없습니다. 확장자(.cmd)와 PATH 를 확인하세요.');
} else {
console.error(err);
}
process.exitCode = 1;
});
// 2) 동기 spawnSync — 던지지 않고 결과 객체의 error 에 담아 돌려준다.
const result = spawnSync('npm.cmd', ['run', 'build'], { shell: true });
if (result.error) {
console.error('실행 실패:', result.error.code);
}
반대로 spawnSync는 예외를 던지지 않고 결과 객체의 error 속성에 담아 돌려줍니다. 그래서 반환값을 확인하지 않으면 실패가 아무 흔적도 남기지 않고 지나갑니다. 스케줄러로 무인 실행하는 스크립트라면 이쪽이 더 위험합니다.
shell: true를 쓸 때 반드시 지킬 것
shell: true는 Node가 막아 둔 문을 다시 여는 옵션입니다. 인자가 코드 안에 고정된 문자열이라면 아무 문제 없습니다. 하지만 사용자 입력, 파일명, 웹 요청 파라미터가 인자로 들어간다면 그대로 두면 안 됩니다.
const { spawn } = require('node:child_process');
// 위험: 사용자 입력이 그대로 cmd.exe 로 흘러간다.
// branchName 이 "main & del /q *" 이면 뒤쪽까지 같이 실행될 수 있다.
// spawn("git.cmd", ["checkout", branchName], { shell: true });
// 안전: 허용할 형태를 먼저 못 박는다.
function checkout(branchName) {
if (!/^[A-Za-z0-9._\/-]+$/.test(branchName)) {
throw new Error('허용되지 않는 브랜치 이름: ' + branchName);
}
return spawn('git', ['checkout', branchName], { stdio: 'inherit' });
}
- 인자가 전부 고정 문자열일 때만
shell: true를 마음 편히 씁니다. - 외부에서 온 값이 인자에 들어가면 해결 2(배치 파일 우회)를 택하거나, 위처럼 허용 패턴을 정규식으로 못 박습니다.
- 따옴표로 감싸는 것만으로는 부족합니다. 배치 파일의 인자 처리 단계에서 따옴표가 한 번 더 벗겨질 수 있습니다.
- 경로에 공백이 있으면
shell: true에서는 직접 따옴표를 붙여야 합니다. shell 없이 배열로 넘길 때는 Node가 알아서 처리합니다.
진단 순서 정리
같은 에러를 다시 만났을 때 되돌아볼 순서입니다.
node -v로 버전을 확인합니다. 18.20.2 / 20.12.2 / 21.7.3 이상이면 이 패치가 적용된 버전입니다.- 실행하려는 명령이
.cmd나.bat인지 확인합니다.where npm을 쳐 보면 됩니다. error이벤트 핸들러를 먼저 붙여err.code를 눈으로 확인합니다.EINVAL인지ENOENT인지에 따라 대응이 갈립니다.- 인자에 외부 입력이 섞이는지 봅니다. 섞인다면
shell: true대신 배치 파일 우회를 택합니다. - 고친 뒤
stdio: "inherit"로 실제 출력을 한 번은 눈으로 봅니다. 종료 코드만 믿지 않습니다.
마무리
가장 작은 첫걸음은 지금 프로젝트에서 spawn(을 검색해 보는 것입니다. 윈도우에서 .cmd를 부르는 자리에 error 이벤트 핸들러가 붙어 있는지만 확인하세요. 핸들러 하나가 붙어 있으면, 다음에 같은 문제가 생겨도 스택 트레이스 대신 사람이 읽을 수 있는 문장이 남습니다.
다음 글에서는 PC를 정해진 시각에 자동으로 깨우고 다시 절전으로 보내는 자동화를 다루겠습니다. 새벽에 돌려야 하는 작업이 있는데 컴퓨터를 켜 둔 채 잠들기 싫은 분께 도움이 될 내용입니다.