본문으로 건너뛰기
김신건의 로그

[Pattern] Idempotency Keys: 중복 요청 안전 처리

· 수정 · 📖 약 1분 · 549자/단어 #idempotency #pattern #api-design #backend #distributed
Idempotency Keys, Idempotency, Idempotency-Key header, Stripe idempotency, exactly-once delivery, deduplication

정의

Idempotency = 같은 요청을 N번 보내도 결과가 1번과 동일. 분산 시스템 / 결제 / API 의 안전망.

IMPORTANT

네트워크는 항상 timeout / retry. 진짜 exactly-once 는 불가능 → at-least-once + idempotent현실적 정답.

HTTP 의 idempotent method

MethodIdempotent
GETO
HEADO
OPTIONSO
PUTO
DELETEO
POSTX (기본)
PATCHX (대개)

POST 의 비-멱등성결제 중복 같은 사고의 근원.

Idempotency-Key Header

Stripe / Square / PayPal 등이 표준화:

POST /v1/charges HTTP/1.1
Authorization: Bearer sk_...
Idempotency-Key: 7f4d8e2a-c-3a-4e-9f-1234567890ab
Content-Type: application/json

{"amount": 5000, "currency": "usd", "customer": "cus_..."}

서버 응답:

1번째 호출: 200 OK (실제 처리)
2번째 호출 (같은 key): 200 OK (옛 응답 그대로)

서버 구현

sequenceDiagram
    autonumber
    participant Client
    participant API
    participant Store as Idempotency Store
    participant DB

    Client->>API: POST + Idempotency-Key: abc
    API->>Store: GET key abc
    alt 처음
        Store-->>API: not found
        API->>Store: SET key abc IN_PROGRESS
        API->>DB: 실제 처리
        DB-->>API: 결과
        API->>Store: SET key abc COMPLETED + 응답
        API-->>Client: 응답
    else 옛 응답 있음
        Store-->>API: COMPLETED + 옛 응답
        API-->>Client: 같은 옛 응답 (실제 처리 없음)
    else 진행 중
        Store-->>API: IN_PROGRESS
        API-->>Client: 409 Conflict (같은 key 중복)
    end
def idempotent_post(key, request, handler):
    cached = store.get(key)
    if cached and cached.status == "completed":
        return cached.response

    if cached and cached.status == "in_progress":
        raise Conflict("Same key in progress")

    store.set(key, "in_progress", ttl=24*3600)
    try:
        response = handler(request)
        store.set(key, "completed", response=response, ttl=24*3600)
        return response
    except Exception:
        store.delete(key)   # 또는 "failed" 로 표시
        raise

Store 선택

Store적합
Redis빠름, TTL 자동
PostgreSQL트랜잭션 친화
DynamoDB분산 + TTL

TIP

PostgreSQL 의 UNIQUE constraint + ON CONFLICT 가 가장 단순. 결제 같이 강한 일관성 필요할 때.

CREATE TABLE idempotency_keys (
  key TEXT PRIMARY KEY,
  request_hash BYTEA NOT NULL,
  response JSONB,
  status TEXT,
  expires_at TIMESTAMPTZ
);

INSERT INTO idempotency_keys (key, request_hash, status)
VALUES (?, ?, 'in_progress')
ON CONFLICT (key) DO NOTHING
RETURNING ...;

Request Hashing (재시도 안전)

def request_hash(req):
    return sha256(req.method + req.url + req.body).hexdigest()
  • 같은 key + 다른 body = 수상. 거절 (400) 또는 옛 응답.
  • 같은 key + 같은 body = 재시도. 옛 응답.

TTL 정책

기간적합
24시간Stripe 표준
7일더 안전
영구결제처럼 극한 안전

짧으면 재시도 보호 부족. 길면 store 부담.

분산 / 큐의 idempotency

flowchart LR
    P[Producer] -->|publish + msg_id| Broker
    Broker --> C[Consumer]
    C -->|이미 처리?| Inbox[(Inbox table)]
    Inbox -->|YES| Skip
    Inbox -->|NO| Process[처리 + 기록]

자세한 건 outbox-patternInbox.

멱등한 작업 만드는 패턴

작업멱등으로 만드는 방법
이메일 발송deduplication store + 발송 전 체크
DB INSERTUNIQUE constraint + ON CONFLICT
계좌 송금transaction_id 컬럼 + UNIQUE
Webhook 수신event_id + 처리 표시
API 호출Idempotency-Key 헤더

흔한 함정

WARNING

  1. 클라이언트가 매번 새 key 생성 = idempotency 의미 없음. retry 시 같은 key 재사용.
  2. key 충돌 = UUID v4 사용. 클라이언트 라이브러리가 자동 생성.
  3. TTL 너무 짧음 = 24시간 뒤 재시도중복 처리.
  4. in-progress 처리 = 그동안 동일 key 가 또 오면 대기 vs 거절 정책. 보통 409.

Key 생성 전략

import { v4 as uuidv4, v5 as uuidv5 } from 'uuid';

// 클라이언트에서 생성 (가장 일반적)
const idempotencyKey = uuidv4();   // 무작위, 충돌 거의 없음

