Cloudflare Cron Triggers로 정기 작업 스케줄링하기

Cloudflare Cron Triggers로 정기 작업 스케줄링하기

매일 새벽에 낡은 데이터를 지우거나, 30분마다 외부 API에서 시세를 긁어오거나, 아침마다 요약 리포트를 보내는 일. 이런 정기 작업은 흔하지만, 이거 하나 돌리자고 서버나 VM을 24시간 켜두는 건 아깝죠. crontab을 걸어둘 상시 머신이 필요하고, 그 머신은 대부분의 시간을 놀면서 요금만 축내니까요.

Cloudflare WorkersCron Triggers는 이 문제를 관리형으로 풉니다. 정해진 시각이 되면 Cloudflare가 알아서 우리 코드를 깨워주고, 실행이 끝나면 다시 잠들어요. 상시 켜둘 서버도 cron 데몬도 없이 “이 표현식대로 이 함수를 돌려줘”라고 선언만 하면 됩니다. 이번 글에서는 scheduled 핸들러를 만들고, cron 표현식을 읽고, 로컬에서 테스트해 배포하는 데까지 해본 뒤, 마지막으로 Cloudflare의 다른 스케줄링 수단과 언제 무엇을 쓸지 정리해 보겠습니다.

Cron Triggers가 푸는 문제

전통적인 cron은 상시 실행되는 머신을 전제로 합니다. 리눅스 서버에 crontab을 걸어두면 그 서버의 cron 데몬이 시각을 지켜보다가 명령을 실행하죠. 문제는 그 서버가 하루 종일 켜져 있어야 한다는 점이에요. 하루에 몇 분 도는 작업 때문에 24시간짜리 요금을 내고, 그 서버가 죽으면 작업도 조용히 멈춥니다.

Cron Triggers는 서버리스라 이 전제를 뒤집습니다. 실행되지 않을 땐 아무 비용이 들지 않고, Cloudflare의 놀고 있는 엣지 인프라 위에서 정해진 시각에만 깨어나요. 깃 푸시로 배포되고, 죽어서 오프라인이 될 서버 자체가 없으니 “cron 머신이 다운돼서 작업이 안 돌았다” 같은 사고도 사라집니다.

핵심 차이가 하나 있어요. 우리가 흔히 쓰는 fetch 핸들러는 사용자의 HTTP 요청에 반응하지만, scheduled 핸들러는 HTTP 요청 없이 시간이 트리거합니다. 아무도 URL을 부르지 않아도, 정해진 시각이 되면 Cloudflare가 직접 우리 워커를 실행해 주는 거죠.

scheduled 핸들러 만들기

이미 워커 프로젝트가 있다면(없다면 Cloudflare Workers 시작하기를 먼저 보세요), 거기에 scheduled 핸들러를 얹으면 됩니다. fetch와 나란히 export default에 넣어요. 예를 들어 매일 오래된 세션을 지우는 작업이라면 이렇게 생겼습니다.

src/index.js
export default {
  async scheduled(controller, env, ctx) {
    console.log(`${controller.cron} 트리거`);
    ctx.waitUntil(cleanupOldSessions(env));
  },
};

async function cleanupOldSessions(env) {
  const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; // 30일 전
  const { meta } = await env.DB.prepare(
    "DELETE FROM sessions WHERE created_at < ?",
  )
    .bind(cutoff)
    .run();
  console.log(`오래된 세션 ${meta.changes}개 삭제`);
}

세 인자를 눈여겨보세요. controller는 이 실행에 대한 정보를 담고 있어서, 어떤 cron이 트리거했는지(controller.cron)와 예정 시각(controller.scheduledTime)을 알 수 있습니다. env로는 D1이나 KV 같은 바인딩에 접근하고요. 마지막 ctxwaitUntil이 특히 중요한데, 무거운 작업을 여기 감싸면 핸들러가 반환된 뒤에도 그 작업이 끝까지 실행됩니다. 이걸 빼먹으면 scheduled가 반환되는 순간 워커가 종료되면서 뒷작업이 잘릴 수 있어요.

언제 실행할지는 코드가 아니라 wrangler.jsonc에 선언합니다.

