Files
2nd/10_Wiki/Topics/Coding/Backend_Cron_Scheduler_Patterns.md
T
2026-05-10 22:08:15 +09:00

8.3 KiB
Raw Blame History

id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
id title category status source_trust_level verification_status created_at updated_at tags tech_stack applied_in aliases
backend-cron-scheduler-patterns Cron / Scheduler — Quartz / cron / Inngest / Trigger.dev Coding draft B conceptual 2026-05-09 2026-05-09
backend
cron
scheduler
vibe-coding
language applicable_to
TS / Python
Backend
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)

# 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)

import cron from 'node-cron';

cron.schedule('0 * * * *', async () => {
  await cleanupOldData();
});

→ Single instance only. Restart 시 잃음.

BullMQ (Redis-backed)

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)

async function withLock(key: string, ttl: number, fn: () => Promise<void>) {
  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)

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)

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)

// 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)

# 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
# schedule.yml
daily_report:
  cron: '0 9 * * *'
  class: ReportWorker

AWS EventBridge + Lambda

# Serverless
functions:
  daily:
    handler: handler.daily
    events:
      - schedule:
          rate: cron(0 9 * * ? *)

→ Managed cron. Lambda 친화.

GCP Cloud Scheduler

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

# wrangler.toml
[triggers]
crons = ["0 9 * * *", "*/5 * * * *"]
export default {
  async scheduled(event, env, ctx) {
    if (event.cron === '0 9 * * *') await dailyReport();
    if (event.cron === '*/5 * * * *') await checkHealth();
  },
};

→ Edge cron.

Vercel Cron

// vercel.json
{
  "crons": [
    { "path": "/api/cron", "schedule": "0 9 * * *" }
  ]
}
// 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

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

// 옛 날짜 cron 재실행
for (const date of getDateRange('2026-04-01', '2026-04-30')) {
  await dailyReport({ date });
}

Timezone

0 9 * * *
# Server timezone (UTC).
// 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)

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

// 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.

🔗 관련 문서