본문 바로가기
macOS & Macintosh

macOS launchd로 주기적 백그라운드 작업 등록하기 - cron 대신 LaunchAgent/LaunchDaemon 쓰기

동교동삼거리·2026년 8월 15일·조회 1

맥에서 백업 스크립트나 정리 작업을 정해진 시각에 돌리려고 crontab -e를 열었다가, 요즘 macOS에서는 cron이 권장 도구가 아니라는 걸 뒤늦게 알게 되는 경우가 많다. cron은 아직 동작은 하지만 Apple이 오래전부터 launchd로 옮기라고 안내해 왔고, 보호된 폴더 접근 권한 문제도 launchd 쪽이 다루기 낫다. 이 글에서는 cron을 걷어내고 launchd로 주기 작업을 등록하는 방법을 순서대로 살펴본다.

결론부터 말하면, 로그인한 사용자 컨텍스트에서 도는 작업이면 ~/Library/LaunchAgents에 plist를 두고 launchctl bootstrap gui/$(id -u)로 올린다. 로그인 전에도 돌아야 하는 시스템 작업이면 /Library/LaunchDaemons에 두고 root로 system 도메인에 올린다. 주기 실행은 StartCalendarInterval(달력 기준)이나 StartInterval(N초 간격) 키로 지정하고, 실행 결과는 plist의 StandardOutPath/StandardErrorPath로 파일에 남긴다.

1. launchd가 무엇이고 cron과 어떻게 다른가

launchd는 macOS의 서비스 관리자다. PID 1로 뜨는 시스템 최초 프로세스이자, 예전 유닉스의 init, cron, inetd 역할을 하나로 합친 것이다. 부팅, 로그인, 파일 변경, 시각 도래 같은 조건을 만나면 지정한 프로그램을 대신 실행한다. launchctl은 이 launchd에 명령을 보내는 CLI다.

cron과 가장 큰 차이는 잠들었다 깨어날 때의 동작이다. cron은 맥이 잠들어 있던 동안의 예약을 그냥 건너뛴다. launchd의 StartCalendarInterval은 잠든 사이 지나간 예약을 깨어날 때 한 번 몰아서 실행한다. 이 차이 때문에 노트북처럼 자주 잠드는 기기에서는 launchd가 유리하다. 다만 이 몰아 실행은 sleep에서 wake로 넘어올 때만 해당한다. 자세한 조건은 3절에서 다시 짚는다.

2. LaunchAgent와 LaunchDaemon, 어디에 두나

launchd 작업은 실행 주체와 시점에 따라 두 종류로 나뉜다. 이름이 비슷해서 헷갈리기 쉬운데, 위치와 실행 컨텍스트가 다르다.

LaunchAgent는 사용자가 로그인한 뒤 그 사용자의 GUI 세션에서 실행된다. 사용자 권한으로 돌고, 화면이나 키체인 같은 로그인 세션 자원에 접근할 수 있다. 개인용은 ~/Library/LaunchAgents, 모든 사용자 공용은 /Library/LaunchAgents에 둔다.

LaunchDaemon은 부팅 시점에, 아무도 로그인하지 않아도 실행된다. 기본적으로 root로 돌며 로그인 세션과 무관하다. 위치는 /Library/LaunchDaemons다. 서버 성격의 상시 작업이나 로그인 전에 준비돼야 하는 것에 쓴다.

개인 백업, 스크린샷 정리, 알림 같은 대다수의 개인 주기 작업은 LaunchAgent로 충분하다. root 권한이나 로그인 이전 실행이 꼭 필요한 게 아니면 LaunchAgent를 먼저 고려한다.

파일 이름은 역방향 도메인 표기의 라벨과 같게 맞춰 두는 것이 관리에 편하다. 아래 예시에서는 라벨을 io.sarc.backup으로 쓰고 파일명을 io.sarc.backup.plist로 둔다.

3. 주기 실행 plist 작성 (LaunchAgent 예시)

먼저 실행할 스크립트를 하나 둔다. 매일 새벽 3시 30분에 백업을 도는 시나리오다.

