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

388 lines
9.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: backend-idempotency-deep
title: Idempotency Deep — keys / windows / storage
category: Coding
status: draft
source_trust_level: B
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
tags: [backend, idempotency, vibe-coding]
tech_stack: { language: "TS", applicable_to: ["Backend"] }
applied_in: []
aliases: [idempotency key, deduplication, exactly-once, retry-safe, request-id]
---
# Idempotency Deep
> Network / retry = 같은 request 가 여러 번. **Idempotency key 가 dedupe**. Stripe / 모든 payment API 의 표준.
## 📖 핵심 개념
- 같은 key + 같은 request = 같은 result (1번 effect).
- Storage 가 매 key 의 result.
- TTL (24-48 hr).
- Concurrency 안전.
## 💻 코드 패턴
### 기본 (단순)
```ts
async function createCharge(req) {
const key = req.headers['idempotency-key'];
if (!key) return { error: 'missing key' };
const cached = await db.idempotency.findOne({ key });
if (cached) return cached.response;
const result = await stripe.charges.create(req.body);
await db.idempotency.insert({ key, response: result, created_at: new Date() });
return result;
}
```
→ Race condition: 두 request 가 동시 = 2번 호출 가능.
### Concurrency-safe (lock)
```ts
async function createCharge(req) {
const key = req.headers['idempotency-key'];
// 1. Try insert (atomic)
try {
await db.idempotency.insert({
key, status: 'in_progress', created_at: new Date()
});
} catch (e) {
if (isUniqueViolation(e)) {
// Existing → return cached or wait
const cached = await db.idempotency.findOne({ key });
if (cached.status === 'completed') return cached.response;
if (cached.status === 'in_progress') {
// Wait or 409
return { error: 'in progress' }; // 409 Conflict
}
}
throw e;
}
// 2. Process
try {
const result = await stripe.charges.create(req.body);
await db.idempotency.update({ key }, { status: 'completed', response: result });
return result;
} catch (e) {
await db.idempotency.update({ key }, { status: 'failed', error: e.message });
throw e;
}
}
```
### Postgres unique constraint
```sql
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
status TEXT NOT NULL,
response JSONB,
request_hash TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL
);
```
→ INSERT 가 atomic. Same key 두 번 = unique violation.
### Request hash (validation)
```ts
async function createCharge(req) {
const key = req.headers['idempotency-key'];
const reqHash = sha256(JSON.stringify(req.body));
const cached = await db.idempotency.findOne({ key });
if (cached) {
if (cached.request_hash !== reqHash) {
return { error: 'idempotency key reused with different body' }; // 422
}
return cached.response;
}
// ... 처리
await db.idempotency.insert({ key, request_hash: reqHash, ... });
}
```
→ "Same key 다른 body" = 클라이언트 bug.
### Stripe 식
```ts
// Stripe 가 client 의 패턴
const charge = await stripe.charges.create(
{ amount: 1000, currency: 'usd', source: 'tok_x' },
{ idempotencyKey: 'order_123' }
);
```
→ Stripe 가 24 hr 안 같은 result.
### TTL
```sql
-- 24-48 hr 후 삭제
DELETE FROM idempotency_keys WHERE expires_at < NOW();
-- 또는 partition
CREATE TABLE idempotency_keys_2026_05_09 PARTITION OF idempotency_keys
FOR VALUES FROM ('2026-05-09') TO ('2026-05-10');
```
→ 매일 partition + drop.
### Redis 식
```ts
async function createCharge(req) {
const key = `idem:${req.headers['idempotency-key']}`;
// SETNX (atomic)
const set = await redis.set(key, 'pending', 'EX', 86400, 'NX');
if (set === null) {
// Already exists
const cached = await redis.get(key);
if (cached === 'pending') return { error: 'in progress' };
return JSON.parse(cached);
}
const result = await stripe.charges.create(req.body);
await redis.set(key, JSON.stringify(result), 'EX', 86400);
return result;
}
```
→ Redis = 빠름. Persistence X (RDB / AOF 가능).
### Locking (advanced)
```ts
import { Redlock } from 'redlock';
async function createCharge(req) {
const key = req.headers['idempotency-key'];
const lock = await redlock.acquire([`lock:${key}`], 10000);
try {
// Check cache → process → save
} finally {
await lock.release();
}
}
```
→ Distributed lock 가 보장.
### HTTP semantics
```
GET, HEAD, PUT, DELETE: idempotent (HTTP spec).
POST: NOT idempotent (default).
→ POST 의 idempotency = key 명시.
PUT 도 idempotency key 가 안전.
```
### Status code
```
200/201: 첫 호출 (success).
200/201: 두 번째 (cached, same response).
422: same key, different body.
409: in progress.
410: expired key.
```
### Async / queue idempotency
```ts
// Outbox pattern
BEGIN;
INSERT INTO outbox (id, event_type, payload) VALUES ('uuid-1', 'order.placed', ...);
// duplicate id = unique violation
COMMIT;
// Consumer
async function processEvent(event) {
if (await db.processed_events.exists(event.id)) return;
// ...
await db.processed_events.insert({ id: event.id });
}
```
→ Outbox + processed events = exactly-once.
→ [[Backend_Outbox_Pattern]].
### Kafka (offset 기반)
```
Kafka consumer:
- Offset commit = "처리됨".
- Crash 후 = 옛 offset 부터 다시 → 중복 가능.
→ Idempotent processing (DB upsert + dedup).
```
### Webhook idempotency
```ts
// Webhook receiver
async function handleWebhook(req) {
const eventId = req.body.id;
if (await db.webhook_events.exists(eventId)) {
return res.status(200).json({ received: true });
}
await db.webhook_events.insert({ id: eventId });
await processWebhook(req.body);
res.status(200).json({ received: true });
}
```
→ Stripe / GitHub 가 retry — duplicate event 가능.
### Race in receiver
```ts
// 두 webhook 가 동시 도착
// transaction 안에서 insert + process
BEGIN;
INSERT INTO webhook_events (id) VALUES ($1); -- unique
// process
COMMIT;
```
### Multi-step (saga)
```
매 step 의 idempotency.
- reserveItems(itemIds, idempotencyKey): 같은 key = 같은 reservation.
- chargePayment(amount, idempotencyKey): 같은 key = 1번 charge.
- shipOrder(orderId, idempotencyKey): 같은 key = 1번 ship.
```
→ Saga retry 가 안전.
### Generated key
```ts
// Server side
const key = `${userId}:${orderId}:${timestamp}`;
// Client side (UUID)
const key = crypto.randomUUID();
```
→ Client 가 generate (Stripe 식).
### Storage capacity
```
1M req/day × 2 KB / req = 2 GB / day.
24 hr TTL = 2 GB live.
→ 큰 system 가 partition / TTL.
```
### Performance 영향
```
매 request 가 1 DB round-trip (idempotency check).
Cache (Redis) 가 mitigate.
Hot key 가 hot row.
→ Production 가 Redis.
```
### Frontend (UI)
```ts
// Generate key once per "submit"
const idempKey = useRef(crypto.randomUUID());
const handleSubmit = async () => {
await fetch('/charge', {
method: 'POST',
headers: { 'idempotency-key': idempKey.current },
body: JSON.stringify(data),
});
};
```
→ Retry on network error 가 안전.
### Failure 시 cleanup
```ts
try {
// process
} catch (e) {
// Mark failed (cache 가 안 stale).
await db.idempotency.delete({ key });
// 또는
await db.idempotency.update({ key }, { status: 'failed', expires_at: now + 5min });
throw e;
}
```
→ Failed 도 some retention (재시도 가 같은 fail).
### LLM call (idempotency)
```ts
// LLM 가 같은 prompt = 같은 답 안 (random).
// Determinism: temperature=0.
const cacheKey = sha256(prompt);
const cached = await cache.get(cacheKey);
if (cached) return cached;
const r = await llm.complete(prompt, { temperature: 0 });
await cache.set(cacheKey, r);
return r;
```
→ LLM cache 도 idempotency 식.
### 함정
```
- TTL 너무 짧음 (1 hr): 늦은 retry 가 missed dedup.
- TTL 너무 길음: storage 폭발.
- Key 가 짧음 (timestamp 만): 충돌.
- Process 후 store (race): 같은 key 가 여러 번.
- Body hash 안: 클라이언트 가 wrong body.
- Failed 가 cache 안 cleanup: 재시도 가 fail.
```
### 표준 (다른 vendor)
```
Stripe: 24 hr.
PayPal: 5 min.
Square: 24 hr.
GitHub API: SHA-based.
Slack: 1 hr.
```
## 🤔 의사결정 기준
| 상황 | 추천 |
|---|---|
| Payment / 외부 API | Idempotency key (Stripe 식) |
| Webhook | Event ID dedup |
| Saga step | 매 step 의 key |
| 빠른 cache | Redis SETNX |
| Persistent | Postgres unique |
| Multi-region | Distributed (Redlock 등) |
| 작은 / 단순 | DB INSERT + unique |
## ❌ 안티패턴
- **No idempotency**: retry = 중복 charge.
- **Process 후 cache**: race.
- **TTL 무한**: storage.
- **Body hash 없음**: wrong body 통과.
- **Failed 가 안 retry**: stuck.
- **Auto-generate key (timestamp)**: collision.
- **Cache only (no persistent)**: restart 후 잃음.
## 🤖 LLM 활용 힌트
- Idempotency = unique key + atomic check.
- Stripe 가 표준 (Idempotency-Key header).
- TTL 24-48 hr.
- Failed 는 짧은 cache.
## 🔗 관련 문서
- [[Backend_Idempotency_Keys]]
- [[Backend_Idempotent_Consumer]]
- [[Backend_Outbox_Pattern]]