launchd에 node 절대경로를 줬는데도 ENOENT가 났다 — 원인은 자식 프로세스였다 (2026)
macOS launchd로 Node 스크립트를 매일 자동 실행하도록 걸어뒀다. plist에는 이미 node의 절대경로를 넣어뒀으니 PATH 문제는 없을 거라고 생각했다. 그런데 첫 실제 자동 실행 로그를 열어보니 이게 있었다.
spawnSync node ENOENT
절대경로를 줬는데 왜 node를 못 찾는가. 답은 절대경로가 고쳐주는 게 첫 번째 hop뿐이라는 데 있었다.
1. 내 plist는 이미 절대경로였다
문제의 plist는 이렇게 생겼다. ProgramArguments 첫 줄이 절대경로다.
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/opt/node@22/bin/node</string>
<string>node_modules/tsx/dist/cli.mjs</string>
<string>scripts/distribute.ts</string>
</array>
launchd가 이 스크립트를 띄우는 것 자체는 문제없이 됐다. 로그에 스크립트 시작 메시지가 정상적으로 찍혔다. 실패한 건 그 다음이었다.
2. 진짜 실패 지점 — 스크립트가 띄우는 자식 프로세스
이 스크립트는 안에서 다른 Node 스크립트를 여러 개 실행한다. 그 코드가 이랬다.
// ❌ 문제 코드
execFileSync("node", [join(ROOT, "scripts/threads-post.mjs"), caption, "--slug", slug], {
stdio: "inherit",
});
여기서 첫 인자가 문자열 "node"다. 이 이름을 실제 파일로 바꾸는 건 PATH 탐색이다. plist에 적은 절대경로는 launchd → 부모 프로세스 구간에만 적용되고, 부모가 이름으로 자식을 부르는 순간 다시 PATH가 필요해진다.
즉 hop이 두 개다.
| hop | 무엇이 무엇을 띄우나 | node를 어떻게 찾나 | 결과 |
|---|---|---|---|
| 1 | launchd → 부모 스크립트 | plist의 절대경로 | ✅ 성공 |
| 2 | 부모 스크립트 → 자식 스크립트 | "node" 이름 + PATH 탐색 | ❌ ENOENT |
3. launchd의 PATH를 직접 확인해봤다
추측하지 말고 재보면 된다.
launchctl getenv PATH
아무것도 출력되지 않는다. launchd에 PATH 오버라이드가 설정돼 있지 않다는 뜻이고, 이 경우 에이전트는 launchd의 최소 기본값을 쓴다. 거기에는 홈브루 경로가 없다.
반면 터미널에서 같은 걸 보면 이렇다.
$ which node
/opt/homebrew/opt/node@22/bin/node
/opt/homebrew/opt/node@22/bin이 셸 PATH에 들어있어서 찾아진 것이다. 이 경로를 넣어주는 건 .zshrc 같은 로그인 셸 설정이고, launchd는 그걸 읽지 않는다.
여기서 이 버그의 가장 고약한 성질이 나온다 — 개발 환경에서는 절대 재현되지 않는다. 터미널에서 npm run 으로 수십 번 돌려도 항상 성공한다. launchd를 거쳐야만 실패한다.
4. 해결 — process.execPath
PATH를 고치는 방법도 있지만(아래 참고), 내가 택한 건 자식을 부를 때 이름 대신 절대경로를 쓰는 것이다.
// ✅ 수정 코드
execFileSync(process.execPath, [join(ROOT, "scripts/threads-post.mjs"), caption, "--slug", slug], {
stdio: "inherit",
});
process.execPath는 지금 실행 중인 node 실행 파일의 절대경로다. 부모가 어떤 경로의 node로 떠 있든 자식은 정확히 같은 node로 뜨고, PATH를 전혀 타지 않는다.
실측한 값이 흥미로웠다. which node와 값이 다르다.
which node → /opt/homebrew/opt/node@22/bin/node (심볼릭 링크)
process.execPath → /opt/homebrew/Cellar/node@22/22.23.1/bin/node (실제 경로)
process.execPath는 링크를 따라간 실제 경로를 준다. 버전이 박혀 있어서 불안해 보이지만, 어차피 “지금 돌고 있는 그 node”를 가리키므로 부모와 자식의 버전이 어긋날 일이 없다는 점에서는 오히려 안전하다.
대안: plist에 PATH를 직접 넣기
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
이것도 동작한다. 다만 node 설치 경로가 plist에 하드코딩되어, 버전을 올리거나 다른 기기로 옮길 때 같이 고쳐야 한다. process.execPath는 그 관리 부담이 없다.
5. 검증 — 실제 launchd로 돌려봐야 한다
수정했다고 끝이 아니다. 터미널 테스트는 이 버그를 재현하지 못하므로 launchd를 통해 돌려야 한다.
launchctl start com.duckpipe.distribute
tail -f data/distribute.log
이걸로 재현과 수정을 둘 다 확인했고, 다음 날 정기 실행(10:05) 로그에도 ENOENT가 없는 것까지 봤다.
6. 정작 더 무서운 건 에러가 아니었다
기술적으로 이 버그는 한 줄 수정이다. 실질적인 피해는 다른 데 있었다.
이 스크립트는 매일 10:05에 조용히 돌면서 SNS에 글을 올리는 역할이었다. ENOENT로 자식 프로세스가 전부 실패하는 동안, 부모 스크립트는 정상 종료했다. 자식 실패를 try/catch로 잡아 로그만 남기고 계속 진행하는 구조였기 때문이다. 겉으로는 “매일 잘 돌고 있는” 상태였다.
로그를 직접 열어보지 않았다면 그대로 계속 아무것도 안 하고 있었을 것이다. 자동화에서 조용한 실패가 시끄러운 실패보다 나쁘다는 게 이 경우에 정확히 맞았다.
정리
- plist의 절대경로는 첫 hop만 고친다. 스크립트가 자식을 이름으로 부르면 다시 PATH가 필요하다.
launchctl getenv PATH로 직접 확인해라. 비어 있으면 launchd 기본값이고 홈브루 경로는 없다.- 자식 프로세스는
execFileSync(process.execPath, ...)로 띄운다. - 터미널 테스트로는 재현되지 않는다.
launchctl start로 검증해라. - 자식 실패를 삼키는 구조라면 로그를 주기적으로 확인하는 절차까지 같이 만들어라.
자주 묻는 질문
plist에 EnvironmentVariables로 PATH를 넣으면 해결되나요?
됩니다. plist에 <key>EnvironmentVariables</key>로 PATH를 직접 지정하면 자식 프로세스도 그 PATH를 물려받습니다. 다만 그러면 node 설치 경로가 plist에 하드코딩되어 버전을 올릴 때 같이 고쳐야 합니다. process.execPath는 그 관리 부담이 없어서 이 글에서는 그쪽을 택했습니다.
process.execPath는 정확히 뭘 반환하나요?
현재 실행 중인 node 실행 파일의 절대경로입니다. 실측해보니 심볼릭 링크가 아니라 실제 경로가 나왔습니다 — which node는 /opt/homebrew/opt/node@22/bin/node였지만 process.execPath는 /opt/homebrew/Cellar/node@22/22.23.1/bin/node였습니다.
cron에서도 같은 문제가 생기나요?
같은 종류의 문제가 생깁니다. cron도 로그인 셸의 환경을 물려받지 않아 PATH가 최소한으로 잡힙니다. 원인과 해법이 동일하므로 이 글의 process.execPath 방식이 cron에도 그대로 적용됩니다.
왜 터미널에서 테스트할 때는 안 걸렸나요?
터미널에서는 로그인 셸이 .zshrc 등을 읽어 PATH에 홈브루 경로를 넣어주기 때문입니다. 즉 이 버그는 실행 환경에서만 재현되고 개발 환경에서는 절대 재현되지 않습니다. launchctl start로 실제 launchd를 통해 실행해봐야 드러납니다.