flowoodz.
← blog
14분 읽기글 flowoodz

macOS LaunchAgent plist 예제: LaunchDaemon과의 차이와 cron 대신 쓰는 기준

실제로 쓰는 macOS LaunchAgent plist 예제와 launchctl bootstrap 명령을 정리했습니다. RunAtLoad와 StartInterval 기준, LaunchDaemon 차이, PATH의 command not found 해결법을 담았습니다.

목차
  1. GUI 세션에서 도는 작업에는 LaunchAgent가 맞았습니다
  2. LaunchAgent와 LaunchDaemon의 차이
  3. LaunchAgent plist 예제
  4. launchctl은 load 대신 bootstrap을 씁니다
  5. RunAtLoad, StartInterval, WatchPaths를 고르는 기준
  6. 로그 파일을 처음부터 지정합니다
  7. 직접 겪은 실패와 해결
  8. 정리

Slack 멘션 알림 글에서 알림을 Hammerspoon에서 macOS LaunchAgent로 옮겼습니다. 그때는 옮겼더니 됐다는 데까지만 썼습니다. 그 뒤로 1초 폴링용 LaunchAgent를 만들었다가 은퇴시켰고, 지금은 메뉴바 앱을 로그인 때 띄우는 LaunchAgent를 하나 쓰고 있습니다. 만들고 고치고 지워 본 경험을 바탕으로 LaunchAgent를 정리합니다. plist 작성법, 현행 launchctl 문법, 처음 쓸 때 걸리는 함정을 다룹니다.

GUI 세션에서 도는 작업에는 LaunchAgent가 맞았습니다#

맥에서 스크립트를 주기적으로 돌리려고 하면 cron이 먼저 떠오릅니다. 저도 그랬습니다. 하지만 macOS의 백그라운드 작업 관리자는 launchd입니다. launchd는 부팅부터 프로세스 전체를 관리하는 init 시스템이고, 사용자 단위 작업은 LaunchAgent로 등록합니다. cron도 아직 지원되지만, Apple 문서는 cron이 launchd로 대체되어 권장하지 않는다고 적습니다(Apple 문서: Scheduling Timed Jobs).

cron과 비교해 실제로 체감한 차이는 이렇습니다.

  • 초 단위로 실행할 수 있습니다. cron은 최소 1분입니다. Slack 폴링은 1초 간격이 필요해서 cron으로는 불가능했습니다.
  • RunAtLoad를 켜면 로그인하자마자 한 번 실행합니다. cron은 다음 예약 시각까지 기다립니다.
  • KeepAlive를 켜면 프로세스가 죽었을 때 launchd가 다시 띄웁니다. cron에는 이런 감독 기능이 없습니다.
  • WatchPaths를 쓰면 파일이 바뀔 때 실행합니다. 폴링 없이 이벤트로 동작할 수 있습니다.
  • LaunchAgent는 로그인한 사용자 세션에서 돕니다. afplay로 소리를 내거나 알림을 띄우는 작업이 그대로 됩니다. cron이나 LaunchDaemon에서는 이 부분이 애매해집니다.

사용자 세션에서 계속 돌거나 주기적으로 도는 작업이라면 LaunchAgent를 씁니다.

LaunchAgent와 LaunchDaemon의 차이#

plist 폴더는 세 곳입니다.

위치 종류 언제 도나
~/Library/LaunchAgents/ 사용자 LaunchAgent 그 사용자가 로그인했을 때, 그 사용자 권한으로
/Library/LaunchAgents/ 전체 사용자 LaunchAgent 어느 사용자든 로그인했을 때, 그 사용자 권한으로
/Library/LaunchDaemons/ LaunchDaemon 로그인과 상관없이 시스템 수준에서

Apple 문서는 에이전트를 로그인한 사용자 한 명에게 속하고 그 사용자가 로그인해 있는 동안만 도는 프로세스로 설명합니다. 데몬은 사용자가 로그인했는지 신경 쓰지 않아야 하고, 사용자별 기능은 에이전트가 맡으라고 적습니다(Apple 문서: Creating Launch Daemons and Agents). 소리를 내거나 메뉴바 앱을 띄우는 개인 자동화는 홈 아래 ~/Library/LaunchAgents/ 하나면 됩니다.

LaunchAgent plist 예제#

LaunchAgent는 ~/Library/LaunchAgents/에 plist 파일 하나를 두면 됩니다. 아래는 mention-ping 앱을 로그인 때 자동으로 띄우려고 실제로 쓰는 plist입니다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.mentionping.desktop</string>
  <key>ProgramArguments</key>
  <array>
    <string>/Applications/mention-ping.app/Contents/MacOS/mention-ping</string>
  </array>
  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <false/>
