launchd cron이 command not found로 죽을 때 — PATH가 없다

launchdmacOSnvm크론트러블슈팅

launchd 잡이 돌긴 도는데, 로그 파일에는 이 한 줄뿐입니다.

$ cat ~/logs/blog-stale-watch.error.log
/bin/bash: line 1: node: command not found

터미널에서는 node가 잘 되는데 launchd에서만 못 찾습니다. 왜 그럴까요?

launchd는 로그인 셸을 거치지 않습니다.

~/.zshrc가 실행되지 않고, 거기서 nvm이 PATH에 넣어 주는 node 경로가 잡에는 없습니다. 잡이 받는 PATH는 plist에 적힌 것이 전부입니다.

고치는 방법은 plist가 래퍼 스크립트 하나만 가리키게 하고, 래퍼 안에서 설치된 node를 찾아 PATH에 붙이는 것입니다.

if ! command -v node >/dev/null 2>&1; then
  nvm_bin=$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)
  [ -n "$nvm_bin" ] && export PATH="$nvm_bin:$PATH"
fi

검색하면 나오는 “plist의 PATH에 node 경로를 박아라”는 당장은 됩니다. 그리고 그 버전에 고정돼 다음 실패를 예약합니다.

이 잡은 8일 동안 한 번도 성공한 적이 없었습니다. 왜 몰랐을까요?

로그 파일이 매번 생겼기 때문입니다.

아래는 그 셋입니다. 실패가 안 보였던 이유, 검색 해법이 틀린 이유, 그리고 래퍼가 실패를 삼키는 함정.

launchd는 왜 node를 못 찾을까?

터미널은 ~/.zshrc를 거쳐 nvm이 초기화되지만, launchd는 그 경로를 건너뛰어 node를 찾지 못한다 터미널 ~/.zshrc nvm 초기화 launchd plist PATH ✓ node ✗ node (127) 여기서 PATH에 nvm이 붙는다 이 구간을 통째로 건너뛴다 Homebrew 경로만 들어 있다

nvm은 셸 함수입니다.

~/.zshrc가 nvm.sh를 source 할 때 비로소 정의되고, 그때 활성 버전의 bin 디렉터리가 PATH 앞에 붙습니다.

launchd는 그 파일을 읽지 않으니 잡이 받는 PATH는 plist에 적힌 것이 전부입니다. Apple 쪽 답변도 launchd 잡의 환경변수는 plist의 EnvironmentVariables로 넣으라고 안내합니다.

<key>EnvironmentVariables</key>
<dict>
    <key>PATH</key>
    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>

네 경로 어디에도 node가 없습니다.

Homebrew로 node를 설치한 적이 없고, 실제 바이너리는 ~/.nvm/versions/node/v24.14.1/bin/node에 있었습니다.

같은 기계, 같은 사용자, 다른 PATH 입니다.

8일 동안 왜 몰랐을까?

제가 이 잡의 상태를 확인하던 방법은 로그 파일이 생겼는지 보는 것이었습니다. 파일이 없으면 아직 발화 전, 있으면 돌았다고 읽었죠.

틀렸습니다.

launchd는 프로세스가 즉사해도 로그 파일을 만듭니다.

launchd.plist(5)의 StandardOutPath와 StandardErrorPath는 잡의 stdout과 stderr를 그 경로로 잇는 키일 뿐입니다(맥에서는 man 5 launchd.plist). 명령이 없어서 실패해도 파일은 열리고 그 안에 에러 한 줄이 들어갈 뿐입니다.

봐야 하는 것은 종료코드입니다.

$ launchctl list com.marketriskradar.blogstalewatch
{
	"LastExitStatus" = 32512;
	...
}

32512 >> 8 = 127. 셸에서 127은 “명령을 못 찾았다”입니다.

파일 존재로 판단했으면 영원히 안 보였을 값입니다.

무인 잡의 상태는 산출물의 내용과 종료코드로 판단합니다. 존재는 실패해도 만들어집니다.

검색하면 나오는 해법은 왜 틀렸나?

이 문제를 검색하면 답은 대체로 하나로 모입니다. plist의 PATH에 node 경로를 박으라는 것입니다.

<!-- 이렇게 하지 마십시오 -->
<string>/Users/me/.nvm/versions/node/v24.14.1/bin:/opt/homebrew/bin:...</string>

당장은 됩니다.

그리고 경로에 버전 번호가 박힌 순간부터 plist는 그 버전에 고정됩니다. nvm install로 node를 올려도 옛 디렉터리는 지워지지 않으므로 잡은 옛 버전으로 계속 돕니다.

터미널은 새 버전, 잡은 옛 버전입니다.

그러다 옛 버전을 nvm uninstall로 지우는 날 같은 127이 돌아오지요.

어느 쪽이든 처음과 똑같이 안 보입니다. 버전이 어긋난 채 도는 동안은 실패조차 아니고, 지운 뒤에는 로그 파일이 또 생기고 종료코드는 또 127이고 아무도 안 알려줍니다.

저 해법은 실패를 고치지 못하고 다음 실패의 시점을 미룰 뿐입니다.

그래서 래퍼에서 찾습니다.

첫 화면의 네 줄이 그것이고, 전체 스크립트는 글 끝 부록에 있습니다.

sort -V는 버전 정렬로, v9가 v24보다 뒤로 가는 사전순 정렬을 피하려고 씁니다.

