macOS launchd로 무인 자동화 파이프라인 만들기
cron 대신 launchd로 옮기며 겪은 권한 문제와, 작업이 6일간 죽어 있어도 몰랐던 알림 구조를 고친 기록
맥에서 주기적으로 도는 작업을 만들려고 crontab을 열었습니다.
그런데 macOS에서 cron은 권장 방식이 아니라는 얘기를 듣고 launchd로 옮겼습니다.
옮기고 나서 한동안 잘 도는 줄 알았습니다.
결론부터 말하면, 그 작업은 6일 동안 죽어 있었고 아무도 몰랐습니다.
launchd 자체는 어렵지 않습니다. 어려운 건 권한과, 죽었을 때 알아채는 방법입니다.
cron 대신 launchd를 쓴 이유
macOS에는 작업 스케줄러가 launchd 하나로 통합돼 있습니다.
부팅 시 실행, 주기 실행, 파일 변경 감지, 프로세스가 죽으면 재시작까지 한 곳에서 처리합니다.
cron에 없는 것 중 실제로 쓸모 있었던 건 파일 감시였습니다.
정해진 시간에 도는 게 아니라 특정 폴더가 바뀌면 도는 작업을 만들 수 있습니다.
<key>WatchPaths</key>
<array>
<string>/Users/me/Documents/source/projects</string>
</array>
<key>ThrottleInterval</key>
<integer>120</integer>ThrottleInterval은 최소 실행 간격입니다.
파일을 여러 개 저장하면 이벤트가 연속으로 발생하는데, 이 값이 없으면 그때마다 프로세스가 뜹니다.
주기 실행은 StartCalendarInterval을 씁니다.
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>9</integer>
<key>Minute</key><integer>0</integer>
</dict>cron 표현식이 아니라 키-값 형태라 처음엔 장황해 보이지만, 오히려 헷갈릴 여지가 적습니다.
처음 시도: 스크립트를 직접 지정했다가 막혔다
plist에서 무엇을 실행할지 적는 부분입니다.
스크립트를 직접 지정하는 형태를 먼저 썼습니다.
<key>ProgramArguments</key>
<array>
<string>/path/to/script.sh</string>
</array>동작은 합니다.
근데 이 형태는 스크립트마다 전체 디스크 접근 권한을 따로 줘야 합니다.
게다가 파일을 옮기거나 이름을 바꾸면 그 권한이 조용히 무효가 됩니다.
권한 설정 화면에는 여전히 남아 있는데 실제로는 안 먹습니다.
인터프리터를 명시하는 형태로 바꿨습니다.
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/path/to/script.sh</string>
</array>이러면 권한은 /bin/bash에 한 번만 주면 되고, 스크립트를 옮겨도 계속 동작합니다.
처음에 이걸 몰라서 한참 헤맸습니다.
터미널에서는 잘 되는데 launchd에서만 실패하니, 스크립트를 계속 고쳤습니다.
문제는 스크립트가 아니라 실행 형태였습니다.
zsh를 쓰면 문서 폴더를 못 읽는다
셸 선택도 중요합니다.
같은 스크립트를 셸만 바꿔가며 돌려봤습니다.
| 실행 형태 | 문서 폴더 읽기 |
|---|---|
/bin/bash + 스크립트 | 성공 |
/bin/sh + 스크립트 | 성공 |
/bin/zsh + 스크립트 | 거부 |
| 스크립트 직접 (shebang zsh) | 거부 |
터미널에서는 zsh가 기본이라 잘 되는데 launchd에서만 막힙니다.
로그인 셸과 launchd가 실행하는 프로세스는 권한이 별개이기 때문입니다.
/bin/sh와 /bin/bash가 같은 파일이 아니라는 점도 알아둘 만합니다.
stat -f %i /bin/sh /bin/bashinode가 다릅니다.
전체 디스크 접근 목록에 하나만 추가하면 다른 쪽을 쓰는 작업은 계속 실패합니다.
종료 코드 126, 127을 파일 문제로 오해했다
launchd에서 작업이 실패했는데 종료 코드가 126이나 127이면 대개 권한 문제입니다.
이 숫자들은 보통 "실행 권한 없음", "명령을 찾을 수 없음"으로 읽힙니다.
그래서 경로를 확인하고 chmod +x를 다시 하고 파일이 있는지 봤습니다.
전부 정상인데 launchd에서만 실패했습니다.
파일이 없는 게 아니라 못 여는 것이었습니다.
막히는 것은 경로가 아니라 프로세스라, 스크립트를 다른 폴더로 옮겨도 소용없습니다.
더 헷갈리는 건 읽기만 막히고 쓰기는 통과한다는 점입니다.
파일 정보를 조회하거나 파일을 쓰는 건 되는데 읽는 것만 안 됩니다.
그래서 엉뚱한 곳에 파일을 쓰고도 눈치채지 못합니다.
권한이 의심되는 상태에서 쓴 파일은 다시 읽어서 확인하기 전까지 썼다고 하면 안 됩니다.
실행 대상을 문서 폴더에 두면 멈춘다
인터프리터가 표준 형태여도, 그 인터프리터가 여는 대상이 보호된 폴더에 있으면 같은 벽에 막힙니다.
그리고 이건 거부가 아니라 멈춤으로 나타나는 경우가 있습니다.
자바 프로그램을 그렇게 돌렸을 때 프로세스가 시작은 하는데 응답이 없었습니다.
스택을 떠보니 jar 파일을 여는 시스템 콜에서 대기 중이었습니다.
| jar 위치 | 결과 |
|---|---|
| 문서 폴더 아래 빌드 결과물 | 무한 대기 |
| 임시 폴더 | 0.9초 |
| 앱 지원 폴더 | 0.9초 |
프로세스는 살아 있고 로그도 안 나오니 "느리다" 또는 "가끔 안 뜬다"로 읽힙니다.
bash로 감싸도 소용없습니다.
권한은 실행 파일 단위라 상속되지 않습니다.
권한이 있는 bash가 띄운 자식 프로세스도 똑같이 멈춥니다.
그래서 실행 대상은 앱 지원 폴더에 복사해서 돌립니다.
빌드 결과물은 저장소에 그대로 두고 설치 스크립트가 복사합니다.
사본이므로 재빌드 후 다시 복사해야 합니다.
안 그러면 "빌드했는데 안 바뀐다"가 되고 원인이 잘 안 보입니다.
상태 확인 명령이 두 파일의 시각을 나란히 보여주게 해두면 바로 드러납니다.
셸 스크립트는 문서 폴더에 둬도 됩니다.
bash에 권한이 있기 때문입니다.
막히는 것은 권한 없는 실행 파일이 여는 대상입니다.
6일 동안 죽어 있었는데 몰랐다
여기가 이 글에서 제일 중요한 부분입니다.
무인으로 도는 작업은 실패해도 사람이 모릅니다.
매일 도는 것으로 알고 있었을 뿐입니다.
알림을 두 층으로 나눴습니다.

