멱등성 (Idempotency)
네트워크 오류 · 타임아웃 · 클라이언트 재시도로 인한 중복 요청을 방지하기 위해 Book Print API가 제공하는 Idempotency-Key 헤더 규약을 안내합니다.
멱등성이란?
동일한 요청을 여러 번 보내도 서버 상태가 한 번 요청한 것과 동일하게 유지되는 성질입니다. 주문 이중 생성이나 충전금 이중 차감 같은 사고를 방지하려면 멱등성을 보장하는 메커니즘이 필요합니다.
| 메서드 | 기본 멱등성 | 설명 |
|---|---|---|
GET | 멱등 | 조회만 — 안전하게 재시도 가능 |
PUT | 멱등 | 전체 교체 — 같은 요청 반복해도 결과 동일 |
DELETE | 멱등 | 이미 삭제된 리소스 재요청 시 404 또는 동일 결과 |
POST | 비멱등 | 새 리소스를 생성 — 재시도 시 중복 생성 위험. Idempotency-Key 헤더로 보완 |
Idempotency-Key 헤더
중복 방지가 필요한 POST 요청에 Idempotency-Key HTTP 헤더를 포함해 호출합니다. 서버는 해당 키로 첫 응답을 24시간 동안 캐시하고, 같은 키로 재요청이 들어오면 첫 응답을 그대로 반환합니다.
동작 규칙
- 키 스코프: 계정(API Key 소유자) + 엔드포인트(HTTP 메서드+경로) + Idempotency-Key 조합. 다른 계정이 같은 키를 써도 서로 영향 없음
- 캐시 유지 시간: 첫 응답 저장 후 24시간
- 캐시 대상: 2xx(성공) · 4xx(클라이언트 오류) 응답이 캐시됩니다. 5xx(서버 오류)는 캐시되지 않아 재시도 시 실제 처리
- 키 길이·형식: 임의의 문자열. 일반적으로 UUID v4 사용 권장
- 헤더 없이 호출하면: 멱등성 처리 생략 — 일반 요청처럼 매번 새로 실행됩니다
ERR_IDEMPOTENCY_KEY_MISMATCH를 반환합니다. 재시도는 동일 키 + 동일 본문이 원칙이며, 새로운 요청에는 반드시 새 키를 발급해야 합니다.curl -X POST 'https://api-sandbox.sweetbook.com/v1/orders' \
-H 'Authorization: Bearer {YOUR_API_KEY}' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: 8d7f9b6c-1234-5678-90ab-cdef12345678' \
-d '{ ... }'적용 엔드포인트
다음 POST 엔드포인트에서 Idempotency-Key 헤더를 인식합니다. 그 외 엔드포인트에 헤더를 보내도 오류는 아니며, 단순히 무시됩니다.
| 엔드포인트 | 중복 방지 효과 |
|---|---|
POST /books | 책 중복 생성 방지 |
POST /books/{bookUid}/cover | 표지 추가 중복 요청 방지 |
POST /books/{bookUid}/contents | 내지 추가 중복 요청 방지 |
POST /books/{bookUid}/finalization | 최종화 중복 요청 방지 |
POST /orders | 충전금 이중 차감 방지 |
POST /orders/{orderUid}/cancel | 전체 취소 중복 요청 방지 (환불 이중 처리 방지) |
POST /orders/{orderUid}/items/{itemUid}/cancel | 부분 취소 중복 요청 방지 (환불 이중 처리 방지) |
응답 헤더
Idempotency-Key 헤더를 포함해 요청한 경우 응답에 아래 헤더가 포함될 수 있습니다. 캐시된 응답이 재사용됐는지 판단할 때 참고하세요.
| 헤더 | 언제 포함 | 의미 |
|---|---|---|
X-Idempotent-Key | 키가 정상 처리된 모든 응답 | 요청에서 보낸 키를 에코함 |
X-Idempotent-Replayed | 값이 true일 때만 | 캐시된 응답이 재반환된 경우. 이 헤더가 없거나 true가 아니면 이번 호출로 새로 처리된 응답 |
정보성 헤더입니다. 비즈니스 로직의 필수 분기 조건으로 의존하기보다, 재시도/중복 감지 목적의 관찰성 용도로 활용하세요.
HTTP/2 201
x-idempotent-key: 8d7f9b6c-1234-5678-90ab-cdef12345678
content-type: application/json; charset=utf-8HTTP/2 201
x-idempotent-replayed: true
x-idempotent-key: 8d7f9b6c-1234-5678-90ab-cdef12345678
content-type: application/json; charset=utf-8동시 요청과 409 Conflict
같은 Idempotency-Key로 동시에 여러 요청이 도착하면 첫 요청만 처리되고, 동시에 들어온 다른 요청들은 첫 요청의 처리 완료를 기다립니다. 첫 요청이 시간 내에 끝나지 않거나 다른 이유로 락 획득에 실패하면 409 Conflict가 반환됩니다.
첫 요청이 완료된 후 같은 키로 오는 후속 요청은 409가 아니라 캐시된 응답을 그대로 받습니다 (24시간 이내).
{
"success": false,
"errorCode": "ERR_CONFLICT",
"message": "Conflict",
"data": null,
"errors": ["동일한 요청이 처리 중입니다. 잠시 후 다시 시도하세요."],
"fieldErrors": []
}호출 패턴별 결과
Idempotency-Key를 사용한 호출 시나리오와 서버 응답을 매트릭스로 정리합니다.
| 호출 패턴 | 결과 | 응답 |
|---|---|---|
| 같은 키 + 같은 본문 재시도 | 최초 응답을 그대로 재사용(중복 처리 방지) | 최초 요청과 동일한 성공/실패 응답 + X-Idempotent-Replayed: true |
| 같은 키 + 다른 본문 | 거부 | 422 + ERR_IDEMPOTENCY_KEY_MISMATCH |
| 같은 키 + 동시 요청(락 타임아웃) | 선행 요청 처리 대기 권장 | 409 + ERR_CONFLICT |
| 다른 키 + 같은 본문 | 새 요청으로 처리 | 정상 처리(중복 차감 가능 — 주의) |
Best Practices
- 키 생성: 요청마다 고유한 UUID v4 사용. 파트너 내부 식별자(주문 번호 등)와 조합해도 무방
- 재시도 시: 동일 키 재사용. 새 키 발급 시 중복 처리 위험
- 새 요청: 내용이 달라졌으면 반드시 새 키 발급. 같은 키에 다른 본문이 들어가면
422 ERR_IDEMPOTENCY_KEY_MISMATCH반환 (응답 shape은 에러 코드 & 트러블슈팅의 케이스 G 참조) - 키 저장·로깅: 파트너 서버에서 키 ↔ 비즈니스 트랜잭션 매핑 로그를 남겨 두면 디버깅과 감사(audit) 추적에 유용
- 409 응답: 에러로 처리하지 말고 "선행 요청 처리 중" 신호로 해석. 잠시 후 같은 키로 재시도하면 캐시된 응답을 받을 수 있음
- 지수 백오프: 5xx / 네트워크 오류 시 재시도 간격을 점진적으로 늘리세요. 상세는 Rate Limiting 문서 참조
멱등적 주문 생성 패턴
UUID v4 키를 발급하고, 네트워크 오류·5xx 응답에는 동일 키로 재시도하며, 409는 잠시 대기 후 재시도하는 예시입니다.
import { randomUUID } from 'crypto';
async function createOrderSafely(orderPayload) {
const idempotencyKey = randomUUID();
const maxAttempts = 4;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const response = await fetch('https://api-sandbox.sweetbook.com/v1/orders', {
method: 'POST',
headers: {
'Authorization': 'Bearer {YOUR_API_KEY}',
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(orderPayload),
});
if (response.ok) {
// 재사용 여부 확인 (선택적)
if (response.headers.get('x-idempotent-replayed') === 'true') {
console.log('캐시된 응답 재사용됨');
}
return await response.json();
}
if (response.status === 409) {
// 선행 요청이 아직 처리 중 — 잠깐 대기 후 같은 키로 재시도
await new Promise(r => setTimeout(r, 500 * Math.pow(2, attempt)));
continue;
}
if (response.status >= 500) {
// 서버 오류 — 동일 키로 재시도 (지수 백오프)
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
continue;
}
// 4xx (409 제외): 재시도 불필요
const err = await response.json().catch(() => ({}));
throw new Error(err.message ?? `요청 실패: ${response.status}`);
} catch (err) {
if (err.name === 'TypeError') {
// 네트워크 오류 — 동일 키로 재시도
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
continue;
}
throw err;
}
}
throw new Error('최대 재시도 횟수 초과');
}import uuid
import time
import requests
def create_order_safely(order_payload, api_key):
idempotency_key = str(uuid.uuid4())
max_attempts = 4
url = 'https://api-sandbox.sweetbook.com/v1/orders'
headers = {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
'Idempotency-Key': idempotency_key,
}
for attempt in range(max_attempts):
try:
response = requests.post(url, json=order_payload, headers=headers, timeout=30)
if response.ok:
if response.headers.get('x-idempotent-replayed') == 'true':
print('캐시된 응답 재사용됨')
return response.json()
if response.status_code == 409:
time.sleep(0.5 * (2 ** attempt))
continue
if response.status_code >= 500:
time.sleep(2 ** attempt)
continue
# 4xx (409 제외): 재시도 불필요
err = response.json() if response.content else {}
raise RuntimeError(err.get('message', f'요청 실패: {response.status_code}'))
except requests.exceptions.ConnectionError:
time.sleep(2 ** attempt)
continue
raise RuntimeError('최대 재시도 횟수 초과')