--- id: backend-cron-patterns title: Cron / Scheduled Jobs — 분산 / 멱등 / 락 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, idempotency, vibe-coding] tech_stack: { language: "TS / SQL / Redis", applicable_to: ["Backend"] } applied_in: [] aliases: [scheduled job, periodic, cron expression, distributed lock, leader election] --- # Cron / Scheduled Jobs > 분산 환경 cron = **leader 락 + 멱등** 안 하면 N대 서버에 N번 실행. **DB row lock / Redis lock / Kubernetes CronJob / 클라우드 매니지드 스케줄러** 중 1개. 매 실행 멱등. ## 📖 핵심 개념 - Single instance: 한 시점에 한 곳만 실행. - Idempotent: 두 번 실행돼도 결과 동일. - Catch-up: 시간 지나가면 늦게라도 실행할지 / skip 할지. - At-least-once: 보통 OK, 단 멱등 보장 필요. ## 💻 코드 패턴 ### node-cron / BullMQ repeat ```ts import { Queue } from 'bullmq'; const q = new Queue('reports'); await q.add('daily', {}, { repeat: { pattern: '0 9 * * *', tz: 'America/New_York' }, // 매일 9시 NY jobId: 'daily-report', // 같은 jobId = 중복 등록 방지 }); ``` ### DB-based lock ```sql -- locks 테이블 CREATE TABLE job_locks ( name TEXT PRIMARY KEY, locked_by TEXT, locked_at TIMESTAMPTZ, expires_at TIMESTAMPTZ ); -- 시도 INSERT INTO job_locks (name, locked_by, locked_at, expires_at) VALUES ('daily-report', $hostname, NOW(), NOW() + INTERVAL '10 minutes') ON CONFLICT (name) DO UPDATE SET locked_by = $hostname, locked_at = NOW(), expires_at = NOW() + INTERVAL '10 minutes' WHERE job_locks.expires_at < NOW() RETURNING *; ``` ```ts async function runWithLock(name: string, fn: () => Promise) { const got = await db.tryLock(name, hostname()); if (!got) return; // 다른 노드가 잡음 try { await fn(); } finally { await db.releaseLock(name); } } ``` ### Redis-based lock (Redlock) ```ts import Redlock from 'redlock'; const lock = await redlock.acquire(['locks:daily-report'], 60_000); try { await runDaily(); } finally { await lock.release(); } ``` ### Idempotency ```ts async function dailyReport(date: string) { const id = `report:${date}`; if (await db.exists(id)) return; // 이미 만들어짐 const r = await build(date); await db.put(id, r); } ``` ### Catch-up ```ts // 마지막 실행 시간 저장 → 차이만큼 반복 const last = await db.get('cursor:reports'); for (let d = nextDay(last); d <= today(); d = nextDay(d)) { await dailyReport(d); await db.put('cursor:reports', d); } ``` ### Kubernetes CronJob ```yaml apiVersion: batch/v1 kind: CronJob metadata: name: daily-report spec: schedule: "0 9 * * *" concurrencyPolicy: Forbid # 이전 job 안 끝났으면 새거 skip successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 5 startingDeadlineSeconds: 600 # 10분 안에 시작 안되면 skip jobTemplate: spec: backoffLimit: 2 template: spec: containers: - name: report image: app:latest args: ["node", "dist/jobs/daily-report.js"] restartPolicy: OnFailure ``` ### Cron expression (UTC vs local) ``` 0 9 * * * # 매일 9:00 UTC (서버 타임존이 UTC면) 0 9 * * 1-5 # 평일 9시 */15 * * * * # 15분마다 0 0 1 * * # 매월 1일 ``` ## 🤔 의사결정 기준 | 환경 | 추천 | |---|---| | Vercel / Cloudflare | Vercel Cron / CF Cron | | AWS | EventBridge → Lambda | | K8s | CronJob + concurrencyPolicy: Forbid | | 단일 Node | node-cron + DB lock | | 큐 기반 | BullMQ repeat / Sidekiq | | 정확한 시간 보장 X | drift 감수 또는 클라우드 | ## ❌ 안티패턴 - **N대 서버에 cron 동시 실행**: 단일 락 또는 leader 만. - **Idempotency 미보장**: 재실행 시 데이터 망가짐. - **타임존 confusion**: UTC 명시 또는 cron expression 에 tz. - **Long-running job 크론에서**: 다음 실행 충돌. 큐로 보내기. - **Catch-up 무한**: 24시간 정지 후 돌아오면 1440번 실행. - **Job 결과 남기지 않음**: 실패 추적 불가. - **At-most-once 가정**: 분산 = 항상 at-least-once. ## 🤖 LLM 활용 힌트 - 항상 멱등 + 락 + 결과 기록. - K8s CronJob = concurrencyPolicy: Forbid. - 시간 = UTC 또는 명시 tz. ## 🔗 관련 문서 - [[Backend_Job_Queue_Patterns]]