1층은 작업 안에서 보냅니다.
실행을 감싸는 래퍼를 두고, 종료 코드가 0이 아니면 알립니다.
2층은 밖에서 대조합니다.
매일 정해진 시간에 "돌아야 하는 작업 목록"과 실제 상태를 비교합니다.
미등록, 상주 중단, 오래 안 돎, 종료 코드 이상을 잡습니다.
1층만으로는 부족합니다.
앞서 말한 6일 사고는 인터프리터가 스크립트를 실행조차 못 해서 생긴 것이었습니다.
스크립트 안의 알림 코드가 시작도 안 됐으니 1층은 무력합니다.
성공은 조용히, 실패만 알린다
규칙 몇 가지를 함께 뒀습니다.
성공까지 매번 알리면 곧 아무도 안 봅니다.
다만 완전한 무음은 위험합니다.
감시 장치 자체가 죽었을 때도 무음이라 구별이 안 됩니다.
그래서 주 1회 생존 신호를 보냅니다.
알림에 쿨다운도 뒀습니다.
재시작이 걸린 작업이 크래시 루프에 빠지면 분당 여러 건을 쏩니다.
채널이 시끄러워지면 사람이 알림을 꺼버리고, 그러면 원래 문제로 정확히 되돌아갑니다.
알림 수단 자체가 살아 있는지도 검사합니다.
웹훅이 설정 안 돼 있으면 그걸 오류로 보고합니다.
조용히 무력한 알림 시스템이 제일 나쁩니다.
실행이 안 됐는데 성공으로 보고됐다
알림을 두 층으로 만들어 두고도 놓친 게 있었습니다.
실행 스크립트에서 명령을 이렇게 부르고 있었습니다.
run_step() {
local out
out=$("$2" 2>&1) || { printf '%s\n' "$out"; return $?; }
}
run_step "sync" "python3 /path/to/sync.py --apply"문제가 둘입니다.
"$2"가 명령 전체를 한 단어로 묶어서 전달하므로, 셸은 그런 이름의 파일 하나를 찾습니다.
당연히 없으니 실행이 안 됩니다.
그리고 return $?가 printf 다음에 실행돼서 printf의 종료 코드인 0을 돌려줍니다.

