---
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<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)
```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]]
