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