$ mkdir -p ~/bin
$ cat > ~/bin/sarc-backup.sh <<'EOF'
#!/bin/bash
set -euo pipefail
rsync -a --delete "$HOME/work/" "$HOME/backup/work/"
echo "backup done: $(date '+%F %T')"
EOF
$ chmod +x ~/bin/sarc-backup.sh

이제 plist를 만든다. launchd는 스크립트를 로그인 셸을 거치지 않고 직접 실행하므로 PATH가 최소한으로만 잡힌다. 필요한 경로는 EnvironmentVariables로 명시하는 편이 사고를 줄인다.

$ cat > ~/Library/LaunchAgents/io.sarc.backup.plist <<'EOF'
<?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>io.sarc.backup</string>

  <key>ProgramArguments</key>
  <array>
    <string>/Users/USERNAME/bin/sarc-backup.sh</string>
  </array>

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

  <key>StartCalendarInterval</key>
  <dict>
    <key>Hour</key>
    <integer>3</integer>
    <key>Minute</key>
    <integer>30</integer>
  </dict>

  <key>StandardOutPath</key>
  <string>/Users/USERNAME/Library/Logs/sarc-backup.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/USERNAME/Library/Logs/sarc-backup.err</string>
</dict>
</plist>
EOF

USERNAME은 실제 계정명으로 바꾼다. plist의 경로에는 ~나 환경변수가 통하지 않으므로 절대 경로로 적어야 한다. 여기서 처음 한 번 걸리기 쉽다. ~/bin/...이라고 써 두면 launchd가 ~를 그대로 파일 경로로 해석해 실행이 조용히 실패한다.

StartCalendarInterval은 지정한 Hour/Minute/Weekday/Day/Month이 맞아떨어질 때 실행한다. 넣지 않은 키는 와일드카드다. 위처럼 HourMinute만 주면 매일 그 시각에 실행한다. 여러 시각에 돌리려면 dictarray로 감싸 여러 개 넣으면 된다.

StartInterval은 달력이 아니라 N초 간격으로 실행한다. 5분마다면 <key>StartInterval</key><integer>300</integer>을 쓴다. 두 키의 슬립 동작이 다르다는 점이 중요하다. man 페이지 기준으로 StartInterval은 잠든 사이 예약 시점이 지나가면 그 회차를 놓친다. StartCalendarInterval은 잠든 동안 지나간 회차를 깨어날 때 한 번으로 합쳐(coalesce) 실행한다.

여기서 오해하기 쉬운 지점을 못박아 둔다. 이 몰아 실행은 sleep 상태에서 wake로 넘어올 때만 적용된다. 맥이 완전히 꺼져(shutdown) 있던 동안 지나간 StartCalendarInterval 예약은 부팅해도 실행되지 않고, 그냥 다음 예정 시각까지 건너뛴다. 새벽 예약을 걸어 두고 밤에 맥을 꺼 두는 습관이라면, 켰을 때 밀린 백업이 자동으로 돌 거라 기대하면 안 된다.

4. launchctl로 등록, 확인, 제거

plist를 launchd에 올리는 현대식 명령은 bootstrap이다. 예전의 launchctl load는 아직 동작하지만 deprecated 안내가 붙는다. LaunchAgent는 GUI 로그인 도메인인 gui/<uid>에 올린다.

$ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/io.sarc.backup.plist

등록됐는지는 list로 라벨을 걸러 보거나 print로 상세를 본다.

$ launchctl list | grep sarc
-	0	io.sarc.backup

$ launchctl print gui/$(id -u)/io.sarc.backup
gui/501/io.sarc.backup = {
	active count = 0
	state = not running
	program = /Users/USERNAME/bin/sarc-backup.sh
	...
	stdout path = /Users/USERNAME/Library/Logs/sarc-backup.log
	stderr path = /Users/USERNAME/Library/Logs/sarc-backup.err
}

