flowoodz.
← blog
14분 읽기글 flowoodz

LaunchAgent StartCalendarInterval: 맥이 잠든 사이 지난 예약은 깨어날 때 돌고, 그 몇 초 사이에 실패했습니다

LaunchAgent StartCalendarInterval 예약은 맥이 잠든 사이 시각이 지나면 깨어날 때 한 번 실행되고, StartInterval은 그 시간을 그냥 건너뜁니다. 맥북에서 launchd 작업 51개를 돌리며 깨어난 직후의 DNS 실패(Errno 8), 요청 중 연결 끊김, 4시간 멈춘 실행이 다음 예약을 삼킨 일을 겪었고, caffeinate와 DNS 대기로 고친 과정을 적었습니다.

목차
  1. StartCalendarInterval은 깨어날 때 한 번 돕니다
  2. 로그에 남은 늦은 실행은 10번이었습니다
  3. 실패 1: 깨어난 몇 초 사이에 DNS를 찾지 못했습니다
  4. 실패 2: 요청 도중에 다시 잠들었습니다
  5. 고친 것: 깨어 있게 잡고, DNS가 돌아올 때까지 기다립니다
  6. 실패 3: 멈춘 실행이 다음 예약을 삼켰습니다
  7. StartInterval은 그냥 건너뜁니다
  8. 미결
  9. 지금 쓰는 기준

맥북 한 대에서 launchd 작업 51개를 돌립니다. 38개는 StartCalendarInterval로 정해진 시각에, 13개는 StartInterval로 몇 분마다 돕니다. 글을 올리고, 데이터를 모으고, 결과를 텔레그램으로 보내는 자동화입니다.

LaunchAgent를 만들고 올리는 기본 절차는 macOS LaunchAgent plist 예제 글에 적었습니다. 이 글은 그다음에 겪은 일입니다.

10월 7일부터 3주 동안 실패를 세 종류 겪었는데, 셋 다 코드가 아니라 맥의 잠자기에서 시작됐습니다.

StartCalendarInterval은 깨어날 때 한 번 돕니다

맥이 자는 동안 예약 시각이 지나면 launchd는 깨어날 때 그 작업을 실행합니다. man launchd.plist에 이렇게 적혀 있습니다.

Unlike cron which skips job invocations when the computer is asleep,
launchd will start the job the next time the computer wakes up.  If
multiple intervals transpire before the computer is woken, those events
will be coalesced into one event upon wake from sleep.

cron은 잠든 동안의 실행을 건너뛰고, launchd는 깨어날 때 실행합니다. 여러 번이 지났으면 한 번으로 합칩니다.

Apple의 Scheduling Timed Jobs 문서는 하나를 더 적습니다. 잠든 게 아니라 전원이 꺼져 있었다면 다음 예약 시각까지 실행하지 않습니다.

제 plist는 요일과 시각을 배열로 줍니다. 화요일과 금요일 21시에 도는 작업의 실제 설정입니다.

<key>StartCalendarInterval</key>
<array>
  <dict>
    <key>Hour</key>
    <integer>21</integer>
    <key>Minute</key>
    <integer>0</integer>
    <key>Weekday</key>
    <integer>2</integer>
  </dict>
  <dict>
    <key>Hour</key>
    <integer>21</integer>
    <key>Minute</key>
    <integer>0</integer>
    <key>Weekday</key>
    <integer>5</integer>
  </dict>
</array>

Weekday는 0과 7이 모두 일요일입니다. 빠진 키는 와일드카드로 읽습니다. Hour만 적고 Minute을 빼면 그 시간에 매분 실행됩니다.

로그에 남은 늦은 실행은 10번이었습니다

작업마다 시작과 끝 시각을 로그에 남깁니다. 10월 3일부터 11월 8일까지 예약으로 시작한 실행 1,066번을 예약 시각과 맞춰 봤습니다.

예약보다 1분 넘게, 1시간이 안 되게 늦게 시작한 실행은 10번이었습니다. 늦은 정도는 1분에서 18분이었습니다. 1시간 넘게 벌어진 68번은 그사이 시간표를 바꾼 작업이 섞여 있어서 세지 않았습니다.

10월 26일 저녁이 가장 뚜렷했습니다.

예약 실제 시작 끝
17:30 (작업 두 개) 17:31:11 17:31:16, 22:28:34
18:29 18:38:14 22:35:45
19:00 19:02:28 22:42:08
21:00 21:04:01 22:26:34
22:00 22:04:43 22:24:37

