한 줄: macOS launchd 자동화가 안 돌면 로그 파일, 실행 권한, 비대화형 PATH·HOME을 이 순서로 보면 원인 대부분이 갈린다.
cron보다 launchd를 쓰는 Mac 미니·개발 맥에서 새벽 잡이 “아무 일도 없이” 끝나는 경우가 많다. plist는 로드됐는데 바이너리를 못 찾고, 표준 출력은 버려지고, GUI 세션에서만 되는 환경 변수를 가정한 스크립트가 실패한다. 아래 체크리스트로 로드 상태 → 로그 → 권한 → PATH → 의존 서비스(인증·네트워크) 순으로 닫는다. Claude 쪽 401이면 Claude Code 인증 복구를 병행한다.
스크립트를 고치기 전에 “에이전트가 실제로 로드됐는지”를 본다. 사용자 LaunchAgents와 시스템 LaunchDaemons 위치가 다르다.
# 사용자 에이전트 (로그인 사용자 기준)
launchctl list | grep -i ambit || true
launchctl print gui/$(id -u)/com.example.myjob 2>&1 | head -40
# plist 문법·로드
plutil -lint ~/Library/LaunchAgents/com.example.myjob.plist
launchctl bootout gui/$(id -u)/com.example.myjob 2>/dev/null || true
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.myjob.plist
launchctl enable gui/$(id -u)/com.example.myjob
launchctl kickstart -k gui/$(id -u)/com.example.myjob
macOS 버전마다 load/unload 대신 bootstrap/bootout이 권장된다. print 출력의 state, last exit code, path를 보면 “한 번도 안 뜸”과 “떴다가 즉시 죽음”이 구분된다. cron 대비 스케줄 설계는 Claude Code cron 스케줄 글과 겹친다.
launchd는 기본적으로 잡 출력을 삼킨다. plist에 로그 경로를 안 쓰면 “실패했는지”조차 안 보인다.
<key>StandardOutPath</key>
<string>/Users/you/logs/myjob.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/you/logs/myjob.err.log</string>
<key>WorkingDirectory</key>
<string>/Users/you/project</string>
- 로그 디렉터리를 미리
mkdir -p 해 둔다. 없으면 생성이 실패하거나 출력이 유실된다.
- 스크립트 첫 줄에 타임스탬프·uid·PATH를 echo 한다. “파일이 갱신되는가”가 1차 신호다.
tail -f로 kickstart 직후 스트림을 본다.
- exit code 0이어도 비즈니스 로직 실패일 수 있다. 앱 로그(예: content-agent)를 같이 본다.
mkdir -p ~/logs
# 스크립트 상단 예시
echo "[$(date '+%Y-%m-%d %H:%M:%S')] start uid=$(id -u) PATH=$PATH" >> ~/logs/myjob.out.log
터미널에서는 되고 launchd만 안 되면 환경 차이일 확률이 높다.
| 증상 |
원인 후보 |
조치 |
| command not found |
PATH에 Homebrew 없음 |
ProgramArguments에 절대 경로 |
| permission denied |
스크립트 +x 없음, 다른 사용자 소유 |
chmod +x, 소유자 일치 |
| 설정·토큰 파일 없음 |
HOME이 다르거나 상대 경로 |
EnvironmentVariables에 HOME, 절대 경로 |
| 키체인·OAuth 실패 |
GUI 세션과 자격 증명 분리 |
같은 uid 로그인 사용자로 Agent 실행 |
| 잡이 안 뜸 (전원·잠금) |
Mac sleep, StartCalendarInterval |
전원 어댑터·caffeinate·서버용 맥 |
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/you/project/scripts/daily.sh</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>HOME</key>
<string>/Users/you</string>
<key>LANG</key>
<string>en_US.UTF-8</string>
</dict>
which node·which claude 결과를 대화형에서 확인한 뒤 그 절대 경로를 plist에 박는다. alias·direnv에 의존하면 비대화형에서 깨진다. headless 권한 플래그는 skip-permissions 자동화와 함께 점검한다.
StartCalendarInterval·StartInterval 설정이 틀리면 “등록은 됐는데 안 도는” 착시가 난다.
StartCalendarInterval의 Hour/Minute는 로컬 타임존 기준이다. 서버 UTC 습관과 혼동하지 않는다.
- 짧은 간격 + 긴 실행 시간이 겹치면 동시 실행이 생길 수 있다. 스크립트에 flock·pid 파일 락을 둔다.
- 즉시 재시작 루프가 반복되면 launchd가 잡을 잠시 보류한다. err 로그에 반복 exit를 남기고 원인을 고친다.
RunAtLoad는 로드 직후 1회 실행. 디버깅용으로 켠 뒤 운영에서는 스케줄만 남길지 결정한다.
# 단일 실행 락 예시
LOCK=/tmp/myjob.lock
exec 9>"$LOCK"
flock -n 9 || { echo "already running"; exit 0; }
# ... 본 작업 ...
리밋으로 본문이 0건인 경우와 스케줄 미실행을 헷갈리지 않는다. 인증·쿼터는 리밋과 재개, 인증 만료는 앞의 401 글을 본다.
현장용 순서다. 위에서 막히면 아래를 보지 않는다.
plutil -lint 통과 여부
launchctl print에 라벨이 보이는지, last exit status
- StandardOut/Error 파일이 kickstart 후 mtime 갱신되는지
- err 로그 첫 에러 한 줄 (not found / permission / python path)
- ProgramArguments 0번이 절대 경로인지, 스크립트
chmod +x
- EnvironmentVariables PATH·HOME·토큰 필요 변수
- WorkingDirectory에 상대 경로 파일이 실제로 있는지
- 수동:
/bin/bash /path/to/script.sh 를 같은 사용자로 실행
- 의존 CLI 인증(예: claude ping) 성공 여부
- Mac sleep·전원·네트워크(VPN) 상태
# 한 번에 훑기
plutil -lint ~/Library/LaunchAgents/com.example.myjob.plist
launchctl print gui/$(id -u)/com.example.myjob 2>&1 | head -50
ls -la ~/logs/myjob.*.log
tail -n 50 ~/logs/myjob.err.log
/bin/bash -x /Users/you/project/scripts/daily.sh
프리빌트 배포 잡을 launchd에 묶은 경우 업로드 실패와 스케줄 실패를 분리한다. 배포 쪽은 Vercel prebuilt 업로드 글을 보면 된다.
※ launchctl 서브커맨드는 macOS 메이저 버전마다 문구가 조금 다르다. man launchctl 과 print 출력을 우선한다.
자동화 plist에 시크릿을 평문으로 넣지 않는 편이 낫다. 스크립트가 .env.local을 source 하되, 파일 권한은 600, 로그에 토큰이 echo 되지 않게 한다.
- 로그 디렉터리를 홈 아래 전용 경로로 두고 백업·공유 폴더에 두지 않는다.
- 팀 공용 맥이면 LaunchDaemon(root)과 사용자 Agent를 섞지 않는다. 권한 상승 필요 시에만 Daemon.
- 실패 알림(메일·슬랙 웹훅)은 err 로그 tail 또는 exit code 훅으로 붙인다.
프로덕션 배포 공통 점검은 배포 체크리스트를 참고한다.
launchctl 동작은 macOS 버전에 따라 다를 수 있습니다. print 출력과 공식 man 페이지를 기준으로 맞추면 됩니다.