Mac에서 Cron 대신 LaunchD 사용

어느날 부턴가 MacBook의 Cron이 안돌기 시작합니다. 얼마전 MacOS업데이트 이후로 그런거 같습니다.

Crontab에 단순명령을 입력해보아도 돌지 않아요. 아래는 1분마다 date를 실행하는 job입니다.

$ crontab -l
* * * * * /bin/date >> /tmp/cron_test.txt 2>&1

크론탭 데몬이 살아있는지 확인해보면 살아있는데

$ ps aux | grep cron
slim              1155   0.0  0.0 34129352    724 s000  S+    9:09AM   0:00.00 grep cron
root              1051   0.0  0.0 33753552   2548   ??  Ss    8:58AM   0:00.02 /usr/sbin/cron

Cron 로그를 확인해도 아무것도 안뜹니다.

$ log show --predicate 'process == "cron"' --last 10m
Filtering the log data using "process == "cron""
Skipping info and debug messages, pass --info and/or --debug to include.
Timestamp                       Thread     Type        Activity             PID    TTL
--------------------------------------------------------------------------------------------------------------------
Log      - Default:          0, Info:                0, Debug:             0, Error:          0, Fault:          0
Activity - Create:           0, Transition:          0, Actions:           0

Launchd에서 cron상태를 확인했을때 정상적으로 com.vix.cron이 떠있습니다.

09:10 ~ $ sudo launchctl list | grep cron
Password:
1051	-15	com.vix.cron

Cron을 다시 로드해보지만 실패합니다.

$ sudo launchctl unload /System/Library/LaunchDaemons/com.vix.cron.plist
Unload failed: 5: Input/output error
Try running `launchctl bootout` as root for richer errors.
$ sudo launchctl load /System/Library/LaunchDaemons/com.vix.cron.plist
Load failed: 5: Input/output error
Try running `launchctl bootstrap` as root for richer errors.

시간이 지나도 Cron은 여전히 돌지 않습니다.

$ ls -al /tmp/cron_test.txt
ls: /tmp/cron_test.txt: No such file or directory

아예 실행조차 되지 않는것 같았습니다.

$ log show --predicate 'process == "cron"' --last 15m 
Filtering the log data using "process == "cron""
Skipping info and debug messages, pass --info and/or --debug to include.
Timestamp                       Thread     Type        Activity             PID    TTL
--------------------------------------------------------------------------------------------------------------------
Log      - Default:          0, Info:                0, Debug:             0, Error:          0, Fault:          0
Activity - Create:           0, Transition:          0, Actions:           0

권한 문제가 있는것 같았습니다.

$ stat -f "%Sm %N" /usr/lib/cron/tabs /usr/lib/cron/tabs/slim
Mar 15 08:25:50 2026 /usr/lib/cron/tabs stat: /usr/lib/cron/tabs/slim: stat: Permission denied

권한 상태를 살펴보았습니다.

$ sudo ls -ld /usr/lib/cron/tabs
Password:
drwxr-xr-x@ 3 root  wheel  96 Mar 15 09:17 /usr/lib/cron/tabs
$ sudo ls -l /usr/lib/cron/tabs
total 8
-rw-------@ 1 root  wheel  226 Mar 15 09:17 slim

권한을 맞춰주었습니다.

$ sudo chmod 755 /usr/lib/cron/tabs
$ sudo chown root:wheel /usr/lib/cron/tabs
$ sudo chmod 600 /usr/lib/cron/tabs/slim
$ sudo chown root:wheel /usr/lib/cron/tabs/slim
$ sudo ls -ld /usr/lib/cron/tabs
drwxr-xr-x@ 3 root  wheel  96 Mar 15 09:17 /usr/lib/cron/tabs

Cron 데몬을 Restart시켜보려는데 에러가 납니다.

$ sudo launchctl kickstart -k system/com.vix.cron
Could not kickstart service "com.vix.cron": 150: Operation not permitted while System Integrity Protection is engaged

크론파일을 삭제하고 다시 넣어봅니다. 여전히 안돌아갑니다.

$ sudo rm /usr/lib/cron/tabs/slim
$ crontab -e
* * * * * /bin/date >> /tmp/cron_test.txt 2>&1
$ ls -al /tmp/cron_test.txt
ls: /tmp/cron_test.txt: No such file or directory

