Skip to content
khiopost — 개발·AI 트러블슈팅
Go back

Cloudflare Workers Cron Trigger가 안 돌 때 — 은퇴시킨 워커에서 배운 것들

Updated:

Cloudflare Workers Cron Trigger가 안 돌면 원인은 거의 셋 중 하나다. wrangler.toml의 crons 설정, UTC 타임존 혼동, scheduled() 핸들러 export. 나는 이 셋을 전부, 순서대로 겪었다.

(미리 밝혀두면 — 이 글은 4월에 처음 발행했고, 2026년 7월에 이 워커를 실제로 은퇴시키면서 그 결말까지 넣어 전면 개고했다.)

2026년 7월 2일에 워커 하나를 지웠다. khiopost-autobot — UTC 02시대에 매분 깨어나던 워커다. 워드프레스로 블로그를 굴리던 시절, 대기열에 쌓아둔 글을 이틀에 한 번 올려주는 예약 발행 담당이었는데, 사이트를 정적 스택으로 옮기면서 할 일이 없어졌다. 지우고 나서야 이 워커한테서 Cron Trigger 삽질을 얼마나 배웠는지 생각나서, 겪은 순서대로 남겨둔다.

등록했는데 아무 일도 일어나지 않았다

크론이 아예 안 돌면 코드보다 wrangler.toml부터 열어봐야 한다.

처음 이 워커에 Cron Trigger를 붙이던 날, 시각이 지나도 아무 일이 없었다. 나는 Worker 코드에 버그가 있는 줄 알고 한참을 헤맸는데, 실제로는 wrangler.toml에 crons 항목을 아예 빼먹은 거였다. 허무하더라.

은퇴 직전까지 실제로 쓰던 설정은 이거다.

# wrangler.toml — 은퇴한 khiopost-autobot 워커의 실제 설정
name = "khiopost-autobot"
main = "src/index.ts"

[triggers]
# UTC 기준. 매 분 호출 — KST 11:00~11:59(UTC 02).
crons = ["* 2 * * *"]

여기서 걸리기 쉬운 문법 함정이 몇 개 있다. crons는 반드시 배열이다 — crons = "0 * * * *"처럼 문자열 하나로 쓰면 안 된다. [triggers][trigger]로 오타 내면 에러도 없이 조용히 무시된다. 그리고 표현식은 표준 5필드(분 시 일 월 요일)만 받는다 — 초 단위가 붙은 6필드는 안 통한다.

그날 이후 생긴 버릇이 하나 있다. wrangler deploy 출력 마지막에 schedule: 줄이 찍히는지 꼭 본다. 이 줄이 없으면 크론은 등록되지 않은 거다. 대시보드 쪽에서는 Workers & Pages > 해당 Worker > Settings > Triggers 탭에서 같은 걸 확인할 수 있다.

매분 도는 크론을 일부러 걸었다

cron 표현식으로는 "매일 랜덤한 분에 한 번"을 표현할 수 없다. 그래서 발행 시간대 전체에 매분 크론을 걸고, 실행 여부는 핸들러 안에서 결정했다.

위 설정의 * 2 * * *는 UTC 02시대 매분 — KST로 11:00~11:59, 하루 60번 발동이다. 매일 정확히 같은 분에 글이 올라오는 게 싫어서, 그날 처음 깨어날 때 0~59 사이 랜덤 분을 하나 뽑아 KV에 schedule:{날짜} 키로 저장해두고, 이후 매분 깨어날 때마다 그 분에 도달했는지만 확인했다. 실제 핸들러 골격은 이랬다.

