내 기기가 변화를 폴링하는 게 아니라 변화에 반응하길 원했다. 답은 거의 아무도 쓰지 않는, 20년 된 macOS 기능이었다, 그리고 중요한 모든 면에서 cron을 이겼다.
지난주에 내 기기의 설정을 자동으로 커밋하는 시스템을 만들었다. Homebrew 패키지를 설치하면, 8초 뒤 GitHub에 그걸 기록하는 git 커밋이 생긴다.
시작할 때, 내 손가락은 모든 개발자의 손가락이 하는 말을 했다.
그냥 cron에 넣지 뭐.
그렇게 하지 않았다. 그리고 그 이유가 몇 년 만에 배운 가장 쓸모 있는 macOS 교훈이 됐다.
⏰ 폴링이라는 문제
내 자동화를 cron 버전으로 만들면 이렇게 생겼을 거다.
*/5 * * * * /Users/me/sync.sh
5분마다, 영원히, 내 기기는 프로세스 하나를 깨워서 묻는다. 뭐 바뀐 거 있어?
거의 매번, 답은 아니오다.
이게 cron의 근본적인 형태다. 폴링. cron은 이벤트를 모른다. 시간만 안다. 그래서 나쁜 트레이드오프에 몰린다.
- 자주 폴링하면 → 하루에 수백 번의 무의미한 실행, 혹시 몰라서
- 드물게 폴링하면 → 변경 사항이 한 시간 동안 커밋 안 된 채 남아, 애초의 목적이 무의미해짐
그리고 노트북에서 돌리는 cron에는 두 번째, 더 은근한 문제가 있다. cron은 기기가 켜져 있다고 가정한다. 11시 58분에 노트북 뚜껑을 닫으면, 정오 작업은 그냥 실행되지 않는다. 재시도도, 따라잡기도, 아무것도 없다. 서버는 항상 켜져 있지만 노트북은 잠든다. cron은 서버를 위해 설계됐다.
내가 실제로 원했던 건 말로 하면 간단했다.
파일시스템이 바뀌면 내 스크립트를 실행해라. 아니면 아무것도 하지 마라.
이건 일정이 아니다. 이벤트다.
⚙️ 이미 갖고 있던 데몬
macOS는 launchd를 기본 탑재하고 있다, 말 그대로 프로세스 1번, 시스템의 모든 서비스를 부팅하는 그것이다. Apple은 몇 년 전 내부적으로 cron을 launchd로 대체했다(당신 맥의 cron 작업들도 사실 launchd가 실행해준다).
대부분의 개발자는 launchd의 존재를 안다. 그런데 거의 아무도 launchd의 최고 기능은 쓰지 않는다.
WatchPaths, launchd는 OS 레벨에서 파일시스템 경로를 감시하다가, 바뀌는 순간 작업을 실행한다.
<key>WatchPaths</key>
<array>
<string>/opt/homebrew/Cellar</string>
<string>/opt/homebrew/Caskroom</string>
</array>이게 전부다. Homebrew는 모든 패키지를 /opt/homebrew/Cellar에 설치한다, 그러니 어느 셸에서든, 어느 스크립트에서든, 어느 도구에서든 설치나 삭제가 일어나면 그 디렉토리가 바뀐다. OS가 알아챈다. 내 스크립트가 실행된다.
폴링도 없다. 낭비되는 실행도 없다. 유휴 상태에선 CPU 사용량이 0이다. 작업은 등록된 의도로 존재하고, 감시는 커널이 한다.
전체 작업 정의는 plist 파일 하나로 끝난다.
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.paurushrai.dotfiles-sync</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/paurushrai/Developer/infra/dotfiles/sync.sh</string>
</array>
<key>WatchPaths</key>
<array>
<string>/opt/homebrew/Cellar</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>12</integer>
<key>Minute</key><integer>0</integer>
</dict>
<key>ThrottleInterval</key>
<integer>60</integer>
<key>StandardOutPath</key>
<string>/Users/paurushrai/Library/Logs/dotfiles-sync.log</string>
</dict>
</plist>~/Library/LaunchAgents/에 넣고, 한 번 등록한다.
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.paurushrai.dotfiles-sync.plist재부팅해도 살아남는다. 터미널이 열려 있든 아니든 실행된다. 이제 OS의 일부다.
정직한 방식으로 테스트했다, 패키지 하나 설치하고 아무것도 건드리지 않았다.
$ brew install jq
... 8초 뒤, 셸 개입 전혀 없이:
67ba27e chore: auto-sync .Brewfile → pushed to GitHub ✅
🧩 무거운 작업을 대신 해주는 세 개의 키
위의 plist에는 세 가지 설계 결정이 담겨 있고, 각각이 내가 직접 짜야 했을 코드를 대신해준다.

ThrottleInterval: 60은 공짜 디바운서다. 패키지 다섯 개를 연달아 설치하면 파일시스템은 이벤트 다섯 개를 쏘지만, launchd는 60초에 한 번 이상 작업을 실행하지 않는다. 그래서 다섯 개를 아우르는 커밋 하나만 생긴다. cron 세계에서는 이런 배치 처리를 직접 짜고 테스트해야 한다. 여기선 정수 하나면 끝이다.
StartCalendarInterval은 cron의 스케줄이다, 노트북 문제만 빼고. 감시가 놓칠 만한 것들을 위한 정오 캐치올 실행을 준다. 그리고 cron과 달리, 정오에 기기가 잠들어 있으면 launchd는 깨어났을 때 그 작업을 실행한다. 놓친 일정은 아예 안 하는 게 아니라 늦게라도 실행된다. 노트북 입장에선 이 하나만으로 비교가 끝난다.
StandardOutPath는 공짜 관측 가능성이다. 매 실행의 출력이 내가 지정한 로그 파일에 쌓인다. 왜 뭔가 실행되지 않았는지 궁금할 때, 답은 이미 적혀 있었다.
🔁 누구나 걸리는 함정
이제 고백할 차례다. 내 스크립트는 git 저장소에 커밋한다, 그리고 첫 본능은 저장소 디렉토리 자체를 감시하는 거였다. 감시 항목 하나로 모든 파일을 커버한다. 우아하다.
그리고 이건 무한 루프이기도 하다.