설치된 것 중 가장 높은 버전을 고르는 건 선택입니다. 잡이 쓰는 레포가 .nvmrc로 버전을 고정한다면 그 파일을 읽어 고르는 쪽이 맞고, 이 래퍼는 “설치된 최신”을 정책으로 둔 것입니다.

이렇게 하면 plist는 스크립트 경로만 가리키므로 스크립트를 고치면 다음 발화부터 적용되고, plist를 다시 설치할 필요가 없습니다.

⚠️ plist 자체를 고쳤다면 이야기가 다릅니다. launchctl kickstart -k는 plist를 다시 읽지 않으므로 bootout 후 bootstrap 해야 합니다.

래퍼가 자기 실패를 삼킨다

고치고 나서 발견한 게 하나 더 있습니다. 래퍼의 마지막 줄이 echo였습니다.

node scripts/blog/stale-watch.mjs "$@"
code=$?
echo "$(date '+%F %T') 감시 종료 (code=$code)"
# ← 여기서 끝나면 스크립트 종료코드는 echo 의 것(=0)이다

스크립트의 종료코드는 마지막 명령의 것입니다.

본체가 실패해도 래퍼는 0을 반환합니다.

래퍼는 node를 못 찾으면 2로 죽도록 만들어 두고(셸이 만드는 일반 실패 127과 구분하려고), 정작 그 값을 마지막 줄에서 지우고 있었습니다.

이 스크립트가 막으려던 실패 모드를 이 스크립트가 그대로 갖고 있었던 셈입니다.

exit $code   # 반드시 전파한다

자주 묻는 질문

EnvironmentVariables 에 nvm 경로를 넣으면 안 되나요

버전 번호가 박히는 순간 plist 가 그 버전에 고정됩니다. node 를 올려도 잡은 옛 버전으로 돌고, 옛 버전을 지우는 날 같은 127 이 돌아옵니다. 래퍼에서 찾으면 그 고정이 생기지 않아요.

zsh -lc 로 감싸면 되지 않나요

로그인 셸을 태우면 ~/.zshrc 가 돌긴 합니다. 다만 잡의 환경이 그 파일의 현재 내용에 통째로 묶이고, 대화형 셸용 설정이 무인 잡에 섞여 들어옵니다. 필요한 것이 PATH 한 줄이라면 그쪽이 더 큰 표면입니다.

잡이 실패한 걸 알림으로 받으려면요

이 글의 범위 밖입니다. 제 경우에는 감시 잡의 산출물을 다른 경로에서 확인하는 쪽을 택했고, 크래시 경보 자체는 아직 없습니다.

cron 에서도 같은 일이 생기나요

같은 부류입니다. cron 도 로그인 셸을 거치지 않고, Docker RUN 과 CI 스크립트도 그렇습니다. 「터미널에서 되는데 거기서만 안 된다」면 PATH 부터 봅니다.

참고 자료

남는 것

launchd는 ~/.zshrc를 읽지 않습니다. nvm은 셸 함수라 그 파일 없이는 존재하지 않습니다. cron과 Docker RUN, CI 스크립트도 같은 부류입니다.

plist에 버전 경로를 박는 해법은 그 버전에 고정합니다. node를 올려도 잡은 옛 버전으로 돌고, 옛 버전을 지우는 날 같은 127이 같은 방식으로 안 보이게 돌아옵니다.

무인 잡은 산출물의 존재로 판단하지 않습니다. 로그 파일은 즉사해도 생깁니다. launchctl list <label>의 LastExitStatus를 보십시오.

래퍼를 쓴다면 종료코드를 전파하십시오. 안 그러면 감시 장치가 자기 실패를 숨깁니다. 잡을 붙인 날 launchctl list를 한 번만 봤으면 8일이 아니라 1분이었습니다.


부록: 래퍼 전체

# launchd 는 로그인 셸을 거치지 않으므로 ~/.zshrc 의 nvm 초기화가 돌지 않는다.
# plist 에 버전 경로를 박지 않는 이유: 그 버전에 고정돼 node 를 올려도 옛 버전으로
# 돌고, 옛 버전을 지우는 날 같은 127 이 돌아온다.
if ! command -v node >/dev/null 2>&1; then
  nvm_bin=$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)
  [ -n "$nvm_bin" ] && export PATH="$nvm_bin:$PATH"
fi

if ! command -v node >/dev/null 2>&1; then
  echo "$(date '+%F %T') node 없음 — PATH=$PATH"
  exit 2   # 127 이 아니라 2 로 죽는다
fi

node scripts/blog/stale-watch.mjs "$@"
code=$?
echo "$(date '+%F %T') 감시 종료 (code=$code)"
exit $code
이 글에서 고친 것 1개
  • 2026-09-12 — 처음 발행본은 “nvm install 한 번으로 같은 127이 조용히 재발합니다. node를 올리면 그 디렉터리는 더 이상 활성 버전이 아니기 때문입니다”라고 썼습니다. 틀렸습니다. nvm은 여러 버전을 나란히 두고, 활성 버전이 바뀌어도 옛 바이너리의 절대 경로는 그대로 실행됩니다. 127은 설치가 아니라 삭제 때 돌아오고, 그 전까지는 버전 불일치가 조용히 이어집니다. “해법이 실패를 미룬다”는 결론은 같지만 이유가 다릅니다.

이 사이트의 수치는 주 1회 DB와 다시 대조하고, 판단이 바뀌면 지우지 않고 글 안에 덧붙입니다. 갱신은 RSS로 받을 수 있습니다.