// 결정론적 key (UUID v5 = namespace + 입력의 SHA-1)
const NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
const key = uuidv5(`payment:${userId}:${orderId}:${amount}`, NAMESPACE);
// 같은 입력이면 항상 같은 key = 재시도 시 동일 key 재사용 가능
방식장점단점
UUID v4 (랜덤)간단, 충돌 거의 없음재시작 시 새 key 생성됨
UUID v5 (해시)결정론적, 재시도 가능namespace 관리 필요
${requestId}직관적클라이언트 구현 필요

WARNING

클라이언트가 retry 마다 새 key 를 생성 = idempotency 의미 없음. retry 시 반드시 같은 key 재사용.

클라이언트 구현 패턴

// Axios interceptor: 자동 idempotency-key 추가
import axios from 'axios';
import { v4 as uuidv4 } from 'uuid';

const api = axios.create({ baseURL: '/api' });

api.interceptors.request.use(config => {
    const method = config.method?.toLowerCase() ?? '';
    if (['post', 'patch', 'put'].includes(method)) {
        // ??= 로 retry 시 같은 key 유지
        config.headers['Idempotency-Key'] ??= uuidv4();
    }
    return config;
});
# Python + httpx + tenacity retry
import httpx, uuid
from tenacity import retry, stop_after_attempt, wait_exponential

@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10))
def charge(amount: int, key: str | None = None):
    key = key or str(uuid.uuid4())
    # retry 할 때 외부에서 key 를 넘겨야 같은 key 재사용
    response = httpx.post(
        '/v1/charges',
        json={'amount': amount},
        headers={'Idempotency-Key': key},
        timeout=30,
    )
    response.raise_for_status()
    return response.json()

# 올바른 재시도: key 를 먼저 생성 후 함수에 전달
key = str(uuid.uuid4())
result = charge(5000, key=key)

Idempotency 라이프사이클

flowchart TD
    A["클라이언트: key 생성 (UUID v4)"] --> B["요청 전송"]
    B --> C{"Store 조회"}
    C -->|"없음"| D["IN_PROGRESS 저장"]
    D --> E["비즈니스 로직 실행"]
    E -->|"성공"| F["COMPLETED + 응답 저장"]
    E -->|"실패"| G["DELETE or FAILED 저장"]
    F --> H["응답 반환"]
    G --> I["에러 반환, 재시도 가능"]
    C -->|"COMPLETED"| J["캐시된 응답 즉시 반환"]
    C -->|"IN_PROGRESS"| K["409 Conflict 반환"]
    B2["재시도 (같은 key)"] --> C

테스트 전략

import pytest

class TestIdempotency:
    def test_first_call_processes(self, client):
        key = "test-key-1"
        res = client.post('/charges', json={'amount': 100},
                          headers={'Idempotency-Key': key})
        assert res.status_code == 200
        assert res.json()['id'] is not None

    def test_retry_returns_same_response(self, client):
        key = "test-key-2"
        res1 = client.post('/charges', json={'amount': 100},
                           headers={'Idempotency-Key': key})
        res2 = client.post('/charges', json={'amount': 100},
                           headers={'Idempotency-Key': key})
        # 응답 동일
        assert res1.json()['id'] == res2.json()['id']
        # DB 에 결제 1건만
        assert count_charges() == 1

    def test_different_keys_create_different_charges(self, client):
        res1 = client.post('/charges', json={'amount': 100},
                           headers={'Idempotency-Key': 'key-A'})
        res2 = client.post('/charges', json={'amount': 100},
                           headers={'Idempotency-Key': 'key-B'})
        assert res1.json()['id'] != res2.json()['id']
        assert count_charges() == 2

    def test_body_mismatch_same_key_returns_400(self, client):
        key = "test-key-3"
        client.post('/charges', json={'amount': 100},
                    headers={'Idempotency-Key': key})
        res = client.post('/charges', json={'amount': 999},
                          headers={'Idempotency-Key': key})
        # 같은 key + 다른 body = 거절
        assert res.status_code == 400

관련 위키

이 글의 용어 (4개)
[Distributed] Kafka: 분산 로그, partition, consumer groupdistributed-systems
정의 Apache Kafka = 분산 commit log. 고처리량 (수백만 msg/s), 영속, 수평 확장. event-driven 아키텍처 의 de facto. 핵심 개념: …
[Pattern] Outbox Pattern: DB + 메시지의 원자성distributed-systems
정의 Outbox Pattern = DB 변경 + 메시지 발행 의 원자성 보장. 이중 쓰기 (dual write) 문제 의 표준 해결. 문제: Dual Write | 시나리오 |…
[Pattern] Saga: 분산 트랜잭션의 보상 흐름distributed-systems
정의 Saga = 여러 service 의 로컬 트랜잭션 을 연결한 긴 흐름. 한 단계 실패 시 이전 단계의 보상 (compensating) 트랜잭션 으로 논리적 롤백. [!IMP…
[Redis] Distributed Lock: SET NX, Redlock, Fencing Tokendatabase-internals
정의 분산 락 (Distributed Lock) 은 여러 프로세스 / 머신 에서 한 시점에 하나만 임계 영역에 진입하도록 보장하는 동기화 도구. Redis 는 간단한 SETNX …

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기