list 출력의 가운데 값은 마지막 종료 코드다. 0이면 직전 실행이 정상 종료였다는 뜻이고, 왼쪽 -는 지금 실행 중이 아니라는 뜻이다.

새벽까지 기다리지 않고 지금 한 번 강제로 돌려 보려면 kickstart를 쓴다. -k는 이미 실행 중이면 죽이고 다시 띄운다.

$ launchctl kickstart -k gui/$(id -u)/io.sarc.backup
$ cat ~/Library/Logs/sarc-backup.log
backup done: 2026-08-15 14:07:11

plist를 고쳤으면 반드시 내렸다가 다시 올려야 반영된다. 내리는 명령은 bootout이다.

$ launchctl bootout gui/$(id -u)/io.sarc.backup
$ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/io.sarc.backup.plist

같은 라벨을 두 번 bootstrap하면 아래 에러가 난다. 이미 올라와 있다는 뜻이니 bootout 먼저 하면 된다.

Bootstrap failed: 5: Input/output error

5. 실행을 잠시 멈추는 disable/enable은 도메인이 다르다

작업을 지우지 않고 재부팅해도 유지되는 방식으로 꺼 두려면 disable을 쓴다. 여기서 도메인 타깃을 헷갈리면 바로 막힌다. disable/enablesystem 도메인, 또는 useruser-login 도메인의 서비스만 타깃할 수 있다. 영구 override는 user/<uid>에 저장된다. LaunchAgent를 올릴 때 쓰던 gui/<uid>를 그대로 disable에 넘기면 서비스를 못 찾고 실패하기 쉽다.

# 올릴 때(bootstrap/bootout/kickstart/print)는 gui/<uid>
# 끄고 켤 때(enable/disable)는 user/<uid>
$ launchctl disable user/$(id -u)/io.sarc.backup
$ launchctl enable  user/$(id -u)/io.sarc.backup

정리하면, LaunchAgent의 로드 계열 명령은 gui/<uid>, 영구 활성/비활성 토글은 user/<uid>다. LaunchDaemon이면 양쪽 다 system을 쓴다.

6. LaunchDaemon으로 올릴 때의 소유권 함정

로그인 전에도 도는 시스템 작업이면 plist를 /Library/LaunchDaemons에 두고 system 도메인에 root로 올린다. 이때 파일 소유권과 권한이 맞지 않으면 launchd가 거부한다.

$ sudo cp io.sarc.daemon.plist /Library/LaunchDaemons/
$ sudo chown root:wheel /Library/LaunchDaemons/io.sarc.daemon.plist
$ sudo chmod 644 /Library/LaunchDaemons/io.sarc.daemon.plist
$ sudo launchctl bootstrap system /Library/LaunchDaemons/io.sarc.daemon.plist

소유권이 root:wheel이 아니거나 그룹/기타 쓰기 권한이 열려 있으면 아래처럼 막힌다. 위의 chown/chmod를 먼저 하면 해결된다.

Bootstrap failed: 5: Input/output error
Path had bad ownership/permissions

7. 로그와 Full Disk Access 권한 함정

launchd 작업은 터미널을 거치지 않으므로, 표준출력을 어딘가에 남겨 두지 않으면 실패해도 화면에 아무것도 안 보인다. plist에 StandardOutPathStandardErrorPath를 반드시 지정하고, 문제가 있으면 그 파일부터 본다.

$ tail -f ~/Library/Logs/sarc-backup.err

launchd 자신이 실행을 어떻게 판단했는지는 통합 로그로 확인한다. 등록 실패나 조기 종료 원인을 잡을 때 유용하다.

$ log show --predicate 'process == "launchd"' --last 1h | grep io.sarc.backup

가장 자주 발목을 잡는 건 권한이다. launchd로 돌린 스크립트가 ~/Desktop, ~/Documents, ~/Library/Mail 같은 보호된 위치를 읽으려 하면 사용자가 로그인해 손으로 돌릴 땐 잘 되던 작업이 이런 에러로 죽는다.

rsync: opendir "/Users/USERNAME/Desktop" failed: Operation not permitted (1)

