바쁜 분은 이것만
- 상태 확인은
launchctl list | grep 이름, 자세히는launchctl print gui/$(id -u)/라벨 - plist를 고쳤으면
plutil -lint→bootout→bootstrap순서로 다시 읽기 - 예약 시각까지 기다리지 않고 지금 돌리려면
launchctl kickstart -k
맥에서 정해진 시각에 파이썬 스크립트를 돌리려고 launchd를 쓰고 있어요. 처음에는 crontab을 썼는데 잠자기 중이면 그냥 건너뛰어서 launchd로 옮겼고(맥 crontab이 실행 안 될 때 (잠자기·launchd·PATH)), 지금은 하루 세 번 시세 데이터를 모으는 작업 하나가 등록돼 있습니다. 그런데 막상 쓰다 보면 "지금 올라가 있나?", "plist 고친 게 반영됐나?"를 확인할 일이 자주 생겨요. 그때 쓰는 launchctl 명령을 실제 출력과 함께 정리했습니다.
예제의 작업 이름(라벨)은 com.blogauto.lostark-snapshot이고, plist는 ~/Library/LaunchAgents/에 있어요. 본인 작업 라벨로 바꿔서 보면 됩니다.
1. launchctl list: 올라가 있는지 한 줄로 보기
launchctl list는 내 계정에 올라간 작업을 전부 보여 줘서 길어요. grep으로 내 라벨만 거릅니다.
$ launchctl list | grep com.blogauto
- 0 com.blogauto.lostark-snapshot
칸은 PID, 상태(마지막 종료 코드), 라벨 순서예요.
- PID가
-: 지금 실행 중이 아니라는 뜻이에요. 예약 실행형 작업은 평소에 이게 정상입니다. - 가운데 숫자: 마지막으로 끝났을 때의 종료 코드예요. 스크립트가
exit 1로 끝났다면 여기 1이 보여요. - 아무 줄도 안 나오면: 아예 안 올라간 상태예요. plist 위치나 bootstrap을 확인할 차례입니다.
2. launchctl print: 상태·실행 횟수·종료 코드 자세히 보기
요즘 macOS에서는 print가 훨씬 자세해요. 대상은 도메인/라벨 형식으로 적는데, 로그인한 사용자 작업은 gui/$(id -u)가 도메인입니다. $(id -u)는 내 사용자 번호(보통 501)로 바뀌어요.
$ launchctl print gui/$(id -u)/com.blogauto.lostark-snapshot
gui/501/com.blogauto.lostark-snapshot = {
active count = 0
path = ~/Library/LaunchAgents/com.blogauto.lostark-snapshot.plist
type = LaunchAgent
state = not running
program = ~/blog-auto/.venv/bin/python
arguments = {
~/blog-auto/.venv/bin/python
lostark/fetch.py
snapshot
}
working directory = ~/blog-auto
stdout path = ~/blog-auto/lostark/data/cron.log
stderr path = ~/blog-auto/lostark/data/cron.log
...
default environment = {
PATH => /usr/bin:/bin:/usr/sbin:/sbin
}
...
runs = 36
last exit code = 0
event triggers = {
com.blogauto.lostark-snapshot.268435472 => {
...
descriptor = {
"Minute" => 10
"Hour" => 21
}
}
...
길지만 볼 곳은 정해져 있어요.
- path: 실제로 읽은 plist 파일. 고친 파일과 다른 파일을 읽고 있는 경우가 의외로 있어요.
- state:
running/not running. - program / arguments / working directory: 실제로 실행되는 명령. 상대 경로
lostark/fetch.py가 동작하는 건 working directory 덕분이에요. - default environment의 PATH:
/usr/bin:/bin:/usr/sbin:/sbin뿐이라 Homebrew 명령은 못 찾아요. 그래서 파이썬은 가상환경 경로를 통째로 적었습니다. - runs / last exit code: 이번에 올린 뒤 몇 번 돌았고, 마지막에 몇으로 끝났는지.
- descriptor:
StartCalendarInterval에 적은 시각. 시각을 여러 개 넣었다면 그 수만큼 나와요.
없는 라벨을 넣으면 이렇게 나옵니다. 오타를 냈거나 아직 안 올린 상태예요.
Bad request.
Could not find service "com.blogauto.lostark-snapshot" in domain for user gui: 501
3. kickstart -k: 예약 시각 기다리지 않고 지금 실행
launchctl kickstart -k gui/$(id -u)/com.blogauto.lostark-snapshot
man page 설명으로는 kickstart는 예약 조건과 상관없이 지금 바로 실행하고, -k는 이미 돌고 있으면 그걸 끄고 다시 띄워요. -p를 붙이면 새로 뜬 프로세스의 PID를 찍어 줍니다.
터미널에서 python fetch.py를 직접 돌리는 것과 다른 점은, launchd가 실제로 쓰는 환경 그대로 돈다는 거예요. 위의 짧은 PATH, working directory, 로그 파일 위치까지 같아서 "터미널에선 되는데 예약으로는 안 돈다"를 확인할 때 좋아요. 출력은 터미널이 아니라 plist의 StandardOutPath 파일로 가니, 돌린 다음 로그 끝을 봅니다.
tail -3 ~/blog-auto/lostark/data/cron.log
이 작업은 같은 날 이미 받은 데이터가 있으면 아무것도 안 받고 끝나게 만들어 두었어요(맥 launchd 자동 실행 스크립트가 가끔 빠질 때). 그래서 받은 날 다시 돌리면 로그에 이런 줄만 남습니다. 아래는 같은 날 저녁 회차가 남긴 실제 줄이에요.
21:23:42 2026-11-02 이미 수집됨 — 건너뜀
반대로 그날 데이터가 없으면 kickstart가 진짜 수집을 해요. 이 글을 쓰던 시각이 자정 직후라 오늘 데이터가 아직 없어서 kickstart는 돌리지 않았습니다. 데이터를 받는 작업이라면 "지금 돌려도 되는 상태인지" 먼저 보는 습관이 좋아요.
4. plist 고친 뒤엔 bootout → bootstrap
plist를 고쳐 저장해도 launchd는 이미 읽어 둔 내용으로 계속 돌아요. 실행 시각을 바꿨다면 내렸다 다시 올려야 합니다. 그 전에 plutil -lint로 문법부터 봐요.
$ plutil -lint ~/Library/LaunchAgents/com.blogauto.lostark-snapshot.plist
~/Library/LaunchAgents/com.blogauto.lostark-snapshot.plist: OK
$ launchctl bootout gui/$(id -u)/com.blogauto.lostark-snapshot
$ launchctl list | grep com.blogauto
$ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.blogauto.lostark-snapshot.plist
$ launchctl list | grep com.blogauto
- 0 com.blogauto.lostark-snapshot
bootout은 도메인/라벨,bootstrap은 도메인 + plist 파일 경로를 받아요. 모양이 달라서 처음에 자주 헷갈립니다.- bootout 직후
list에 아무것도 안 나오면 잘 내려간 거예요.
다시 올린 뒤 print를 보면 카운터가 새로 시작해요. 시각 세 개도 그대로 읽혔습니다.
$ launchctl print gui/$(id -u)/com.blogauto.lostark-snapshot | grep -E 'state =|runs|last exit|"Hour"'
state = not running
runs = 0
last exit code = (never exited)
"Hour" => 21
"Hour" => 12
"Hour" => 9
이미 올라간 걸 또 bootstrap 하면 이런 메시지가 나와요.
$ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.blogauto.lostark-snapshot.plist
Bootstrap failed: 5: Input/output error
Try re-running the command as root for richer errors.
"root로 다시 해 보라"고 하지만 sudo로 돌리면 안 됩니다. 사용자 작업이 시스템 쪽에 올라가 버려요. 대부분은 이미 올라가 있어서 나는 메시지라, bootout 먼저 하고 다시 올리면 됩니다. 숫자의 뜻은 launchctl error로 볼 수 있어요.
$ launchctl error 5
5: Input/output error
5. 예전 load / unload와 뭐가 다른가
검색하면 아직 launchctl load ~/Library/LaunchAgents/xxx.plist가 많이 나와요. 지금도 동작은 하지만 launchctl help에 "레거시"로 표시돼 있습니다.
$ launchctl help
...
load Recommended alternatives: bootstrap | enable. Bootstraps a service or directory of services.
unload Recommended alternatives: bootout | disable. Unloads a service or directory of services.
| 예전 | 지금 | 차이 |
|---|---|---|
load 파일 |
bootstrap gui/$(id -u) 파일 |
어느 도메인에 올릴지 직접 적음 |
unload 파일 |
bootout gui/$(id -u)/라벨 |
파일 대신 라벨로 지정 가능 |
list | grep 라벨 (레거시 아님) |
print gui/$(id -u)/라벨 |
list도 되지만 print는 경로·환경·트리거까지 보임 |
man page에 따르면 레거시 명령은 root로 실행했는지 아닌지로 대상 도메인을 정해요. 그래서 습관처럼 sudo launchctl load를 하면 사용자 작업이 엉뚱하게 시스템 도메인으로 갑니다. 새 명령은 gui/501처럼 도메인을 직접 적으니 이런 실수가 줄어요.
6. plutil -lint: 깨진 plist 미리 잡기
plist는 XML이라 태그 하나만 틀려도 안 올라가요. 일부러 <true/>를 <true>로 잘못 적은 테스트 파일을 검사해 봤습니다.
<key>RunAtLoad</key>
<true>
$ plutil -lint broken.plist
broken.plist: (Encountered non-empty <true> on line 13)
$ echo $?
1
줄 번호까지 알려 주고, 문제가 있으면 종료 코드가 1이라 스크립트에서 plutil -lint 파일 && launchctl bootstrap ...처럼 묶어 쓰기 좋아요. 다만 문법만 봐요. Hour를 hour로 적는 식의 키 이름 오타는 통과하니, 올린 뒤 print의 descriptor로 한 번 더 확인합니다.
정리
| 하고 싶은 것 | 명령 |
|---|---|
| 올라가 있나 | launchctl list | grep 라벨 |
| 자세한 상태·실행 횟수·종료 코드 | launchctl print gui/$(id -u)/라벨 |
| 지금 바로 실행 | launchctl kickstart -k gui/$(id -u)/라벨 |
| plist 문법 검사 | plutil -lint 파일 |
| 고친 plist 다시 읽기 | bootout gui/$(id -u)/라벨 → bootstrap gui/$(id -u) 파일 |
| 에러 숫자 뜻 | launchctl error 숫자 |
참고: Apple launchctl(1) man page — kickstart, bootstrap, bootout, LEGACY SUBCOMMANDS / launchd.plist(5) man page / plutil(1) man page