wrangler.jsonc
{
  "name": "my-scheduler",
  "main": "src/index.js",
  "compatibility_date": "2026-07-02",
  "triggers": {
    "crons": ["0 0 * * *"],
  },
}

triggers.crons는 cron 표현식의 배열이에요. 여러 개를 넣으면 표현식마다 따로 스케줄이 걸리고, 어느 스케줄이 트리거했는지는 앞서 본 controller.cron으로 구분합니다. 코드는 그대로 두고 이 배열만 바꾸면 실행 주기가 바뀌는 거죠.

cron 표현식 읽는 법

cron 표현식은 공백으로 구분된 다섯 자리입니다. 왼쪽부터 분, 시, 일, 월, 요일 순서예요.

┌───── 분 (0-59)
│ ┌─── 시 (0-23)
│ │ ┌─ 일 (1-31)
│ │ │ ┌ 월 (1-12)
│ │ │ │ ┌ 요일 (일-토)
* * * * *

*는 “매번”, */n은 “n마다”, -는 범위, ,는 나열입니다. 자주 쓰는 패턴을 정리하면 이렇습니다.

  • * * * * * — 매 분
  • */30 * * * * — 30분마다
  • 0 * * * * — 매 정시(0분)
  • 0 0 * * * — 매일 자정
  • 10 7 * * mon-fri — 평일 07:10
  • 0 15 1 * * — 매월 1일 15:00

여기서 가장 자주 발목 잡히는 함정이 시간대예요. Cloudflare의 cron은 전부 UTC 기준으로 돕니다. 한국(KST)은 UTC보다 9시간 빠르니, “매일 아침 9시(KST)에 돌리고 싶다”면 UTC로는 자정이라 0 0 * * *으로 적어야 합니다. 무심코 0 9 * * *로 적으면 한국 시각으로는 오후 6시에 돌아요. 로컬 시각으로 착각하기 딱 좋으니 배포 전에 꼭 UTC로 환산하세요.

로컬에서 테스트하기

스케줄이 실제로 돌 때까지 기다렸다가 디버깅할 순 없죠. 다행히 wrangler devscheduled 핸들러를 수동으로 찔러볼 수 있는 특수 경로를 열어줍니다.

bunx wrangler dev

개발 서버가 뜨면, curl로 /cdn-cgi/handler/scheduled를 호출해 스케줄 실행을 즉시 흉내 낼 수 있어요.

# scheduled 핸들러를 지금 바로 한 번 실행
curl "http://localhost:8787/cdn-cgi/handler/scheduled"

# 특정 cron이 트리거한 것처럼 시뮬레이션
curl "http://localhost:8787/cdn-cgi/handler/scheduled?cron=*+*+*+*+*"

cron 파라미터로 어떤 스케줄이 트리거했는지, time 파라미터로 예정 시각까지 지정할 수 있어서, controller.cron이나 controller.scheduledTime으로 분기하는 로직도 그대로 검증됩니다. 실제 시각이 될 때까지 기다릴 필요가 없는 거죠.

배포하면 무슨 일이 일어날까

배포는 여느 워커와 똑같습니다.

bunx wrangler deploy

한 가지 알아둘 동작이 있어요. 배포하면 triggers.crons 배열이 기존 스케줄을 통째로 대체합니다. 그래서 배열에서 뺀 cron은 사라지고, 빈 배열("crons": [])로 배포하면 모든 스케줄이 제거돼요. 반대로 설정에서 triggers를 아예 생략하면 기존 스케줄은 그대로 유지됩니다. 스케줄을 코드처럼 선언적으로 관리하는 셈이라, 지금 무엇이 걸려 있는지 궁금하면 설정 파일만 보면 됩니다.

개수 제한도 짚고 넘어갈게요. 예전에는 워커 하나당 cron이 무료 3개, 유료 5개로 제한됐는데, 2026년에 워커당 제한이 없어졌습니다. 대신 계정 단위 제한이 적용돼서, 계정 전체를 통틀어 무료는 5개, 유료는 250개까지 걸 수 있어요. 오래된 튜토리얼에서 “워커당 3개”라는 설명을 보더라도 지금은 다르다는 걸 기억하세요.