이건 macOS의 TCC(개인정보 보호) 때문이다. 대화형 셸에서는 터미널 앱이 이미 접근 권한을 물려주지만, launchd가 띄운 프로세스에는 그 권한이 없다. 해결은 시스템 설정 > 개인정보 보호 및 보안 > 전체 디스크 접근 권한(Full Disk Access)에서 실제로 launchd가 실행하는 실행 파일을 추가하는 것이다.

여기서 한 번 더 걸린다. 목록에 추가할 대상은 스크립트 파일이 아니라 그 스크립트를 해석해 도는 인터프리터 바이너리다. #!/bin/bash로 시작하는 스크립트라면 /bin/bash가 접근 주체가 된다. 셸 인터프리터에 전체 디스크 접근을 통째로 주는 게 꺼려지면, 보호 폴더를 직접 건드리는 부분을 전용 실행 파일로 분리하고 그 파일만 목록에 넣는 편이 낫다. 권한을 준 뒤에는 launchctl kickstart -k로 다시 띄워 확인한다.

8. 정리

로그인 사용자 작업은 LaunchAgent, 로그인 전 시스템 작업은 LaunchDaemon으로 나눈다. plist에는 절대 경로와 PATH를 명시하고, 로그 경로를 반드시 넣는다. 올릴 때는 bootstrap, 내릴 때는 bootout, 즉시 테스트는 kickstart -k를 쓴다. 영구 토글은 enable/disable이되 도메인이 user/<uid>라는 점, 보호 폴더 접근에는 전체 디스크 접근 권한이 필요하다는 점, 그리고 꺼져 있던 동안의 예약은 밀려서 실행되지 않는다는 점만 기억하면 cron에서 넘어오는 데 큰 무리가 없다.

자주 묻는 질문

cron은 이제 macOS에서 못 쓰나?

쓸 수는 있다. crontab은 여전히 동작한다. 다만 Apple은 오래전부터 launchd로 옮기라고 안내해 왔고, 보호 폴더 접근 권한(Full Disk Access)이나 잠자기 복귀 시 밀린 작업 처리 같은 부분은 launchd가 더 낫다. 새로 만드는 주기 작업은 launchd로 두는 것을 권한다.

맥이 꺼져 있거나 잠들어 있는 동안의 예약은 어떻게 되나?

두 경우가 다르다. 잠들어(sleep) 있던 동안 지나간 StartCalendarInterval 예약은 깨어날 때 한 번으로 합쳐 실행된다. 반면 맥이 완전히 꺼져(shutdown) 있던 동안 지나간 예약은 부팅해도 실행되지 않고 다음 예정 시각까지 건너뛴다. StartInterval은 잠든 사이 지나간 회차를 놓친다.

plist를 고쳤는데 반영이 안 된다.

launchd는 로드 시점의 plist를 캐시한다. 파일만 고쳐서는 반영되지 않는다. launchctl bootout gui/$(id -u)/라벨 로 내린 뒤 launchctl bootstrap gui/$(id -u) plist경로 로 다시 올려야 한다. 즉시 확인은 launchctl kickstart -k 로 한다.

launchctl disable에 gui/uid를 줬더니 실패한다.

enable/disable은 system 도메인, 또는 user와 user-login 도메인만 타깃할 수 있다. 영구 override는 user/<uid>에 저장되므로 LaunchAgent를 끌 때도 launchctl disable user/$(id -u)/라벨 을 써야 한다. bootstrap/bootout/kickstart/print에서 쓰는 gui/<uid>와 도메인이 다르다.

손으로는 되던 스크립트가 launchd로 돌리면 Operation not permitted로 죽는다.

macOS의 TCC 보호 때문이다. 대화형 셸에서는 터미널 앱의 접근 권한이 상속되지만 launchd가 띄운 프로세스에는 없다. 시스템 설정 > 개인정보 보호 및 보안 > 전체 디스크 접근 권한에 실제 실행 파일(스크립트라면 그 인터프리터 바이너리)을 추가하고 kickstart -k로 다시 띄운다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.