멱등성 (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 사용 권장
  • 헤더 없이 호출하면: 멱등성 처리 생략 — 일반 요청처럼 매번 새로 실행됩니다
요청 본문 일치 검증: 같은 키로 다른 본문의 요청이 들어오면 서버는 422 ERR_IDEMPOTENCY_KEY_MISMATCH를 반환합니다. 재시도는 동일 키 + 동일 본문이 원칙이며, 새로운 요청에는 반드시 새 키를 발급해야 합니다.
Request (헤더 포함)
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가 아니면 이번 호출로 새로 처리된 응답

정보성 헤더입니다. 비즈니스 로직의 필수 분기 조건으로 의존하기보다, 재시도/중복 감지 목적의 관찰성 용도로 활용하세요.

Response headers 예시 (첫 호출)
HTTP/2 201
x-idempotent-key: 8d7f9b6c-1234-5678-90ab-cdef12345678
content-type: application/json; charset=utf-8
Response headers 예시 (재호출, 캐시 재사용)
HTTP/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 ERR_CONFLICT — 락 타임아웃
{
  "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
다른 키 + 같은 본문새 요청으로 처리정상 처리(중복 차감 가능 — 주의)
재시도 원칙: 네트워크 오류 등으로 재시도해야 하는 경우, 원본 요청과 완전히 동일한 본문같은 키로 보내세요. 본문을 수정해야 하는 상황이면 새로운 Idempotency-Key를 발급해야 합니다.

Best Practices

  • 키 생성: 요청마다 고유한 UUID v4 사용. 파트너 내부 식별자(주문 번호 등)와 조합해도 무방
  • 재시도 시: 동일 키 재사용. 새 키 발급 시 중복 처리 위험
  • 새 요청: 내용이 달라졌으면 반드시 새 키 발급. 같은 키에 다른 본문이 들어가면 422 ERR_IDEMPOTENCY_KEY_MISMATCH 반환 (응답 shape은 에러 코드 & 트러블슈팅케이스 G 참조)
  • 키 저장·로깅: 파트너 서버에서 키 ↔ 비즈니스 트랜잭션 매핑 로그를 남겨 두면 디버깅과 감사(audit) 추적에 유용
  • 409 응답: 에러로 처리하지 말고 "선행 요청 처리 중" 신호로 해석. 잠시 후 같은 키로 재시도하면 캐시된 응답을 받을 수 있음
  • 지수 백오프: 5xx / 네트워크 오류 시 재시도 간격을 점진적으로 늘리세요. 상세는 Rate Limiting 문서 참조

멱등적 주문 생성 패턴

UUID v4 키를 발급하고, 네트워크 오류·5xx 응답에는 동일 키로 재시도하며, 409는 잠시 대기 후 재시도하는 예시입니다.

Node.js
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('최대 재시도 횟수 초과');
}
Python
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('최대 재시도 횟수 초과')