7단계
데이터 파이프라인 — 재시도 · 멱등
25 분
데이터 파이프라인 — 재시도 · 멱등
외부 API 호출 · 배치 · 큐 소비. 네트워크 · 장애 · 중복 메시지는 일상. 멱등 (idempotent) 으로 짜면 재시도가 안전합니다.
1. Exactly-once 의 환상
분산 시스템에서 exactly-once 는 원칙적으로 불가능. at-least-once + 멱등 소비자가 실제 해법.
Producer → Kafka → Consumer
(at-least-once 전달)
↓
(멱등 처리로 중복 흡수)
2. 자연 키 멱등
CREATE TABLE payments (
id BIGSERIAL PRIMARY KEY,
idempotency_key TEXT NOT NULL UNIQUE, -- 클라이언트 또는 producer 가 발급
amount DECIMAL NOT NULL,
status VARCHAR NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
INSERT INTO payments (idempotency_key, amount, status)
VALUES ($1, $2, 'pending')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING *;
UNIQUE + ON CONFLICT = 두 번 실행해도 한 번만 기록.
3. 상태 전이 멱등
UPDATE orders SET status = 'paid', paid_at = now()
WHERE id = $1 AND status = 'pending';
이미 paid 면 0 rows 업데이트. 재실행 안전.
4. 재시도 패턴
async function withRetry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
for (let i = 0; i < max; i++) {
try { return await fn(); }
catch (e) {
if (i === max - 1) throw e;
const wait = 2 ** i * 1000 + Math.random() * 500; // exp + jitter
await new Promise(r => setTimeout(r, wait));
}
}
throw new Error("unreachable");
}
- exponential backoff — 1s · 2s · 4s
- jitter — 동시 다량 재시도로 재난 방지
5. Circuit Breaker
연속 실패 시 잠시 차단 → 서비스 회복 시간 확보.
let consecutiveFailures = 0;
let openedAt: number | null = null;
const THRESHOLD = 5, COOLDOWN_MS = 30_000;
async function call<T>(fn: () => Promise<T>): Promise<T> {
if (openedAt && Date.now() - openedAt < COOLDOWN_MS) {
throw new Error("circuit_open");
}
try {
const r = await fn();
consecutiveFailures = 0; openedAt = null;
return r;
} catch (e) {
consecutiveFailures++;
if (consecutiveFailures >= THRESHOLD) openedAt = Date.now();
throw e;
}
}
6. Outbox 패턴
트랜잭션 커밋과 이벤트 발행을 원자적으로.
BEGIN;
UPDATE users SET verified = true WHERE id = $1;
INSERT INTO outbox_events (type, payload)
VALUES ('user.verified', jsonb_build_object('userId', $1));
COMMIT;
별도 워커가 outbox_events 를 폴링 → Kafka 발행 → 성공 시 DELETE.
DB 쓰기와 이벤트 발행이 같은 트랜잭션. "DB 는 커밋됐는데 이벤트 발행 실패" 회피.
7. Saga 패턴
여러 서비스 트랜잭션. 한 단계 실패 시 앞 단계 보상.
주문 접수 → 재고 차감 → 결제 → 배송
↓ 실패
재고 복구 (보상)
각 단계의 보상 트랜잭션 미리 설계. 단순한 2-3 단계는 saga 오버엔지니어링.
8. dead-letter queue
3 번 재시도 실패 메시지를 DLQ 로 이동. 운영자 수동 확인.
original-topic → retry-topic → dead-letter-topic
DLQ 에 쌓이면 알림 · 조사.
9. 배치 처리 멱등
# 배치 시작 전 체크포인트
last_processed = db.fetchval("SELECT MAX(id) FROM events WHERE processed")
rows = db.fetch("SELECT * FROM events WHERE id > $1 ORDER BY id LIMIT 1000", last_processed)
for row in rows:
process(row)
db.execute("UPDATE events SET processed = true WHERE id = $1", row.id)
중간 실패 후 재실행해도 last_processed 부터.
10. 자주 걸리는 자리
- idempotency_key 없음 — 중복 과금 · 중복 알림
- backoff 없는 재시도 — thundering herd
- 트랜잭션 밖 이벤트 발행 — DB 롤백 후에도 이벤트 나감
- at-most-once 전제 — producer 실패 시 이벤트 누락
11. 관찰
- 재시도 횟수 · 성공률 대시보드
- DLQ 사이즈 알람
- p95 · p99 latency
숫자 없이 튜닝 안 됨.
하고픈 말
"재시도 가능한 코드 = 멱등한 코드" 라는 규칙만 새기고 설계. 대부분의 분산 시스템 문제가 이 규칙 하나로 해결됩니다.
Next
- 08-backup-restore