Files
2nd/10_Wiki/Topics/Coding/Backend_Idempotency_Keys.md
T
2026-05-09 21:08:02 +09:00

5.4 KiB

id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
id title category status source_trust_level verification_status created_at updated_at tags tech_stack applied_in aliases
backend-idempotency-keys Idempotency Keys — 중복 결제 / 중복 처리 방지 Coding draft B conceptual 2026-05-09 2026-05-09
backend
idempotency
payment
vibe-coding
language applicable_to
TS / SQL
Backend
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.

💻 코드 패턴

클라

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 기반

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
);
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

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)

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}`);
  }
}

트랜잭션 안 — 가장 강함

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 처럼)

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 전달.

🔗 관련 문서