--- 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( key: string, reqBodyHash: string, fn: () => Promise, ): Promise { 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(key: string, fn: () => Promise): Promise { 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]]