모든 git 커밋은 .git/을 변경한다. 그러니: 변경이 에이전트를 발동시킨다 → 에이전트가 커밋한다 → 커밋이 .git/을 바꾼다 → 그건 감시 중인 디렉토리 안에 있다 → 그래서 에이전트가 다시 발동된다. 영원히.
해결책은 외워둘 만한 규칙 하나다.
네 작업이 쓰는 디렉토리는 절대 감시하지 마라.
대신 나는 개별 파일을 감시한다, 추적하는 각 dotfile마다 자기만의 WatchPaths 항목을 갖고, 저장소 루트는 일부러 빼둔다. 트레이드오프는 이렇다. 깊은 서브디렉토리 안의 새 파일은 즉시 알아채지 못한다. 정오 실행이 대신 쓸어담아준다. 자기 꼬리를 물지 않는 시스템을 위한 공정한 대가다.
같은 날 오후에 겪은 함정 두 개를 더 남긴다. 나처럼 수업료를 내지 않아도 되게.
- plist를 심링크하지 마라. 나는 plist를 dotfiles 저장소에 두고
~/Library/LaunchAgents/에는 복사한다, 최신 macOS에서 launchd는 심링크된 작업 정의를 다루는 게 불안정하다. 저장소가 원본을 갖고, 설치 스크립트가 사본을 배포한다. - launchd 작업은 최소한의 환경만 받는다. 당신 셸의
PATH도, alias도,.zshrc도 없다. 내 스크립트는 첫 줄에서 자기만의PATH를 설정한다. "터미널에서는 되는데 launchd에서는 안 된다"면, 거의 항상 이게 원인이다.
🔔 말하게 만들고, 그다음 기다리게 만들기
눈에 보이지 않는 백그라운드 작업은 결국 신뢰를 잃는 백그라운드 작업이다.
내 스크립트는 macOS 네이티브 한 줄로 모든 커밋을 알린다.
osascript -e 'display notification "Committed & pushed: .Brewfile" with title "dotfiles sync"'첫 테스트: 나는 알림을 보지 못했다. 배너 알림은 5초면 사라진다.
해결책은 코드가 아니라 설정이었다, 거의 아무도 안 바꾸는 설정. 시스템 설정 → 알림 → Script Editor → 알림. 알림(Alert) 스타일은 직접 닫을 때까지 화면에 남아 있다. 이제 모든 동기화는 내가 확인할 때까지 구석에서 기다리고, 바뀐 파일 이름이 메시지에 담긴다.
중요한 설계 디테일: 스크립트는 실제로 뭔가 커밋됐을 때만 알린다. 아무 일도 없던 실행은 조용하다. 계속 울리는 알림은 이미 무시하는 법을 배운 알림이다, 알림 피로는 알림이 아예 없는 것보다 관측 가능성을 더 빨리 죽인다.
🛠 치트 시트
직접 만드는 데 필요한 전부.
# 등록 (한 번, 혹은 plist를 수정한 뒤)
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.myjob.plist# 등록 해제
launchctl bootout gui/$(id -u)/com.example.myjob# 로드됐는지 확인
launchctl list | grep myjob# 무슨 일이 있었는지
tail -f ~/Library/Logs/myjob.log그리고 내가 있었으면 했던 결정표.
- 작업이 파일이나 디렉토리 변경에 반응해야 한다 →
WatchPaths - 작업이 노트북에서도 안전한 일정으로 돌아야 한다 →
StartCalendarInterval - 작업이 뭉텅이로 발생해서 배치 처리가 필요하다 →
ThrottleInterval - 작업이 등록되는 순간 바로 실행돼야 한다 →
RunAtLoad - 맥에서
crontab -e를 치려는 참이다 → 멈추고, plist를 써라
🎯 교훈 아래의 교훈
내가 만든 자동화는 작다. 원칙은 그렇지 않다.
나도 cron에 손이 갈 뻔했다. cron은 내가 아는 것이었으니까. 그리고 파일 감시용 npm 패키지에도 손이 갈 뻔했다. 검색 결과가 그렇게 하라고 하니까. 둘 다 작동은 했을 거다, 나쁘게. 하나는 일정으로 폴링하고, 다른 하나는 OS가 네이티브로 해주는 일을 위해 의존성 하나를 더 얹는다.
지루하고, 기본 탑재돼 있고, 20년 된 도구가 둘 다보다 나았다. 폴링이 아니라 이벤트 기반. 서버를 가정하는 대신 노트북을 아는. 하나 더 설치할 것을 늘리는 대신 의존성 제로.
도구를 추가하기 전에, OS가 이미 뭘 제공하는지부터 물어라.
답은 생각보다 자주 이렇다. 당신이 필요했던 모든 것이, 프로세스 1번 안에, 2005년부터 기다리고 있었다.
당신의 LaunchAgents 폴더엔 뭐가 돌고 있나
다른 사람들이 OS 레벨에서 뭘 자동화했는지, 그리고 그 과정에서 어떤 함정에 걸렸는지 궁금하다. WatchPaths 피드백 루프가 유일한 함정일 리는 없을 텐데.