// src/index.ts — 은퇴한 워커의 실제 scheduled() 골격
export default {
  async scheduled(
    _controller: ScheduledController,
    env: Env,
    _ctx: ExecutionContext
  ): Promise<void> {
    // 1. 2일 인터벌 체크 — 마지막 발행으로부터 48시간 안 지났으면 silent skip
    const nextAfterIso = await getNextPublishAfter(env);
    if (nextAfterIso && Date.now() < new Date(nextAfterIso).getTime()) {
      return;
    }

    // 2. 오늘 스케줄(랜덤 target 분) 가져오기. 없으면 자동 생성.
    const sched = await getOrCreateTodaySchedule(env);
    if (sched.morningDone) return;

    // 3. 현재 KST 분이 target 이상인지 확인. 미달이면 silent skip.
    const now = new Date();
    const kstMinutes =
      (now.getUTCHours() * 60 + now.getUTCMinutes() + 9 * 60) % (24 * 60);
    const targetAt = sched.morningHour * 60 + sched.morningMinute;
    if (kstMinutes < targetAt) return;

    // 4. 발행. 성공하면 다음 발행 가능 시각 = 지금 + 48시간.
    const result = await publishNext(env, { silentEmpty: true });
    if (result.success) {
      await markSlotDone(env);
      const next = new Date(Date.now() + 48 * 3600 * 1000);
      await setNextPublishAfter(env, next.toISOString());
    }
  },
  // fetch()도 같이 export — 텔레그램 명령 webhook 처리용
};

하루 60번 깨어나지만 실제로 일하는 건 이틀에 한 번이다. 나머지는 KV 한 번 읽고 바로 return이라 부담이 거의 없었다. Cron Trigger가 "정확한 시각 실행"보다 "조건 검사 후 실행"의 트리거로 더 쓸모 있다는 걸 이 구조를 굴리면서 알았다.

형식 쪽에서 하나만 짚으면, 위처럼 ES Modules 형식에서는 export default 객체에 scheduled라는 이름으로 핸들러를 내보내야 한다. schedule로 오타 내면 크론이 발동해도 아무 일도 일어나지 않는다. 크론은 도는데 로직이 안 도는 것 같다면 export 이름부터 의심해라.

Cloudflare Workers Cron Trigger는 전부 UTC다

표현식에 적는 시각은 무조건 UTC 기준이다. 한국 시간으로 잡으려면 9시간을 빼야 한다.

나는 처음에 0 9 * * *로 설정해놓고 왜 한국 시간 오후 6시에 실행되는지 한참 고민했다. 당연히 UTC 9시는 KST 18시인데, 그걸 깨닫기까지 시간이 좀 걸렸다. 위 워커의 * 2 * * *에서 2가 UTC 02시라서 KST 11시대가 되는 것도 같은 이치다.

여기에 함정이 하나 더 있다. KST 새벽 시간대(00:00~08:59)는 UTC로는 전날이라서, 요일 조건까지 걸면 요일도 하루 밀린다. "매주 월요일 KST 06:00"을 원하면 0 21 * * 0(UTC 일요일 21시)이다. 0 21 * * 1로 쓰면 화요일 새벽에 돈다.

원하는 KST 시각 Cron 표현식 (UTC)
매일 KST 09:00 0 0 * * *
매일 KST 11시대 매분 * 2 * * *
매주 월요일 KST 06:00 0 21 * * 0

표현식이 의도대로 파싱되는지는 crontab.guru에서 확인할 수 있는데, 여기서도 해석은 UTC 기준으로 읽어야 한다. 필드 규칙 자체는 Cloudflare 공식 문서가 기준이다.

고쳤는데도 안 되면 배포부터 의심한다

로컬에서 고친 crons는 wrangler deploy를 다시 실행하기 전까지 엣지에 반영되지 않는다.

당연한 얘기 같지만, git push와 배포를 혼동하면 "분명히 고쳤는데 왜 그대로지"에 빠진다. 나는 push 자동 배포를 붙이려다 11번 연속 실패한 경험 이후로 배포를 항상 명시적으로 실행하고 출력까지 확인한다. 볼 곳은 두 군데다 — 출력에 schedule: 줄이 있는지, 그리고 Current Version ID가 이전 배포와 달라졌는지. 버전 ID가 그대로면 코드 변경이 반영되지 않은 거다.

크론 시각까지 기다릴 필요 없다 — __scheduled

wrangler dev가 띄우는 로컬 서버의 /__scheduled 엔드포인트를 curl로 치면 scheduled()를 즉시 트리거할 수 있다.

# 로컬 개발 서버 실행
npx wrangler dev

