f8b21af4be
10_Wiki/Topics 대규모 정리: - 오류 캡처/미완성 stub 문서 227개 제거 - 교차폴더 중복 43클러스터 병합 (63파일 → redirect) - 링크명 정규화: 깨진 링크 수정·redirect 직결·개념 매핑 ~2,400건 - 카테고리 MOC 6개 신규 생성 - Graph 섹션 미해결 related-keyword 링크 10,058건 제거 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
184 lines
5.3 KiB
Markdown
184 lines
5.3 KiB
Markdown
---
|
|
id: backend-idempotency-keys
|
|
title: Idempotency Keys — 중복 결제 / 중복 처리 방지
|
|
category: Coding
|
|
status: draft
|
|
source_trust_level: B
|
|
verification_status: conceptual
|
|
created_at: 2026-05-09
|
|
updated_at: 2026-05-09
|
|
tags: [backend, idempotency, payment, vibe-coding]
|
|
tech_stack: { language: "TS / SQL", applicable_to: ["Backend"] }
|
|
applied_in: []
|
|
aliases: [idempotency key, dedupe, exactly-once, Stripe-Idempotency-Key]
|
|
---
|
|
|
|
# Idempotency Keys
|
|
|
|
> 네트워크 = at-least-once. **같은 요청이 두 번 와도 한 번만 처리** 보장. 클라가 키 생성 → 서버가 키 저장 + 결과 캐시 → 같은 키 다시 = 캐시 응답. Stripe / PayPal 표준.
|
|
|
|
## 📖 핵심 개념
|
|
- Idempotency key: 클라이언트가 만든 unique ID (UUID).
|
|
- 서버는 (key, response) 캐시.
|
|
- 같은 key 재요청 = 캐시된 response 반환.
|
|
- TTL: 보통 24h.
|
|
|
|
## 💻 코드 패턴
|
|
|
|
### 클라
|
|
```ts
|
|
async function createPayment(input: CreatePaymentInput) {
|
|
const key = crypto.randomUUID(); // 한 번만 생성, 재시도해도 같은 key
|
|
const r = await fetchWithRetry('/api/payments', {
|
|
method: 'POST',
|
|
headers: { 'idempotency-key': key, 'content-type': 'application/json' },
|
|
body: JSON.stringify(input),
|
|
});
|
|
return r.json();
|
|
}
|
|
```
|
|
|
|
### 서버 — DB 기반
|
|
```sql
|
|
CREATE TABLE idempotency (
|
|
key TEXT PRIMARY KEY,
|
|
status TEXT NOT NULL, -- 'pending' | 'done'
|
|
response JSONB,
|
|
request_hash TEXT NOT NULL, -- 같은 key + 다른 body 검출
|
|
expires_at TIMESTAMPTZ NOT NULL
|
|
);
|
|
```
|
|
|
|
```ts
|
|
async function withIdempotency<T>(
|
|
key: string,
|
|
reqBodyHash: string,
|
|
fn: () => Promise<T>,
|
|
): Promise<T> {
|
|
const existing = await db.idempotency.find(key);
|
|
if (existing) {
|
|
if (existing.requestHash !== reqBodyHash) {
|
|
throw new Error('IDEMPOTENCY_MISMATCH'); // 같은 key + 다른 body
|
|
}
|
|
if (existing.status === 'done') return existing.response as T;
|
|
if (existing.status === 'pending') {
|
|
throw new Error('IDEMPOTENCY_IN_PROGRESS'); // 또는 wait
|
|
}
|
|
}
|
|
|
|
await db.idempotency.insert({
|
|
key, status: 'pending', requestHash: reqBodyHash,
|
|
expiresAt: new Date(Date.now() + 24 * 3600_000),
|
|
});
|
|
|
|
try {
|
|
const result = await fn();
|
|
await db.idempotency.update(key, { status: 'done', response: result });
|
|
return result;
|
|
} catch (e) {
|
|
await db.idempotency.delete(key); // 실패 시 재시도 가능하게
|
|
throw e;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Express middleware
|
|
```ts
|
|
app.post('/api/payments', async (req, res, next) => {
|
|
const key = req.headers['idempotency-key'] as string | undefined;
|
|
if (!key) return res.status(400).json({ error: 'idempotency-key required' });
|
|
|
|
const hash = sha256(JSON.stringify(req.body));
|
|
try {
|
|
const result = await withIdempotency(key, hash, () => createPayment(req.body));
|
|
res.json(result);
|
|
} catch (e) {
|
|
if (e.message === 'IDEMPOTENCY_MISMATCH') return res.status(409).json({ error: 'mismatch' });
|
|
if (e.message === 'IDEMPOTENCY_IN_PROGRESS') return res.status(409).json({ error: 'in progress' });
|
|
next(e);
|
|
}
|
|
});
|
|
```
|
|
|
|
### Redis 기반 (빠름, TTL native)
|
|
```ts
|
|
const TTL = 24 * 3600;
|
|
|
|
async function withIdempotency<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
const cached = await redis.get(`idem:${key}`);
|
|
if (cached) return JSON.parse(cached);
|
|
|
|
// SETNX 로 락
|
|
const got = await redis.set(`idem:lock:${key}`, '1', 'EX', 60, 'NX');
|
|
if (!got) throw new Error('IN_PROGRESS');
|
|
|
|
try {
|
|
const result = await fn();
|
|
await redis.set(`idem:${key}`, JSON.stringify(result), 'EX', TTL);
|
|
return result;
|
|
} finally {
|
|
await redis.del(`idem:lock:${key}`);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 트랜잭션 안 — 가장 강함
|
|
```ts
|
|
await db.transaction(async (tx) => {
|
|
// 1. 키 unique insert 시도 — 중복 = 충돌
|
|
try {
|
|
await tx.idempotency.insert({ key, status: 'pending' });
|
|
} catch (e) {
|
|
if (isUniqueViolation(e)) {
|
|
const cached = await tx.idempotency.find(key);
|
|
throw new IdempotencyHit(cached.response);
|
|
}
|
|
throw e;
|
|
}
|
|
|
|
// 2. 실제 작업
|
|
const result = await actualWork(tx);
|
|
|
|
// 3. 결과 저장
|
|
await tx.idempotency.update(key, { status: 'done', response: result });
|
|
|
|
return result;
|
|
});
|
|
```
|
|
|
|
### 외부 API 호출 (Stripe 처럼)
|
|
```ts
|
|
const intent = await stripe.paymentIntents.create(
|
|
{ amount: 1000, currency: 'usd' },
|
|
{ idempotencyKey: orderId }, // Stripe 가 처리
|
|
);
|
|
```
|
|
|
|
## 🤔 의사결정 기준
|
|
| 작업 | 적용 |
|
|
|---|---|
|
|
| 결제 / 주문 생성 | 필수 |
|
|
| 외부 API 호출 (이메일 등) | 필수 |
|
|
| 알림 발송 | 권장 |
|
|
| 큐 메시지 처리 | 메시지 ID = key |
|
|
| GET / 조회 | 자연 idempotent |
|
|
| 단순 DELETE | 자연 idempotent |
|
|
|
|
## ❌ 안티패턴
|
|
- **클라이언트가 매번 새 key**: idempotency 의미 없음. 재시도 시 same key.
|
|
- **Body hash 검사 없음**: 같은 key + 다른 body 통과.
|
|
- **Pending 상태 처리 없음**: 동시 두 요청 중 하나 실패 처리.
|
|
- **TTL 없음**: 무한 자라남.
|
|
- **결과 cache 안 함**: 두 번째 요청도 다시 실행.
|
|
- **외부 부수효과 후 cache update**: 부수효과 두 번 + cache 만 1.
|
|
- **Pending → 즉시 reject**: 더 좋은 UX = wait 또는 long-poll.
|
|
|
|
## 🤖 LLM 활용 힌트
|
|
- 클라 = UUID 한 번 + 재시도 시 same.
|
|
- 서버 = transaction + unique insert + cache response.
|
|
- TTL 24h, 외부 API (Stripe) 도 같은 key 전달.
|
|
|
|
## 🔗 관련 문서
|
|
- [[Backend_Webhook_Patterns]]
|
|
- [[Backend_Retry_Strategy]]
|