뒤에 붙은 @ 표시가 중요합니다. 이건 extended attributes (xattr) 가 붙어 있다는 뜻입니다. macOS에서 /usr/lib/cron/tabs/* 파일에 quarantine / provenance 같은 xattr이 붙으면 cron이 실행을 무시하는 경우가 있습니다. @을 제거해보도록 하겠습니다. 하지만 제거가 원활하게 되지 않습니다.

$ sudo ls -l /usr/lib/cron/tabs
total 8
-rw-------@ 1 root  wheel  226 Mar 15 09:16 slim
$ sudo xattr -l /usr/lib/cron/tabs/slim
com.apple.provenance:
$ sudo xattr -c /usr/lib/cron/tabs/slim
$ sudo ls -l /usr/lib/cron/tabs
total 8
-rw-------@ 1 root  wheel  226 Mar 15 09:17 slim

MacBook을 재부팅해봤습니다. 여전히 Cron이 안돌아 갑니다. 로그를 열어놓고 기다려봤지만 안돌아 갑니다.

$ sudo log stream --predicate 'process == "cron"' --info
Filtering the log data using "process == "cron""

cron 대신 macOS 기본 스케줄러(launchd) 로 같은 작업을 실행해 보면 cron 문제인지 바로 알 수 있습니다.

09:29 ~ $ launchctl submit -l testcron -- /bin/date

그리고 확인. 시스템은 정상이고 cron만 문제입니다.

09:33 ~ $ log show --last 1m | grep testcron
2026-03-15 09:33:53.612271-0400 0xb2f6     Default     0x0                  1      0    launchd: [testcron:] This service is defined to be constantly running and is inherently inefficient.
2026-03-15 09:33:53.612293-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron:] internal event: WILL_SPAWN, code = 0
2026-03-15 09:33:53.612309-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron:] service state: spawn scheduled
2026-03-15 09:33:53.612311-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron:] service state: spawning
2026-03-15 09:33:53.612338-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron:] launching: speculative
2026-03-15 09:33:53.612871-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] xpcproxy spawned with pid 1261
2026-03-15 09:33:53.612887-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] internal event: SPAWNED, code = 0
2026-03-15 09:33:53.612890-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] service state: xpcproxy
2026-03-15 09:33:53.612974-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] internal event: SOURCE_ATTACH, code = 0
2026-03-15 09:33:53.620899-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] service state: running
2026-03-15 09:33:53.620908-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] internal event: INIT, code = 0
2026-03-15 09:33:53.620916-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] Successfully spawned date[1261] because speculative
2026-03-15 09:33:53.624623-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] exited due to exit(0), ran for 12ms
2026-03-15 09:33:53.624631-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] service state: exited
2026-03-15 09:33:53.624635-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] internal event: EXITED, code = 0
2026-03-15 09:33:53.624638-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501 [100002]:] service inactive: testcron
2026-03-15 09:33:53.624640-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron [1261]:] service state: not running
2026-03-15 09:33:53.624643-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron:] Service only ran for 0 seconds. Pushing respawn out by 10 seconds.
2026-03-15 09:33:53.624677-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron:] internal event: WILL_SPAWN, code = 0
2026-03-15 09:33:53.624681-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron:] service state: spawn scheduled
2026-03-15 09:33:53.624682-0400 0xb2f6     Default     0x0                  1      0    launchd: [gui/501/testcron:] service spawn deferred by 10 seconds due to throttle

크론을 죽였다 살려봅니다. 여전히 안됩니다.

$ sudo pkill cron
$ ps aux | grep cron
$ ls -al /tmp/cron_test.txt
ls: /tmp/cron_test.txt: No such file or directory

그렇다면 내 계정의 cron만 무시하는건지 root로 실행해도 안되는건지 테스트해봅니다. root로 해도 안됩니다.

$ sudo nano /etc/crontab
* * * * * root /bin/date >> /tmp/cron_test.txt 2>&1
$ ls -al /tmp/cron_test.txt
ls: /tmp/cron_test.txt: No such file or directory

cron daemon은 떠 있지만 cron 스케줄러가 아예 돌지 않습니다. 이 패턴은 macOS에서 가끔 생기는 cron service 자체가 launchd에서 제대로 작동하지 않는 상태입니다. 그래서 해결 방법은 cron 설정이 아니라 cron launch daemon을 다시 로드하는 것입니다. 이건 cron 서비스를 완전히 unload 한뒤 다시 load 하는 것입니다. 그런데 에러가납니다.

$ sudo launchctl bootout system /System/Library/LaunchDaemons/com.vix.cron.plist
Boot-out failed: 5: Input/output error
$ sudo launchctl bootstrap system /System/Library/LaunchDaemons/com.vix.cron.plist
Bootstrap failed: 5: Input/output error

정상적인 cron 디버깅은 거의 다 했습니다. 그래서 이제 결론은 하나입니다.

지금 상태는 cron이 launchd에서 깨진 상태입니다.

증거들을 정리하면:

  • cron daemon 실행됨
  • crontab 정상
  • /etc/crontab도 실행 안됨
  • cron 로그 0
  • kill / reboot / permission 수정 → 효과 없음
  • launchctl bootout/bootstrap → I/O error

이 패턴은 macOS에서 launch daemon registry가 꼬였을 때 나타납니다.

cron을 다시 살리는 것보다 macOS 기본 스케줄러 launchd로 바꾸는 것이 훨씬 안정적입니다. Apple도 그렇게 권장합니다.

그래서 같은 테스트를 launchd job으로 만들어 보겠습니다. 임시테스트 파일을 하나 만들겠습니다.

nano ~/Library/LaunchAgents/test.cron.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>test.cron</string>

<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>/bin/date >> /tmp/cron_test.txt</string>
</array>

<key>StartInterval</key>
<integer>60</integer>

<key>RunAtLoad</key>
<true/>

</dict>
</plist>

해당 파일을 LaunchD에 등록합니다.

launchctl load ~/Library/LaunchAgents/test.cron.plist

1분뒤에 확인합니다. 잘되네요.

$ cat /tmp/cron_test.txt
Sun Mar 15 09:04:29 EDT 2026

임시로 올렸던 스케줄파일과 테스트파일들 삭제하세요.

launchctl unload ~/Library/LaunchAgents/test.cron.plist
rm ~/Library/LaunchAgents/test.cron.plist
rm /tmp/cron_test.txt

크론에서 만약에 아래와 같은 파일을 실행하고 있었다면

0 5 * * * /Users/slim/git/mijutoday.com/rundailynews.sh > /Users/slim/git/mijutoday.com/output/output_rundailynews.txt

아래 폴더에 해당 스케줄을 넣을 파일을 하나 생성하고 (현재 사용자 이름이 slim인경우입니다.)

$ nano ~/Library/LaunchAgents/com.slim.rundailynews.plist

아래와 같은 XML파일을 저장합니다.

<?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.slim.rundailynews</string>

    <key>ProgramArguments</key>
    <array>
        <string>/bin/zsh</string>
        <string>/Users/slim/git/mijutoday.com/rundailynews.sh</string>
    </array>

    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>5</integer>
        <key>Minute</key>
        <integer>0</integer>
    </dict>

    <key>StandardOutPath</key>
    <string>/Users/slim/git/mijutoday.com/output/output_rundailynews.txt</string>

    <key>StandardErrorPath</key>
    <string>/Users/slim/git/mijutoday.com/output/output_rundailynews_error.txt</string>

    <key>RunAtLoad</key>
    <false/>
</dict>
</plist>

문법이 맞는지 확인합니다.

plutil -lint ~/Library/LaunchAgents/com.slim.rundailynews.plist
/Users/slim/Library/LaunchAgents/com.slim.rundailynews.plist: OK
$ plutil -p ~/Library/LaunchAgents/com.slim.rundailynews.plist
{
  "EnvironmentVariables" => {
    "PATH" => "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
    "PYTHONPATH" => "/Users/slim/git/mijutoday.com"
  }
  "Label" => "com.slim.rundailynews"
  "ProgramArguments" => [
    0 => "/bin/zsh"
    1 => "/Users/slim/git/mijutoday.com/rundailynews.sh"
  ]
  "RunAtLoad" => 0
  "StandardErrorPath" => "/Users/slim/git/mijutoday.com/output/output_rundailynews_error.txt"
  "StandardOutPath" => "/Users/slim/git/mijutoday.com/output/output_rundailynews.txt"
  "StartCalendarInterval" => {
    "Hour" => 5
    "Minute" => 0
  }
  "WorkingDirectory" => "/Users/slim/git/mijutoday.com"
}

실행권한을 줍니다.

$ chmod 644 ~/Library/LaunchAgents/com.slim.rundailynews.plist
$ ls -l ~/Library/LaunchAgents/com.slim.rundailynews.plist
-rw-r--r--@ 1 slim  staff  1200 Mar 15 09:53 /Users/slim/Library/LaunchAgents/com.slim.rundailynews.plist

그리고 설정한 파일을 스케줄러에 등록합니다.

$ launchctl load ~/Library/LaunchAgents/com.slim.rundailynews.plist

혹시 이미 등록이 되어 있어서 에러가 난다면 내리고 다시 올려야합니다. 아래와 같이등록여부를 확인합니다.

$ launchctl list | grep rundailynews
- 0 com.slim.rundailynews

이미 등록이 되어 있다면 어떻게 등록이 되어있는지 확인합니다.

$ launchctl print gui/$(id -u)/com.slim.rundailynews
gui/501/com.slim.rundailynews = {
	active count = 0
	path = /Users/slim/Library/LaunchAgents/com.slim.rundailynews.plist
	type = LaunchAgent
	state = not running

	program = /bin/zsh
	arguments = {
		/bin/zsh
		/Users/slim/git/mijutoday.com/rundailynews.sh
	}

	working directory = /Users/slim/git/mijutoday.com

	stdout path = /Users/slim/git/mijutoday.com/output/output_rundailynews.txt
	stderr path = /Users/slim/git/mijutoday.com/output/output_rundailynews_error.txt
	inherited environment = {
		SSH_AUTH_SOCK => /private/tmp/com.apple.launchd.tmkWVan7Yd/Listeners
	}

	default environment = {
		PATH => /usr/bin:/bin:/usr/sbin:/sbin
	}

	environment = {
		PATH => /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin
		PYTHONPATH => /Users/slim/git/mijutoday.com
		XPC_SERVICE_NAME => com.slim.rundailynews
	}

	domain = gui/501 [100002]
	asid = 100002
	minimum runtime = 10
	exit timeout = 5
	runs = 0
	last exit code = (never exited)

	event triggers = {
		com.slim.rundailynews.268435470 => {
			keepalive = 0
			service = com.slim.rundailynews
			stream = com.apple.launchd.calendarinterval
			monitor = com.apple.UserEventAgent-Aqua
			descriptor = {
				"Minute" => 17
				"Hour" => 4
			}
		}
	}

	event channels = {
		"com.apple.launchd.calendarinterval" = {
			port = 0x0
			active = 0
			managed = 1
			reset = 0
			hide = 0
			watching = 1
		}
	}

	spawn type = daemon (3)
	jetsam priority = 40
	jetsam memory limit (active) = (unlimited)
	jetsam memory limit (inactive) = (unlimited)
	jetsamproperties category = daemon
	jetsam thread limit = 32
	cpumon = default
	probabilistic guard malloc policy = {
		activation rate = 1/1000
		sample rate = 1/0
	}

	properties = inferred program | needs LWCR update | managed LWCR
}

만약 plist파일을 수정했고 다시 반영하고 싶다면 bootout/bootstrap으로 재런칭을 합니다.

$ launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.slim.rundailynews.plist
$ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.slim.rundailynews.plist
새로 반영된 스케줄을 확인합니다.
$ launchctl print gui/$(id -u)/com.slim.rundailynews
gui/501/com.slim.rundailynews = {
	active count = 0
	path = /Users/slim/Library/LaunchAgents/com.slim.rundailynews.plist
	type = LaunchAgent
	state = not running

	program = /bin/zsh
	arguments = {
		/bin/zsh
		/Users/slim/git/mijutoday.com/rundailynews.sh
	}

	working directory = /Users/slim/git/mijutoday.com

	stdout path = /Users/slim/git/mijutoday.com/output/output_rundailynews.txt
	stderr path = /Users/slim/git/mijutoday.com/output/output_rundailynews_error.txt
	inherited environment = {
		SSH_AUTH_SOCK => /private/tmp/com.apple.launchd.tmkWVan7Yd/Listeners
	}

	default environment = {
		PATH => /usr/bin:/bin:/usr/sbin:/sbin
	}

	environment = {
		PATH => /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin
		PYTHONPATH => /Users/slim/git/mijutoday.com
		XPC_SERVICE_NAME => com.slim.rundailynews
	}

	domain = gui/501 [100002]
	asid = 100002
	minimum runtime = 10
	exit timeout = 5
	runs = 0
	last exit code = (never exited)

	event triggers = {
		com.slim.rundailynews.268435471 => {
			keepalive = 0
			service = com.slim.rundailynews
			stream = com.apple.launchd.calendarinterval
			monitor = com.apple.UserEventAgent-Aqua
			descriptor = {
				"Minute" => 0
				"Hour" => 5
			}
		}
	}

	event channels = {
		"com.apple.launchd.calendarinterval" = {
			port = 0x0
			active = 0
			managed = 1
			reset = 0
			hide = 0
			watching = 1
		}
	}

	spawn type = daemon (3)
	jetsam priority = 40
	jetsam memory limit (active) = (unlimited)
	jetsam memory limit (inactive) = (unlimited)
	jetsamproperties category = daemon
	jetsam thread limit = 32
	cpumon = default
	probabilistic guard malloc policy = {
		activation rate = 1/1000
		sample rate = 1/0
	}

	properties = inferred program
}

등록된 스케줄을 확인합니다.

이제 Cron은 전부 지우세요

$ crontab -l