# 다른 터미널에서 scheduled 이벤트 트리거 (cron 파라미터는 URL 인코딩)
curl "http://localhost:8787/__scheduled?cron=*+2+*+*+*"

이걸 알기 전에는 배포해놓고 크론 시각이 올 때까지 멍하니 기다렸다. 이제 와 생각하면 시간 낭비가 심했다. 알고 난 뒤로는 크론 로직을 손댈 때마다 이걸로 먼저 확인하고 배포했다.

배포된 워커 쪽은 npx wrangler tail --format pretty로 실시간 로그를 볼 수 있다. 크론 발동 시점에 로그가 아예 안 찍히면 크론 자체가 발동되지 않은 것이고, 에러 로그가 찍히면 핸들러는 호출됐는데 내부 로직이 문제인 거다. 이 구분이 트러블슈팅의 절반이다.

제일 무서운 건 조용한 실패였다

scheduled()는 HTTP 요청과 달리 응답을 돌려줄 상대가 없다. 에러가 나도 아무도 모른 채 다음 크론 시각을 기다릴 뿐이다.

대시보드 Triggers 탭의 실행 히스토리에 Failure가 남긴 하지만, 이틀에 한 번 도는 작업의 실패를 대시보드를 열어봐야 안다는 건 사실상 모른다는 뜻이다. 그래서 발행 로직을 try-catch로 감싸고 실패하면 텔레그램으로 알림을 받았다. 은퇴한 워커의 실제 코드에서 그 부분만 추리면 이렇다.

// publishNext() 내부 — 실패를 텔레그램 알림으로 전환
try {
  const wpResponse = await publishToWordPress(env, post);
  await markPublished(env, post, wpResponse.id);
  // …발행 후 검증 생략…
  return { success: true, message: `Published: ${post.title}` };
} catch (error) {
  await markFailed(env, post);

  const errMsg = error instanceof Error ? error.message : String(error);
  const msg =
    `❌ 블로그 발행 실패\n\n` +
    `📝 ${post.title}\n` +
    `⚠️ ${errMsg}`;

  await sendTelegramMessage(env, msg);
  return { success: false, message: errMsg };
}

잡히지 않은 예외는 런타임이 삼키고 히스토리에 Error 상태만 남겨서 원인 파악이 어렵다. try-catch는 선택이 아니다. 크론을 한 워커에 여러 개 등록했다면 controller.cron을 로그에 넣어 어느 크론에서 터졌는지 구분하는 것도 방법이다. 알림 채널 자체가 죽는 경우까지 잡으려면 알림 호출도 따로 감싸야 하고.

은퇴시키던 날

사이트를 정적 스택으로 옮기고 나니 이 워커가 할 일이 없었다. 정적 사이트는 빌드하고 배포하는 순간이 곧 발행이라, 예약 발행이라는 개념 자체가 사라진다. 워드프레스로 굴리던 시절엔 로딩 속도부터 발행 스케줄까지 서버 쪽에서 챙길 게 많았는데, 옮기고 보니 크론이 있을 자리가 없더라.

그래서 2026년 7월 2일, 워커를 삭제했다. 매분 깨어나던 크론도 같이 은퇴했다. KV 네임스페이스만 과거 발행 기록 보존용으로 남겨뒀다 — 언젠가 이것도 지우겠지만, 아직은 두고 있다.

워커는 갔지만 버릇 셋은 남았다. 배포 출력에서 schedule: 줄 확인하기, 시각은 UTC로 변환해서 검산하기, 크론 로직을 고치면 __scheduled로 먼저 찔러보기. Cron Trigger가 안 돌 때 내게 필요했던 건 결국 이 셋이 거의 전부였다.


글쓴이 — khio · 10년차 백엔드 개발자. 직접 겪은 문제와 해결 과정을 기록한다.
Share this post on:

Previous Post
Mac Node.js 버전 충돌 — 빌드가 styleText에서 죽었고, 지우는 대신 공존을 택했다
Next Post
아이폰 HEIC 사진 왜 안 열릴까 — 회사 PC에서 겪은 뒤 정리한 3가지 대응