[Pattern] Idempotency Keys: 중복 요청 안전 처리
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
| Method | Idempotent |
|---|---|
| GET | O |
| HEAD | O |
| OPTIONS | O |
| PUT | O |
| DELETE | O |
| POST | X (기본) |
| PATCH | X (대개) |
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-pattern 의 Inbox.
멱등한 작업 만드는 패턴
| 작업 | 멱등으로 만드는 방법 |
|---|---|
| 이메일 발송 | deduplication store + 발송 전 체크 |
| DB INSERT | UNIQUE constraint + ON CONFLICT |
| 계좌 송금 | transaction_id 컬럼 + UNIQUE |
| Webhook 수신 | event_id + 처리 표시 |
| API 호출 | Idempotency-Key 헤더 |
흔한 함정
WARNING
- 클라이언트가 매번 새 key 생성 = idempotency 의미 없음. retry 시 같은 key 재사용.
- key 충돌 = UUID v4 사용. 클라이언트 라이브러리가 자동 생성.
- TTL 너무 짧음 = 24시간 뒤 재시도 시 중복 처리.
- 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 …
이 개념을 다룬 위키 페이지 (12)
- wiki[API Design] API Versioning: URL / Header / Date 비교
- wiki[API Design] REST API: 원칙, 자원 모델링, HATEOAS, 버전 관리
- wiki[AWS] SQS: managed queue, FIFO, DLQ
- wiki[Concurrency] Circuit Breaker: cascade failure 방어
- wiki[Concurrency] Retry + Exponential Backoff + Jitter
- wiki[Distributed Systems] CAP Theorem과 PACELC
- wiki[Pattern] CQRS: Command / Query 분리, read model
- wiki[Distributed] Kafka Consumer Group: rebalancing, offset, lag
- wiki[Distributed] Message Broker 비교: Kafka / RabbitMQ / NATS / SQS / Redis Streams
- wiki[Pattern] Outbox Pattern: DB + 메시지의 원자성
- wiki[Distributed] RabbitMQ: exchange, queue, routing key
- wiki[Pattern] Saga: 분산 트랜잭션의 보상 흐름
💬 댓글