</dict>
</plist>

필요한 키는 네 개입니다.

키 역할
Label 고유 식별자. 파일명(com.mentionping.desktop.plist)과 맞춥니다
ProgramArguments 실행할 명령. 배열이고 절대 경로로 씁니다
RunAtLoad 로드될 때(로그인할 때) 한 번 바로 실행합니다
KeepAlive 프로세스가 죽으면 다시 띄울지 정합니다

이 앱은 메뉴바에 계속 떠 있으므로 KeepAlive를 false로 두었습니다. 사용자가 직접 끈 앱을 launchd가 계속 다시 띄우면 오히려 불편합니다. 절대 멈추면 안 되는 폴링 프로세스라면 true로 둡니다.

메뉴바에 상주하는 앱의 예로 Loupecast가 있습니다. 메뉴바에서 ⌘⇧2로 녹화를 시작하는 macOS 화면 녹화 도구입니다.

launchctl은 load 대신 bootstrap을 씁니다#

macOS LaunchAgent 실행 흐름. plist를 gui 도메인에 bootstrap하면 launchd가 RunAtLoad, StartInterval, WatchPaths 조건에 따라 실행하며, PATH가 최소값이라 절대 경로가 필요합니다

인터넷 예제 대부분은 launchctl load ~/Library/LaunchAgents/foo.plist를 알려 줍니다. 이것은 레거시 문법입니다. 아직 동작하지만 실패해도 이유를 거의 알려 주지 않습니다. 지금은 bootstrap과 bootout을 쓰고, 어느 세션(도메인)에 올릴지 명시합니다. 사용자 GUI 세션은 gui/$(id -u)입니다.

실제 설치 스크립트에서 쓰는 순서입니다.

LABEL="com.mentionping.desktop"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
 
# 이미 떠 있으면 내린다 (없으면 에러를 무시)
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
 
# 올린다
launchctl bootstrap "gui/$(id -u)" "$PLIST"
 
# 상태 확인
launchctl print "gui/$(id -u)/$LABEL"

bootstrap 전에 bootout을 먼저 합니다. 같은 Label이 이미 로드돼 있으면 bootstrap이 에러로 끝납니다. || true를 붙여 없으면 무시하게 하면 스크립트를 여러 번 돌려도 안전합니다.

제거는 반대 순서입니다.

launchctl bootout "gui/$(id -u)/$LABEL"
rm "$HOME/Library/LaunchAgents/$LABEL.plist"

plist를 고친 뒤에도 같습니다. 파일만 고치면 반영되지 않습니다. bootout하고 다시 bootstrap해야 launchd가 새 내용을 읽습니다. 저는 이것을 몰라서 StartInterval을 바꿨는데 왜 그대로인지 한참 들여다봤습니다. 떠 있는 프로세스만 다시 시작하려면 launchctl kickstart -k gui/$(id -u)/$LABEL을 씁니다.

이 순서를 매번 손으로 치기 번거로워서 설치 스크립트(enable-login.sh) 하나로 묶었습니다. plist를 만들고 bootout과 bootstrap까지 한 번에 합니다. 동료 맥에 설치할 때도 이 스크립트만 넘기면 됩니다.

RunAtLoad, StartInterval, WatchPaths를 고르는 기준#

RunAtLoad만 켜면 한 번 실행하고 끝납니다. 반복 실행 방법은 세 가지입니다.

  • StartInterval은 N초마다 실행합니다. <key>StartInterval</key><integer>60</integer>처럼 씁니다. 폴링 작업의 기본입니다.
  • StartCalendarInterval은 매일 9시처럼 달력 기준으로 실행합니다. cron 시간표에 해당합니다.
  • WatchPaths는 지정한 파일이나 디렉터리가 바뀌면 실행합니다. 다운로드 폴더 감시나 설정 파일 변경 감지에 맞습니다.

주기가 아주 짧으면 StartInterval보다 계속 떠 있는 프로세스가 낫습니다. Slack 폴링 초기 버전은 LaunchAgent가 1초마다 셸 스크립트를 새로 띄웠습니다. 매초 프로세스를 만들고 없애는 것 자체가 낭비였습니다. 스크립트도 설정과 상태 파일을 매번 처음부터 다시 읽었습니다. 결국 그 LaunchAgent는 은퇴시켰습니다. 지금은 RunAtLoad로 앱을 한 번 띄우고 앱 안에서 루프를 돕니다. LaunchAgent는 로그인할 때 앱을 켜는 일만 하고, 주기는 프로그램이 관리합니다.