실패하면 재시도, 관찰은 대시보드에서

정기 작업은 사람이 지켜보지 않는 새벽에 도는 일이 많아서, 실패했을 때의 동작과 관찰 수단을 미리 챙겨두는 게 좋아요. scheduled 실행이 예외로 실패하면 Cloudflare가 기본적으로 자동 재시도합니다. 그런데 중복 실행이 위험한 작업(예를 들어 결제나 알림 발송)이라면 재시도가 오히려 사고를 부르니, 이럴 땐 controller.noRetry()를 호출해 재시도를 꺼둡니다.

async scheduled(controller, env, ctx) {
  try {
    ctx.waitUntil(chargeSubscriptions(env));
  } catch (err) {
    controller.noRetry(); // 중복 청구를 막으려면 재시도하지 않습니다
    throw err;
  }
}

스케줄이 실제로 언제 돌았고 성공했는지는 Cloudflare 대시보드의 워커 상세 화면에서 Cron 이벤트 기록으로 확인할 수 있습니다. 개발 중이나 배포 직후에 실행 로그를 실시간으로 보고 싶다면 bunx wrangler tail을 띄워두면, scheduled 안에서 남긴 console.log가 그대로 흘러나와요. “새벽에 조용히 안 돌고 있었다”는 최악의 상황을 막으려면, 처음 며칠은 이 두 가지로 실제 실행을 눈으로 확인해 두는 걸 권합니다.

언제 Cron Triggers 대신 다른 걸 쓸까

Cron Triggers는 “정해진 시각에 도는 정적 스케줄”에 딱 맞지만, Cloudflare에는 성격이 다른 스케줄링 수단이 몇 개 더 있습니다. 재미있게도 공식 문서는 이걸 한자리에 모아 비교해 주지 않아서, 문제 모양에 따라 무엇을 고를지 헷갈리기 쉬운데요. 표로 만들면 이렇습니다.

수단언제 쓰나
Cron Triggers배포 때 이미 정해진 고정 스케줄 (야간 정리, 정기 동기화)
Durable Object Alarms런타임에 정하는 동적 예약, 객체마다 따로 걸리는 타이머
Queues시간이 아니라 물량 기준의 비동기 작업 처리
Workflows재시도와 상태 저장이 필요한 다단계 흐름

조금 풀어 볼게요. Durable Object Alarms는 “이 주문을 30분 뒤에 처리”처럼 예약 시각이 실행 중에 결정되거나, 객체별로 독립된 타이머가 필요할 때 씁니다. 객체 수만큼 알람을 걸 수 있어 큐나 배칭을 직접 구현하는 토대도 되고요. Queues는 예약이라기보다 처리 파이프라인에 가까워서, 대량의 비동기 작업을 CPU 시간 제한 없이 천천히 소비하고 싶을 때 어울립니다. Workflows는 “큐 하나, cron 하나, 데이터베이스 폴링”을 조합해야 했던 긴 흐름을 하나의 durable한 워크플로로 묶어, 각 단계가 실패하면 알아서 재시도하고 상태를 이어갑니다.

한 줄로 정리하면, 배포 때 시각이 정해지면 Cron Triggers, 런타임에 정해지면 Alarms, 시간 대신 물량이면 Queues, 여러 단계를 신뢰성 있게 이으려면 Workflows입니다.

마치며

Cloudflare Cron Triggers로 상시 서버 없이 정기 작업을 돌리는 법을 살펴봤습니다. scheduled 핸들러에 로직을 담고, wrangler.jsonc에 cron 표현식을 선언하고, /cdn-cgi/handler/scheduled로 로컬에서 확인해 배포하면 끝이에요. UTC 시간대와 waitUntil, 그리고 배포가 스케줄을 통째로 대체한다는 점만 기억하면 발목 잡힐 일이 거의 없습니다. 정해진 시각을 넘어 동적 예약이나 다단계 흐름이 필요해지면 Alarms와 Queues, Workflows로 자연스럽게 넘어가면 되고요.

더 자세한 내용은 Cloudflare Cron Triggers 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord