멱등성 (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 응답이 저장됩니다. 어떤 4xx가 저장되는지는 아래 「4xx 응답 저장 여부」 표를 참고하세요. 응답이 저장되면 그 응답에
X-Idempotent-Key헤더가 포함됩니다. 저장된 4xx를 받은 뒤 같은 키로 고친 본문을 보내면 422ERR_IDEMPOTENCY_KEY_MISMATCH가 반환되므로, 4xx를 받고 요청을 고쳐 다시 보낼 때는 새 키를 사용하세요. 4xx 응답은 아무것도 처리되지 않은 상태라 새 키로 보내도 중복 처리되지 않습니다 - 키 길이·형식: 임의의 문자열. 일반적으로 UUID v4 사용 권장
- 헤더 없이 호출하면: 멱등성 처리 생략 — 일반 요청처럼 매번 새로 실행됩니다
4xx 응답 저장 여부
| 엔드포인트 | 저장되는 4xx (같은 키로 다시 보내면 같은 응답 또는 422) | 저장되지 않는 4xx (원인 해소 후 같은 키로 다시 보내도 됨) |
|---|---|---|
| 모든 적용 엔드포인트 공통 | — | 요청 형식 검증 실패 400(필수 필드 누락, 길이·범위 위반 등), 401, 402 ERR_INSUFFICIENT_CREDIT, 동시 요청 409, 422, 429, 5xx |
POST /books | 400 ERR_VALIDATION_FAILED — 허용되지 않은 creationType, 존재하지 않는 bookSpecUid·specProfileUid, PDF 방식인데 pageCount 누락, 책 생성 처리 중 일시 오류 | 400 ERR_INSUFFICIENT_PAGES·ERR_PAGECOUNT_INVALID (페이지 규칙 위반) |
POST /books/{bookUid}/cover, /contents (템플릿 방식) | 400 ERR_VALIDATION_FAILED — 존재하지 않거나 삭제·최종화된 책, 템플릿 파라미터·이미지 오류(이미지 URL 다운로드 실패 포함), 이미 표지가 있음, 표지 없이 내지 추가 등 · 404 ERR_NOT_FOUND — 존재하지 않는 템플릿 | 403 ERR_ENV_MISMATCH, 400 ERR_PAGECOUNT_INVALID (내지 최대 페이지 초과) |
POST /books/{bookUid}/finalization | — | 404 ERR_NOT_FOUND, 403 ERR_ENV_MISMATCH, 400 ERR_FINALIZE_PREREQ_UNMET·ERR_INSUFFICIENT_PAGES·ERR_PAGECOUNT_INVALID |
POST /orders | 400 ERR_VALIDATION_FAILED — 존재하지 않거나 삭제된 책, 최종화되지 않은 책, 다른 환경(Sandbox/Live)의 책 | (공통 항목만 해당) |
POST /orders/{orderUid}/cancel, /items/{itemUid}/cancel | 400 ERR_VALIDATION_FAILED — 존재하지 않는 주문, 취소할 수 없는 상태(이미 취소된 주문·항목 포함), 주문에 없는 항목 | (공통 항목만 해당 — cancelReason 누락·길이 초과 포함) |
표에 없는 4xx도 저장될 수 있습니다. 판단이 어려우면 4xx를 받은 뒤에는 새 키로 다시 보내세요.
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 | 부분 취소 중복 요청 방지 (환불 이중 처리 방지) |
표지·내지 추가는 템플릿 방식 엔드포인트입니다. PDF 업로드 방식의 POST /books/{bookUid}/pdf-cover · POST /books/{bookUid}/pdf-contents는 멱등 키를 인식하지 않습니다. 이미 등록된 상태에서 다시 POST하면 409를 반환하고, 교체는 PUT이 멱등이므로 재시도에 안전합니다.
응답 헤더
Idempotency-Key 헤더를 포함해 요청한 경우 응답에 아래 헤더가 포함될 수 있습니다. 캐시된 응답이 재사용됐는지 판단할 때 참고하세요.
| 헤더 | 언제 포함 | 의미 |
|---|---|---|
X-Idempotent-Key | 응답이 저장된 경우 (성공 응답과 일부 4xx — 위 「4xx 응답 저장 여부」 참조) | 요청에서 보낸 키를 에코함. 이 헤더가 없으면 응답이 저장되지 않은 것 |
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시간 이내).
현재 서버 동작상 락 타임아웃 409는 errorCode·errors·fieldErrors 없이 아래 두 필드만 반환합니다. errorCode가 없는 409를 받으면 락 타임아웃으로 보고 같은 키로 재시도하세요 (에러 핸들링 & Retry 전략 참고).
{
"success": false,
"message": "동일한 요청이 처리 중입니다. 잠시 후 다시 시도하세요."
}호출 패턴별 결과
Idempotency-Key를 사용한 호출 시나리오와 서버 응답을 매트릭스로 정리합니다.
| 호출 패턴 | 결과 | 응답 |
|---|---|---|
| 같은 키 + 같은 본문 재시도 | 최초 응답을 그대로 재사용(중복 처리 방지) | 최초 요청과 동일한 성공/실패 응답 + X-Idempotent-Replayed: true |
| 같은 키 + 다른 본문 | 거부 | 422 + ERR_IDEMPOTENCY_KEY_MISMATCH |
| 같은 키 + 동시 요청(락 타임아웃) | 선행 요청 처리 대기 권장 | 409 (errorCode 없음 — 현재 서버 동작) |
| 다른 키 + 같은 본문 | 새 요청으로 처리 | 정상 처리(중복 차감 가능 — 주의) |
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('최대 재시도 횟수 초과')