정리하면 이렇습니다.

  • 분 단위 이상 주기는 StartInterval을 씁니다.
  • 파일 변경에 반응해야 하면 WatchPaths를 씁니다.
  • 초 단위 폴링이나 상시 감시는 RunAtLoad와 상주 프로세스를 씁니다. 필요하면 KeepAlive를 true로 둡니다.

서버에서 도는 스케줄 작업은 n8n으로 옮겼습니다. 그 구성은 n8n 셀프호스팅 글에 있습니다.

로그 파일을 처음부터 지정합니다#

LaunchAgent가 조용히 실패하면 단서가 없습니다. 터미널이 없으니 에러 메시지가 뜰 곳이 없습니다. Slack 알림 글의 멘션은 왔는데 소리만 안 나던 증상이 이 경우였습니다. 그래서 stdout과 stderr를 파일로 보내는 키를 처음부터 넣습니다.

<key>StandardOutPath</key>
<string>/tmp/mentionping.out.log</string>
<key>StandardErrorPath</key>
<string>/tmp/mentionping.err.log</string>

확인은 이렇게 합니다.

# 살아 있는지와 마지막 종료 코드
launchctl print "gui/$(id -u)/com.mentionping.desktop" | grep -E "state|last exit"
 
# 스크립트 출력
tail -f /tmp/mentionping.err.log

launchctl print 출력의 last exit code를 먼저 봅니다. 0이 아니면 스크립트가 실패한 것입니다. 항목이 아예 없으면 로드되지 않은 것입니다. plist 문법이 의심되면 plutil -lint ~/Library/LaunchAgents/foo.plist로 검사합니다.

직접 겪은 실패와 해결#

증상 원인 해결
터미널에서는 되는데 LaunchAgent에서는 command not found PATH가 최소값이고 .zshrc를 읽지 않음 명령을 절대 경로로 쓰거나 EnvironmentVariables로 PATH를 넣음
파일을 못 찾음 작업 디렉터리가 홈이 아니고 상대 경로를 사용 스크립트에서 절대 경로만 쓰고, 필요하면 WorkingDirectory를 지정
bootstrap이 에러로 실패 같은 Label이 이미 로드됐거나 plist 문법 오류 먼저 bootout ... || true를 하고 plutil -lint로 검사
로드는 됐는데 실행 흔적이 없음 스크립트에 실행 권한이 없음 chmod +x를 하고 StandardErrorPath로 에러 확인

Bootstrap failed: 5: Input/output error는 원인이 이 표보다 많습니다. 10가지를 재현한 결과는 launchctl input/output error 글에 정리했습니다.

원인은 대부분 하나입니다. LaunchAgent는 내 셸 환경을 물려받지 않습니다. 로그인 셸이 아니므로 .zshrc도, alias도, brew가 넣은 PATH도 없습니다. 터미널에서 bash script.sh는 되는데 LaunchAgent에서만 안 된다면 거의 늘 이 때문입니다. 환경변수가 필요하면 plist에 적습니다.

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

토큰 같은 비밀값은 plist에 넣지 않습니다. plist는 평문이고, 홈 디렉터리를 백업하면 같이 복사됩니다. Slack 토큰은 처음에는 별도 설정 파일에 두었다가 지금은 Keychain으로 옮겼습니다.

권한 문제도 한 번 겪었습니다. 스크립트를 만들고 chmod +x를 하지 않아서, 로드는 됐는데 아무 일도 일어나지 않았습니다. StandardErrorPath에 남은 "Permission denied" 한 줄로 원인을 찾았습니다. 로그 파일을 먼저 지정하라고 앞에서 쓴 이유입니다.

정리#

  1. macOS에서 사용자 백그라운드 작업은 LaunchAgent로 돌립니다. cron은 최소 1분 주기이고 사용자 세션 정보가 없습니다.
  2. launchctl은 bootstrap과 bootout이 현행 문법입니다. load와 unload를 쓰는 예제는 오래된 글입니다.
  3. 초 단위 폴링은 StartInterval 대신 RunAtLoad와 상주 프로세스로 만듭니다.
  4. LaunchAgent 환경에는 PATH도 셸 설정도 없습니다. 절대 경로를 쓰고 StandardOutPath와 StandardErrorPath를 처음부터 지정합니다.

plist 한 장과 launchctl 명령 두 줄이면 로그인할 때마다 도는 자동화가 생깁니다. 이번 주 할 일을 보드로 정리하는 개인 도구는 Plan Wall에 있습니다.