결과가 세 겹이었습니다.
데이터가 반영되지 않고, 실패로 안 잡히니 실패 알림이 안 가고, 출력이 비어서 성공 알림도 안 갔습니다.
완전한 무음 상태로 매일 정상으로 보고하고 있었습니다.
고친 형태는 이렇습니다.
run_step() {
local name="$1"; shift
local out code=0
out=$("$@" 2>&1) || code=$?
printf '%s\n' "$out"
[ "$code" -ne 0 ] && return "$code"
}
run_step "sync" python3 /path/to/sync.py --apply인자를 펼쳐서 받고, 종료 코드를 그 자리에서 잡습니다.
성공, 실패, 없는 파일, 신호 종료를 각각 넣어 확인했더니 0, 실제 코드, 127 계열, 143이 나옵니다.
문법 게이트를 따로 뒀다
이런 부류는 테스트로 잡기 어렵습니다.
테스트가 그 파일을 아예 임포트하지 않으면 문법이 깨져 있어도 전부 초록입니다.
import ast, pathlib, pytest
FILES = sorted(p for d in ('lib', 'bin', 'tools')
for p in (ROOT / d).rglob('*.py'))
@pytest.mark.parametrize('path', FILES, ids=lambda p: p.name)
def test_syntax(path):
ast.parse(path.read_text(encoding='utf-8'), filename=str(path))임포트가 아니라 파싱이라 부작용이 없습니다.
모듈을 로드할 때 뭔가 실행되는 파일이 섞여 있어도 안전합니다.
셸은 bash -n으로 같은 일을 합니다.
처음엔 검사 대상 폴더를 두 곳만 적었다가 저장소 파일의 절반을 놓쳤습니다.
그 폴더에 든 파일도 파이프라인이 매번 실행하는 것이었습니다.
대상 목록은 실제로 실행되는 것을 기준으로 세는 편이 낫습니다.
원격 접속에 시간 제한을 걸었다
운영 데이터를 읽는 코드가 원격 접속을 쓰는데, 시간 제한이 한 군데도 없었습니다.
상대 기기가 응답하지 않으면 무인 작업이 무기한 매달립니다.
끊어줄 사람도 없습니다.
ssh -o BatchMode=yes -o ConnectTimeout=10 host commandBatchMode는 비밀번호 프롬프트에서 멈추지 않고 즉시 실패하게 합니다.
ConnectTimeout은 연결 단계 상한입니다.
연결된 뒤에 멈추는 경우까지 막으려면 명령 전체에도 상한을 겁니다.
파이썬 subprocess에는 timeout 인자가 있습니다.
닿지 않는 주소로 시험해 보니 정확히 설정한 시간에 끊겼습니다.
로그는 표준 출력에만 쓴다
위치는 ~/Library/Logs/<프로젝트>/로 통일했습니다.
표준 위치라 콘솔 앱에서도 보입니다.
회전은 실행할 때마다 확인합니다.
회전을 안 걸어두면 조용히 커집니다.
비울 때는 파일을 옮기지 말고 잘라야 합니다.
파일을 옮기면 그 파일을 열어둔 프로세스가 계속 옛 파일에 씁니다.
새 파일은 비어 있고 디스크는 그대로 차 있는 상태가 됩니다.
작업 스크립트는 표준 출력에만 씁니다.
로그 파일 경로를 스크립트가 직접 알지 않게 했습니다.
로그 위치, 회전, 알림은 전부 래퍼가 맡습니다.
이렇게 나눠두면 스크립트는 자기 일만 하면 되고, 로그 정책을 바꿔도 스크립트를 안 건드립니다.
헤맸던 부분들
1. 등록했다고 도는 게 아니다
부팅 시 실행 옵션이 켜져 있는데도 실행 횟수가 0으로 남는 경우가 있습니다.
등록과 실행은 별개입니다.
실행 횟수를 확인하고 0이면 수동으로 한 번 시작시킵니다.
2. 기동 대기를 짧게 잡으면 멀쩡한 걸 죽인다
3초로 판정했다가 멀쩡히 뜨는 중인 프로세스를 실패로 읽고 죽이는 일을 두 번 겪었습니다.
15초까지 기다리게 바꿨습니다.
3. 종료 코드 143은 정상 종료가 아니다
종료 신호를 받았다는 뜻입니다.
누가 내렸는지 봐야 합니다.
재시작 옵션이 켜져 있으면 그 뒤 정상적으로 다시 뜨긴 합니다.
4. 등록 해제는 비동기다
곧바로 다시 등록하면 아직 빠지는 중이라 조용히 실패합니다.
그 상태에서 시작 명령을 부르면 없는 작업에 아무 일도 안 일어나고 기동 실패로만 끝납니다.
등록 해제 후에는 실제로 빠졌는지 확인하고 나서 다음 단계로 갑니다.
5. 마지막 종료 코드만 남는다
상태 조회로 보이는 종료 코드는 가장 최근 것 하나뿐입니다.
간헐적으로 실패하는 작업은 다음 실행이 덮어써서 증거가 사라집니다.
그래서 래퍼가 실행 시각과 종료 코드를 별도 파일에 남기게 했습니다.
그래도 cron이 나은 경우
솔직히 모든 상황에서 launchd가 좋은 건 아닙니다.
한 줄짜리 정리 작업을 개인 계정에서 돌리는 정도라면 crontab 한 줄이 더 빠릅니다.
plist를 쓰고 등록하고 권한을 주는 비용이 작업 자체보다 큽니다.
리눅스와 같은 스크립트를 공유해야 하는 경우도 cron 쪽이 편합니다.
launchd는 macOS 전용이라 그대로 옮기지 못합니다.
정리
좋은 점
- 파일 변경 감지, 프로세스 재시작까지 한 곳에서 처리
- plist가 키-값 형태라 cron 표현식보다 헷갈릴 여지가 적음
- 실행 횟수, 마지막 종료 코드를 조회할 수 있어 상태 파악이 쉬움
고려할 점
- 전체 디스크 접근 권한 설정이 GUI 조작이라 자동화가 안 됨
- 종료 코드가 권한 문제를 파일 문제처럼 보이게 해서 원인 추적이 오래 걸림
- macOS 전용이라 리눅스와 스크립트를 공유하지 못함
무인으로 돌릴 작업이 여러 개라면 launchd로 옮기고 알림을 두 층으로 두는 편을 추천합니다.
작업이 하나뿐이고 실패해도 바로 알 수 있는 환경이라면 굳이 옮길 필요까지는 없습니다.