--- id: backend-cron-scheduler-patterns title: Cron / Scheduler — Quartz / cron / Inngest / Trigger.dev category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [backend, cron, scheduler, vibe-coding] tech_stack: { language: "TS / Python", applicable_to: ["Backend"] } applied_in: [] aliases: [cron, scheduler, Inngest, Trigger.dev, BullMQ, Sidekiq, Temporal cron, distributed cron] --- # Cron / Scheduler > "매 X 시간 작업 실행". **Cron 가 simple, BullMQ / Inngest / Trigger.dev / Temporal 가 modern**. Distributed lock + retry + observability. ## 📖 핵심 개념 - Cron expression: `0 * * * *`. - Single instance vs distributed. - Missed run 처리. - Retry / dedup / idempotency. ## 💻 코드 패턴 ### Linux cron (가장 simple) ```cron # crontab -e 0 2 * * * /usr/bin/node /app/cleanup.js >> /var/log/cleanup.log 2>&1 ``` → 매일 2 AM. 1 server 만. ### Cron expression ``` * * * * * | | | | | | | | | +-- Day of week (0-6, Sun=0) | | | +---- Month (1-12) | | +------ Day of month (1-31) | +-------- Hour (0-23) +---------- Minute (0-59) 0 * * * * 매 hour */5 * * * * 매 5 min 0 9 * * 1-5 평일 9 AM 0 0 1 * * 매월 1일 자정 ``` → crontab.guru 가 검증. ### Node-cron (in-process) ```ts import cron from 'node-cron'; cron.schedule('0 * * * *', async () => { await cleanupOldData(); }); ``` → Single instance only. Restart 시 잃음. ### BullMQ (Redis-backed) ```ts import { Queue, Worker } from 'bullmq'; const queue = new Queue('jobs', { connection: { host: 'redis' } }); // Repeating job await queue.add('cleanup', null, { repeat: { pattern: '0 * * * *' }, }); // Worker new Worker('jobs', async (job) => { if (job.name === 'cleanup') await cleanup(); }, { connection }); ``` → Multi-instance OK (Redis 가 lock). Persistent. ### Distributed cron 의 문제 ``` 2 instance × 같은 cron = 2번 실행. 해결: 1. Lock (Redis / DB). 2. Single leader. 3. Job queue. ``` ### Distributed lock (Redis) ```ts async function withLock(key: string, ttl: number, fn: () => Promise) { const got = await redis.set(`lock:${key}`, 'taken', 'EX', ttl, 'NX'); if (!got) return; // 다른 instance try { await fn(); } finally { await redis.del(`lock:${key}`); } } cron.schedule('0 * * * *', async () => { await withLock('cleanup', 3600, async () => { await cleanup(); }); }); ``` ### Inngest (modern, function-as-a-service) ```ts import { Inngest } from 'inngest'; const inngest = new Inngest({ name: 'My App' }); export const dailyReport = inngest.createFunction( { id: 'daily-report' }, { cron: '0 9 * * *' }, async ({ event, step }) => { const data = await step.run('fetch', () => fetchData()); await step.run('email', () => sendEmail(data)); } ); ``` → Step = retry + dedup automatic. ### Trigger.dev (alternative) ```ts import { client } from './trigger'; client.defineJob({ id: 'daily-report', trigger: cronTrigger({ cron: '0 9 * * *' }), run: async (payload, io) => { const data = await io.runTask('fetch', async () => fetchData()); await io.sendEvent('email', { data }); }, }); ``` ### Temporal (workflow engine) ```ts // Schedule import { Connection, ScheduleClient } from '@temporalio/client'; const client = new ScheduleClient({ connection }); await client.create({ scheduleId: 'daily-cleanup', spec: { intervals: [{ every: '1 day' }] }, action: { type: 'startWorkflow', workflowType: 'cleanupWorkflow', }, }); ``` → Cron + workflow + retry. ### Sidekiq (Ruby) ```ruby # Sidekiq scheduler Sidekiq.configure_server do |config| config.on(:startup) do Sidekiq.schedule = YAML.load_file('config/schedule.yml') Sidekiq::Scheduler.reload_schedule! end end ``` ```yaml # schedule.yml daily_report: cron: '0 9 * * *' class: ReportWorker ``` ### AWS EventBridge + Lambda ```yaml # Serverless functions: daily: handler: handler.daily events: - schedule: rate: cron(0 9 * * ? *) ``` → Managed cron. Lambda 친화. ### GCP Cloud Scheduler ```bash gcloud scheduler jobs create http daily-report \ --schedule "0 9 * * *" \ --uri https://api.example.com/cron/daily \ --http-method POST ``` → HTTP trigger. ### Cloudflare Workers Cron ```toml # wrangler.toml [triggers] crons = ["0 9 * * *", "*/5 * * * *"] ``` ```ts export default { async scheduled(event, env, ctx) { if (event.cron === '0 9 * * *') await dailyReport(); if (event.cron === '*/5 * * * *') await checkHealth(); }, }; ``` → Edge cron. ### Vercel Cron ```json // vercel.json { "crons": [ { "path": "/api/cron", "schedule": "0 9 * * *" } ] } ``` ```ts // app/api/cron/route.ts export async function GET(request) { const auth = request.headers.get('authorization'); if (auth !== `Bearer ${process.env.CRON_SECRET}`) { return new Response('Unauthorized', { status: 401 }); } await dailyReport(); return Response.json({ ok: true }); } ``` ### Idempotency ```ts async function cleanup() { // 매 run 가 idempotent — retry safe. await db.exec('DELETE FROM logs WHERE created_at < NOW() - INTERVAL 30 DAY'); } ``` → "다시 실행해도 OK". ### Missed run ``` Server down 1 hour → 1 cron 가 missed. 처리: 1. 무시 (간단). 2. Catch up (다음 run 가 이전 work 도). 3. Manual trigger. → Inngest / Temporal 가 missed-run 처리. ``` ### Backfill ```ts // 옛 날짜 cron 재실행 for (const date of getDateRange('2026-04-01', '2026-04-30')) { await dailyReport({ date }); } ``` ### Timezone ```cron 0 9 * * * # Server timezone (UTC). ``` ```ts // Asia/Seoul = UTC+9 // 9 AM KST = 0 AM UTC '0 0 * * *' in UTC = 9 AM KST. ``` → Inngest / Trigger.dev 가 timezone 옵션. ### Frequency limit ``` ✓ 매일, 매 시간: 일반. ✗ 매 분: high-volume — queue 더 좋음. ✗ 매 초: bad pattern. → "매 분 미만" = queue / streaming. ``` ### Job 의 visibility ``` - 매 run 의 status (running / done / failed) - Duration / retry count - Last successful run - Next scheduled → Inngest / Trigger.dev / Temporal UI. ``` ### DB 의 schedule (own) ```sql CREATE TABLE scheduled_jobs ( id TEXT PRIMARY KEY, cron TEXT, last_run TIMESTAMP, next_run TIMESTAMP, status TEXT ); -- 매 분 worker SELECT * FROM scheduled_jobs WHERE next_run <= NOW(); -- → 매 job 실행, last_run / next_run update. ``` → DIY 의 simple version. ### Retry policy ```ts // BullMQ queue.add('cleanup', null, { repeat: { pattern: '0 * * * *' }, attempts: 3, backoff: { type: 'exponential', delay: 1000 }, }); ``` ### Dependent cron ``` dailyReport 가 dailyClean 후. → Workflow (Temporal / Inngest) 가 좋음. 순서 + dependency + retry 가 자동. ``` ### Cron alert ``` - 매 run 의 metric (Prometheus). - "Last run > expected interval" alert. - Failure count threshold. → Datadog / Grafana. ``` ### Cost ``` Lambda + EventBridge: $ 작음 ($0.01 / month / cron). Inngest: $0 (1k step / month free). Trigger.dev: 비슷. Vercel: 무료 (Pro 가 1 cron job). Self-host: server cost. → Cron 가 비싼 보다 missing run 가 비싼. ``` ### When? ``` Simple (1 server): node-cron. Multi-instance: BullMQ / Redis lock. Modern serverless: Inngest / Trigger.dev / Vercel Cron. Workflow: Temporal. Cloud: AWS EventBridge / GCP Scheduler. Edge: Cloudflare Cron. ``` ### Real-world ``` Examples: - Daily report email (9 AM) - Weekly digest - Monthly billing - Cleanup old data (1 AM) - Health check (매 5 min) - Sync (매 hour) ``` ## 🤔 의사결정 기준 | 상황 | 추천 | |---|---| | 1 server | node-cron / Linux cron | | Multi-instance | BullMQ / Redis lock | | Modern serverless | Inngest / Trigger.dev | | Workflow | Temporal | | AWS | EventBridge + Lambda | | Vercel | Vercel Cron | | Edge | Cloudflare Cron | | 매우 frequent | Queue / streaming | ## ❌ 안티패턴 - **No lock (multi-instance)**: 중복. - **No idempotency**: retry = corrupt. - **No alert**: silent miss. - **매 1 sec cron**: queue 사용. - **Timezone confusion**: 9 AM ≠ 9 AM. - **No visibility**: blind. - **Missed run 무시**: data 잃음. ## 🤖 LLM 활용 힌트 - 단순 = node-cron / Linux. - Modern = Inngest / Trigger.dev (function-as-a-service). - Multi-instance = BullMQ + lock. - Workflow = Temporal. ## 🔗 관련 문서 - [[Backend_Cron_Patterns]] - [[Backend_Job_Queue_Patterns]] - [[Backend_Job_Scheduling_Temporal]]