17:30 예약 두 개는 같은 초에 시작했습니다. 맥이 그 순간 깨어나 지난 예약을 한꺼번에 실행한 것입니다. 그리고 금방 끝난 하나를 빼면 모두 22시 24분에서 42분 사이에 끝났습니다.

sysctl로 맥이 마지막으로 잠든 시각과 깨어난 시각을 볼 수 있습니다.

$ sysctl -n kern.waketime kern.sleeptime
{ sec = 1793021013, usec = 541484 } Mon Oct 26 22:23:33 2026
{ sec = 1793020918, usec = 487428 } Mon Oct 26 22:21:58 2026

11월 8일에 찍은 값인데 아직 10월 26일입니다. 그 뒤로 이 맥은 잠들지 않았습니다. 로그와 겹쳐 보면 그날 저녁 작업들은 잠깐 깨어난 틈에 시작했다가 맥과 함께 멈췄고, 22시 23분에 완전히 깨어난 뒤에야 마저 끝났습니다.

실패 1: 깨어난 몇 초 사이에 DNS를 찾지 못했습니다

10월 8일에 Threads 게시 작업이 두 번 연달아 실패했습니다. 12:30 예약은 12:39:27에, 17:30 예약은 17:40:32에 시작했고 둘 다 1초 만에 끝났습니다. 에러는 같았습니다.

<urlopen error [Errno 8] nodename nor servname provided, or not known>

n8n 웹훅 호출도, 실패를 알리는 텔레그램 전송도 같은 에러로 실패했습니다. 그래서 실패했다는 알림조차 오지 않았습니다.

macOS는 잠든 상태에서 화면을 켜지 않고 잠깐 깨어 백그라운드 일을 합니다. 이 상태를 DarkWake라고 부릅니다.

launchd는 그 짧은 순간에 지난 예약을 실행했고, 네트워크는 아직 돌아오지 않았습니다. 그날 고친 커밋에는 2초짜리 DarkWake라고 적어 두었습니다.

같은 날 다른 실행기에서는 더 오래 붙잡혔습니다. Claude CLI로 글을 쓰는 루틴인데, 13:00 예약이 13:06:21의 DarkWake에서 시작했습니다. CLI가 API 서버 주소를 찾지 못해(ENOTFOUND) 40분 동안 재시도만 했습니다.

제가 덮개를 연 13:44 직후, 13:46에야 실패로 끝났습니다. 그때 맥은 배터리 상태로 덮개가 닫혀 있었습니다.

실패 2: 요청 도중에 다시 잠들었습니다

하루 전인 10월 7일에는 22:30 예약이 22:32:45에 시작했습니다. 14분 뒤인 22:46:45에 이 에러로 끝났습니다.

[Errno 54] Connection reset by peer

n8n에 요청을 보내 놓고 응답을 기다리는 사이 맥이 다시 잠들었다고 봤습니다. 첫 대응은 작업이 도는 동안 caffeinate -i로 잠자기를 막는 것이었습니다. 그런데 man caffeinate를 보면 이 옵션이 막는 범위는 좁습니다.

-i      Create an assertion to prevent the system from idle sleeping.
 
-s      Create an assertion to prevent the system from sleeping. This
        assertion is valid only when system is running on AC power.

-i는 입력이 없어서 드는 잠(idle sleep)만 막습니다. -s는 전원 어댑터에 꽂혀 있을 때만 통합니다. 13:06에 실패한 루틴처럼 배터리 상태로 덮개를 닫아 둔 맥은 둘 다 붙잡지 못합니다.

실제로 10월 26일 저녁의 작업들은 caffeinate -i가 걸린 채로 시작했는데도 22시 23분까지 맥과 함께 멈춰 있었습니다.

고친 것: 깨어 있게 잡고, DNS가 돌아올 때까지 기다립니다

실행 스크립트 run.sh에서 작업을 띄우기 전 단계를 이렇게 바꿨습니다.

caffeinate -i -w $$ &
for _ in {1..24}; do
  python3 -c "import socket; socket.getaddrinfo('n8n.flowoodz.com', 443)" 2>/dev/null && break
  echo "wait: DNS not ready"; sleep 5
done

-w $$는 이 셸이 끝나면 caffeinate도 같이 끝내라는 옵션입니다. 처음에는 작업 명령 하나만 caffeinate -i로 감쌌는데, DNS를 기다리는 동안에도 깨어 있도록 스크립트 전체를 잡게 옮겼습니다.

그다음 DNS 조회가 될 때까지 5초 간격으로 최대 24번, 2분을 기다립니다.

이 대기는 그 뒤 두 번 걸렸습니다. 10월 10일 14:57:52와 10월 26일 15:29:42에 시작한 실행이 wait: DNS not ready를 두 번 찍고 10초 뒤 정상으로 이어졌습니다. 잠자기 때문에 생긴 Errno 8 실패는 그 뒤로 없었습니다.

Claude CLI 루틴의 실행기는 반대로 정했습니다. 시작할 때 API 서버에 연결해 보고, 안 되면 Claude를 띄우지 않고 "오프라인"으로 기록한 뒤 끝냅니다. 40분 동안 붙잡혀 있다가 실패하느니, 바로 끝내고 다음 예약을 기다리는 편이 낫다고 봤습니다.

실패 3: 멈춘 실행이 다음 예약을 삼켰습니다

3시간마다 도는 게시 작업이 있습니다. 10월 26일 18:29 예약은 18:38:14에 시작해 22:35:45에 끝났습니다. 이 작업의 실행 204번 가운데 절반은 14초 안에 끝났고, 30분을 넘긴 건 이 한 번뿐입니다.

4시간 가까이 걸린 그 실행 사이에 21:29 예약이 있었는데, 로그에는 21:29의 흔적이 한 줄도 없습니다.

run.sh는 같은 작업이 이미 돌고 있으면 skip: ... still running 한 줄을 남기고 끝나도록 만들어 두었습니다. 그 줄조차 없으니 launchd가 21:29에 작업을 아예 띄우지 않은 것입니다.

man 페이지는 "돌고 있는 동안의 예약은 놓친다"는 문장을 StartInterval 항목에만 적어 두었습니다. 달력 예약에서도 똑같이 움직였습니다.

제 대시보드는 이 21:29를 "놓침"으로 표시했습니다. 앞선 실행이 돌던 시간 안에 든 예약은 놓친 것으로 세지 않게 고쳤습니다.

StartInterval은 그냥 건너뜁니다

StartInterval은 반대로 움직입니다. man 페이지의 문장입니다.

If the system is asleep during the time of the next scheduled interval
firing, that interval will be missed due to shortcomings in kqueue(3).

20분(1200초)마다 도는 수집 작업의 10월 26일 로그를 봤습니다. 낮에는 20분 간격이 지켜지다가 12:24:34 다음이 13:11:49, 15:43:13 다음이 22:39:02였습니다.

7시간 가까이 비었는데도, 깨어난 뒤에 밀린 실행이 몰려서 돌지 않았습니다. 달력 예약 작업들이 17시부터 잠깐씩 깨어 시작하던 그 저녁에도 이 작업은 한 번도 돌지 않았습니다.

대시보드는 출력이 세 주기 넘게 없으면 "놓침"으로 띄웁니다. 덮개를 닫아 둔 시간까지 세서 경고가 났습니다. 그래서 기준 시각을 마지막 출력과 kern.waketime 중 늦은 값으로 바꿨습니다.

since = max(filter(None, (last, ctx.woke)), default=None)  # sleep time is not a missed tick

미결

22:39:02가 왜 그 시각이었는지 아직 모릅니다. 완전히 깨어난 22:23:33에서 15분 29초 뒤라 20분 주기와 맞지 않습니다. 깨어 있던 시간만 세서 주기를 채운다면 저녁의 짧은 DarkWake들을 더해 20분이 됐을 수 있는데, 확인하지 못했습니다.

이 맥은 pmset -g에서 powernap 1입니다. Power Nap을 끄면 DarkWake에서 작업이 시작되는 일 자체가 줄어드는지는 시험해 보지 않았습니다.

지금 쓰는 기준

  • 잠든 사이에 시각이 지나도 한 번은 돌아야 하는 작업은 StartCalendarInterval로 둡니다. 깨어날 때 한 번 돕니다.
  • 놓쳐도 되는 폴링은 StartInterval로 둡니다. 잠든 동안의 실행은 사라지고, 깨어난 뒤에 몰려서 돌지 않습니다.
  • 네트워크를 쓰는 작업은 시작할 때 DNS부터 확인합니다. 깨어난 직후 몇 초는 네트워크가 없습니다.
  • caffeinate -i는 덮개를 닫은 배터리 맥을 깨어 있게 하지 못합니다.
  • 실행이 길어지면 그사이 예약은 launchd가 띄우지 않습니다.

plist를 고쳐 다시 올리다가 Bootstrap failed: 5: Input/output error를 만나면 launchctl input/output error 글의 점검 순서를 따라가면 됩니다.

서버에서 돌려도 되는 예약 작업은 잠자기와 상관없는 n8n 셀프호스팅으로 옮겼습니다.