docs(10_Wiki): 위키 전체 재구성 — Topic_* 폴더를 4개 카테고리로 통합 + 대규모 중복 제거
Topic_Agent/Topic_Blog/Topics/Topics_Biz/Topics_Meeting/Topics_Rag의 마크다운 지식 문서를 Topic_General/Topic_Programming/Topic_Graphic/Topic_Business 4개 카테고리로 재분류. - 중복 제거: frontmatter의 status:duplicate/merged + duplicate_of/redirect_to 필드로 자기 자신을 중복으로 선언한 리다이렉트 stub 1032개 제거, 완전 동일 내용 파일 472개 제거, 동일 파일명·다른 내용 충돌 시 더 큰(완전한) 버전만 유지(162개 제거) — 총 1639개 중복 제거. - 분류: 폴더 단위로 명확한 항목(AI_and_ML/Coding/Architecture 등 → Programming, Comfyui/Visual_Effects → Graphic, Topics_Biz/Topics_Meeting/사업 등 → Business, Poetic_Blog_Writing/창의성/Game_Design 등 → General)은 폴더 우선순위로, 나머지 혼재 폴더(Topic_Agent/Topic_Blog/Topics 루트/Thinking & Reasoning/Other/UI_UX_Assets)는 title/tags 키워드 스코어링으로 파일 단위 분류(불명확한 경우 General로 폴백). 원본 폴더명은 "From_*" 서브폴더로 보존해 추적 가능성 유지. - 최종 배치: Programming 2784 / General 1608 / Graphic 285 / Business 249 = 4926개 문서. - 에이전트 운영 상태(.astra/.agent/.obsidian/sessions/memory/_company/docs/lessons/_shared/src)는 지식 콘텐츠가 아니므로 재분류 대상에서 제외하고 원위치 유지. - Topics/Topic_email(상위 보호 폴더 Topic_email과 파일명 100% 중복) 삭제 — 보호 폴더 자체는 미변경. - 완전히 비게 된 Topic_Agent/Topic_Blog/Topics_Biz/Topics_Rag 폴더 제거.
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
---
|
||||
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<void>) {
|
||||
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]]
|
||||
Reference in New Issue
Block a user