Cron Trigger가 예상대로 실행되지 않을 때, 순서대로 점검하면 대부분 원인을 찾을 수 있다. 이 글은 내가 실제로 Cloudflare Workers의 Cron Trigger를 운영하면서 겪었던 문제들을 체크리스트 형태로 정리한 것이다. wrangler.toml 설정 실수부터 UTC 타임존 혼동, scheduled() 핸들러 누락까지 — 한 항목씩 따라가다 보면 어디서 막혔는지 금방 파악할 수 있을 거다.
📑 목차
증상부터 확인하기
Cron Trigger 문제는 증상이 거의 비슷하다. 대시보드에서 Cron을 등록했는데 아무 일도 일어나지 않거나, wrangler tail을 켜놓고 기다려도 로그가 찍히지 않는 상황이 대표적이다. 처음 이 문제를 만났을 때 나는 Worker 코드 자체에 버그가 있는 줄 알고 한참을 헤맸는데, 실제로는 wrangler.toml에 crons 항목을 아예 빼먹은 거였다. 허무하더라.
증상을 크게 나누면 세 가지이다.
- Cron이 아예 실행되지 않음 — 대시보드 Triggers 탭의 Cron 히스토리가 비어 있고,
wrangler tail에도scheduled이벤트가 찍히지 않는다. - Cron이 실행은 되지만
scheduled()핸들러가 호출되지 않음 — 로그에 이벤트는 보이는데 실제 비즈니스 로직이 동작하지 않는다. export 문제일 가능성이 높다. - Cron이 예상한 시각과 다른 시각에 실행됨 — 가장 흔한 원인은 UTC와 로컬 타임존(KST) 혼동이다.
아래 체크리스트를 위에서부터 하나씩 확인해 보자. 순서가 중요하다. 설정이 맞아야 코드가 실행되고, 코드가 실행돼야 타임존을 논할 수 있으니까.
wrangler.toml [triggers] 설정 점검
가장 먼저 봐야 할 곳은 wrangler.toml이다. Cron Trigger는 이 파일의 [triggers] 섹션에 정의되며, 여기가 잘못되면 나머지는 의미가 없다.
# wrangler.toml — 올바른 예시
name = "my-cron-worker"
main = "src/index.ts"
compatibility_date = "2024-09-23"
[triggers]
crons = ["0 */6 * * *", "30 2 * * 1"]
자주 발생하는 실수를 정리하면 이렇다.
[triggers]를[trigger]로 오타 내는 경우 — Wrangler 3.x에서는 에러 없이 무시된다.wrangler deploy출력에Uploaded my-cron-worker만 나오고Configured cron triggers메시지가 없다면 섹션 이름이 잘못된 거다.- crons 값을 문자열 하나로 쓰는 경우 —
crons = "0 * * * *"는 틀리다. 반드시 배열 형태["0 * * * *"]로 작성해야 한다. - 6필드 cron 표현식을 쓰는 경우 — Cloudflare Workers는 표준 5필드(분 시 일 월 요일)만 지원한다. 초 단위 필드를 앞에 붙이면
Error: Invalid cron expression이 발생한다.
배포 후 대시보드의 Settings > Triggers 탭에서 등록된 Cron 스케줄이 보이는지 반드시 확인하자. 여기에 표시되지 않으면 Cron은 절대 실행되지 않는다.
scheduled() 핸들러 올바르게 내보내기
Cron Trigger가 발동하면 Cloudflare는 Worker의 scheduled() 핸들러를 호출한다. 이 핸들러가 올바르게 export 되지 않으면 이벤트가 도착해도 아무 일도 일어나지 않는다. 콘솔에 No scheduled handler라는 경고가 찍히는 경우가 이에 해당된다.
// src/index.ts — ES Modules 형식 (권장)
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
return new Response("OK");
},
async scheduled(
controller: ScheduledController,
env: Env,
ctx: ExecutionContext
): Promise<void> {
// controller.scheduledTime: 예약된 실행 시각 (밀리초 epoch)
// controller.cron: 매칭된 cron 표현식 문자열
console.log(`Cron fired: ${controller.cron} at ${new Date(controller.scheduledTime).toISOString()}`);
ctx.waitUntil(doMyTask(env));
},
};
async function doMyTask(env: Env) {
// 실제 작업 로직
const res = await fetch("https://api.example.com/data");
if (!res.ok) {
throw new Error(`API 호출 실패: ${res.status} ${res.statusText}`);
}
// ...
}
주의할 점이 몇 가지 있다.
- Service Worker 형식(
addEventListener)과 ES Modules 형식을 혼용하면 안 된다. Wrangler 3.80.0 이상에서main필드를 사용하면 ES Modules 형식으로 인식한다. Service Worker 형식이라면addEventListener("scheduled", handler)로 등록해야 한다. scheduled를schedule로 오타 내는 경우가 생각보다 많다. 자동완성을 믿지 말고 직접 확인하자.ctx.waitUntil()을 빠뜨리면 비동기 작업이 끝나기 전에 Worker가 종료될 수 있다. 특히 외부 API 호출이나 KV 쓰기 작업에서 데이터가 누락되는 원인이 된다.
UTC vs KST 타임존 혼동 바로잡기
이건 정말 많이 겪는 문제이다. Cloudflare Workers의 Cron 표현식은 무조건 UTC 기준이다. 한국 시간(KST)은 UTC+9이므로, 한국 시간 오전 9시에 실행하고 싶으면 UTC 0시, 즉 0 0 * * *로 설정해야 한다.
나는 처음에 0 9 * * *로 설정해놓고 왜 한국 시간 오후 6시에 실행되는지 한참 고민했다. 당연히 UTC 9시는 KST 18시인데, 그때는 그걸 깨닫기까지 시간이 좀 걸렸다.
자주 쓰는 시간대 변환표를 정리해 두겠다.
| 원하는 KST 시각 | UTC 시각 | Cron 표현식 |
|---|---|---|
| 매일 KST 06:00 | UTC 21:00 (전날) | 0 21 * * * |
| 매일 KST 09:00 | UTC 00:00 | 0 0 * * * |
| 매일 KST 12:00 | UTC 03:00 | 0 3 * * * |
| 매일 KST 18:00 | UTC 09:00 | 0 9 * * * |
| 매일 KST 00:00 (자정) | UTC 15:00 | 0 15 * * * |
| 평일 KST 09:00 | UTC 00:00 (월~금) | 0 0 * * 1-5 |
주의할 점이 하나 더 있다. KST 기준 새벽 시간대(00:00~08:59)를 노리는 Cron은 UTC 기준으로 전날 15:00~23:59에 해당한다. 요일 조건이 포함된 Cron에서 이 부분을 놓치면 엉뚱한 요일에 실행될 수 있다. 예를 들어 “매주 월요일 KST 06:00″을 원한다면 0 21 * * 0(UTC 일요일 21시)으로 설정해야 한다. 0 21 * * 1로 쓰면 화요일 새벽에 실행된다.
wrangler deploy로 변경사항 반영했는지 확인
wrangler.toml에서 crons를 수정하거나 scheduled() 핸들러를 고친 뒤에는 반드시 wrangler deploy를 다시 실행해야 한다. 로컬에서 아무리 코드를 바꿔도 배포하지 않으면 Cloudflare 엣지에는 이전 버전이 돌아가고 있다.
배포 후에 확인해야 할 출력 메시지가 있다.
$ npx wrangler deploy
⛅️ wrangler 3.80.4
---
Total Upload: 12.34 KiB / gzip: 3.21 KiB
Worker Startup Time: 2 ms
Uploaded my-cron-worker (1.42 sec)
Deployed my-cron-worker triggers (0.23 sec)
https://my-cron-worker.username.workers.dev
schedule: 0 */6 * * *
schedule: 30 2 * * 1
Current Version ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890
schedule: 줄이 출력에 포함되어 있는지 확인하자. 이 줄이 보이지 않으면 [triggers] 섹션 설정에 문제가 있는 거다. 또한 Current Version ID가 이전과 달라졌는지도 체크 포인트이다. 같은 ID라면 코드 변경이 실제로 반영되지 않은 것이다.
Free Plan 제한사항
Cloudflare Workers Free Plan에서 Cron Trigger를 사용할 때 알아둬야 할 제약이 있다.
- 최대 Cron 개수: Worker당 최대 3개까지 등록할 수 있다. 4개 이상 넣으면
wrangler deploy시Error: A]free account is limited to 3 cron triggers per Worker [code: 10102]에러가 발생한다. - 최소 실행 간격: Free Plan에서는 1분 간격(
* * * * *)도 설정 자체는 가능하지만, Cloudflare가 내부적으로 실행을 건너뛸 수 있다. 안정적으로 동작하는 최소 간격은 5분(*/5 * * * *)이라고 보면 된다. - CPU 시간 제한: Free Plan은 요청당 10ms CPU 시간 제한이 있다. Cron에서 호출한
scheduled()도 이 제한을 받는다. 복잡한 연산이나 다수의 외부 API 호출이 포함된 작업이라면 Paid Plan(30초 CPU 시간)을 고려해야 한다. - 실행 보장: Cron Trigger는 “최소 1회 실행(at-least-once)”을 보장하지만, 정확한 초 단위 실행 시각은 보장하지 않는다. 지정 시각 전후 수 초 정도의 오차는 정상이다.
디버깅: wrangler tail, curl 테스트, 대시보드 확인
설정을 다 점검했는데도 동작하지 않을 때, 아래 세 가지 방법으로 직접 확인할 수 있다.
wrangler tail로 실시간 로그 확인
wrangler tail은 배포된 Worker의 로그를 실시간으로 스트리밍한다. Cron이 발동되는 시점에 맞춰 켜두면 scheduled() 핸들러 내부의 console.log 출력을 바로 볼 수 있다.
# 실시간 로그 모니터링
$ npx wrangler tail --format pretty
# 출력 예시 (Cron 실행 시)
GET https://my-cron-worker.username.workers.dev/ - Ok @ 2026-04-02T00:00:01Z
(log) Cron fired: 0 0 * * * at 2026-04-02T00:00:00.000Z
(log) Task completed successfully
# 에러 발생 시
(error) API 호출 실패: 503 Service Unavailable
로그가 아예 안 찍히면 Cron 자체가 발동되지 않은 것이고, (error)가 찍히면 핸들러는 호출되었으나 내부 로직에 문제가 있는 것이다. 이 구분이 트러블슈팅의 핵심이다.
curl로 __scheduled 엔드포인트 테스트
Cron이 실행될 때까지 기다리기 힘들다면, 로컬에서 직접 scheduled() 핸들러를 트리거할 수 있다. Wrangler의 로컬 개발 서버는 /__scheduled 엔드포인트를 제공한다.
# 먼저 로컬 개발 서버 실행
$ npx wrangler dev
# 다른 터미널에서 scheduled 이벤트 트리거
$ curl "http://localhost:8787/__scheduled?cron=0+0+*+*+*"
# 응답 예시
# 성공: 빈 응답 (204 No Content) 또는 "Ran scheduled event"
# 실패: 에러 메시지 출력
이 방법은 실제 Cron 발동 없이도 핸들러 로직을 빠르게 검증할 수 있어서 개발 단계에서 굉장히 유용하다. 나는 새로운 Cron 작업을 추가할 때마다 이 방법으로 먼저 테스트하고, 확인이 끝난 뒤에 배포하는 습관을 들였다. 그 전에는 배포하고 Cron 시각이 올 때까지 멍하니 기다렸는데, 이제 와 생각하면 시간 낭비가 심했다.
대시보드 Cron 히스토리 확인
Cloudflare 대시보드에서 Workers & Pages > 해당 Worker > Triggers 탭으로 이동하면 Cron 실행 히스토리를 볼 수 있다. 각 실행의 상태(Success/Failure), 실행 시각, 실행 시간(duration)이 표시된다. wrangler tail을 놓쳤을 때 여기서 과거 실행 기록을 확인할 수 있다.
히스토리가 완전히 비어 있다면 Cron 등록 자체가 안 된 상태이니 wrangler.toml 설정부터 다시 점검해야 한다.
scheduled() 내부 에러 핸들링 패턴
Cron Trigger에서 실행되는 scheduled() 핸들러는 HTTP 요청과 달리 응답을 반환할 대상이 없다. 에러가 발생하면 조용히 실패하고, 다음 Cron 시각에 다시 시도하는 게 전부이다. 그래서 에러 핸들링을 직접 구현해야 한다.
export default {
async scheduled(
controller: ScheduledController,
env: Env,
ctx: ExecutionContext
): Promise<void> {
ctx.waitUntil(
(async () => {
try {
const result = await doMyTask(env);
console.log(`[${controller.cron}] 작업 완료:`, result);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
console.error(`[${controller.cron}] 작업 실패: ${errorMessage}`);
// 실패 알림 (Slack, Discord 등)
await fetch(env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `Cron 작업 실패 (${controller.cron}): ${errorMessage}`,
}),
}).catch((e) =>
console.error("알림 발송도 실패:", e)
);
}
})()
);
},
};
몇 가지 팁을 덧붙이다.
- try-catch를 반드시 감싸자. 잡히지 않은 예외(uncaught exception)는 Worker 런타임이 삼켜버리고, 대시보드에 “Error” 상태만 남긴다. 원인 파악이 어려워진다.
- 알림 전송 실패에도 대비하자. 위 코드처럼 알림 fetch 자체도
.catch()로 감싸야 알림 서버 장애가 Worker 에러로 이어지지 않는다. controller.cron을 로그에 포함하자. 하나의 Worker에 여러 Cron이 등록되어 있을 때, 어떤 Cron에서 에러가 난 건지 구분할 수 있다.- 실행 시간을 측정하자.
Date.now()로 시작/종료 시각을 찍어두면 CPU 시간 제한에 근접하는지 모니터링할 수 있다. Free Plan의 10ms 제한은 생각보다 빡빡하다.
자주 쓰는 Cron 표현식 모음
매번 cron 문법을 찾아보기 귀찮아서 정리해 둔다. 모두 UTC 기준이니 KST로 변환할 때는 위의 변환표를 참고하자.
| 표현식 | 의미 |
|---|---|
* * * * * |
매분 실행 |
*/5 * * * * |
5분마다 실행 |
0 * * * * |
매시 정각 실행 |
0 */6 * * * |
6시간마다 실행 (0시, 6시, 12시, 18시 UTC) |
0 0 * * * |
매일 UTC 00:00 (KST 09:00) |
0 0 * * 1-5 |
평일(월~금) UTC 00:00 |
0 0 1 * * |
매월 1일 UTC 00:00 |
30 14 * * 0 |
매주 일요일 UTC 14:30 (KST 23:30) |
Cron 표현식이 의도대로 파싱되는지 확인하고 싶다면 crontab.guru 같은 온라인 도구를 활용하자. 다만 이 도구들은 로컬 타임존 기준이 아니라 UTC 기준으로 해석된다는 점을 잊으면 안 된다.
마무리
정리하면, Cron Trigger가 작동하지 않을 때 점검 순서는 이렇다.
wrangler.toml의[triggers]섹션과crons배열 문법 확인scheduled()핸들러가 올바른 형식으로 export 되었는지 확인- Cron 표현식이 UTC 기준인지 확인 (KST와 9시간 차이)
wrangler deploy실행 후 출력에서schedule:줄 확인- Free Plan 제한(Cron 3개, CPU 10ms) 해당 여부 확인
wrangler tail또는curl /__scheduled로 실제 동작 검증
대부분의 문제는 1~3번에서 잡힌다. 특히 UTC 타임존 혼동은 한 번 겪으면 절대 잊지 않는데, 처음 겪기 전에 이 글을 봤다면 한 단계 건너뛴 셈이니 다행이다. Cron이 제대로 돌기 시작하면 그때부터 진짜 재미있는 자동화를 구축할 수 있으니, 설정에서 막히지 않길 바란다.