멱등성 (Idempotency)

동일한 API 요청이 여러 번 실행되어도 결과가 동일하게 유지되는 멱등성 개념과 SweetBook API의 중복 요청 방지 메커니즘을 안내합니다.

멱등성이란?

멱등성(Idempotency)이란 동일한 요청을 여러 번 보내더라도 서버 상태가 한 번 요청한 것과 동일하게 유지되는 성질입니다. 네트워크 오류, 타임아웃, 클라이언트 재시도 등의 상황에서 주문이 중복 생성되거나 충전금이 이중 차감되는 것을 방지하기 위해 반드시 필요합니다.

text
클라이언트 → POST /orders (요청 #1) → 네트워크 타임아웃
클라이언트 → POST /orders (요청 #1 재시도) → 409 Conflict (이미 처리됨)

→ 주문은 1건만 생성되고, 충전금도 1회만 차감됩니다.

HTTP 메서드별 멱등성

HTTP 메서드에 따라 멱등성이 보장되는 정도가 다릅니다.

메서드멱등성설명
GET멱등데이터를 조회만 하므로 서버 상태를 변경하지 않습니다. 안전하게 재시도할 수 있습니다.
PUT멱등전체 리소스를 교체하므로 동일 요청을 반복해도 결과가 같습니다.
DELETE멱등이미 삭제된 리소스에 대해 재요청해도 결과가 동일합니다 (404 또는 200).
POST비멱등새 리소스를 생성하므로 재시도 시 중복 생성 위험이 있습니다. 별도의 중복 방지 메커니즘이 필요합니다.
POST 요청 주의: 주문 생성(POST /orders)과 같은 비멱등 요청은 반드시 아래에서 설명하는 중복 방지 메커니즘을 활용하세요.

SweetBook의 중복 요청 방지

SweetBook API는 Redis 분산 락(Distributed Lock)을 사용하여 중복 요청을 방지합니다. 동일한 요청이 30초 이내에 다시 수신되면 이전 요청이 아직 처리 중인 것으로 판단하여409 Conflict 응답을 반환합니다.

text
요청 수신 → Redis Lock 획득 시도 (TTL 30초)
  ├─ Lock 획득 성공 → 요청 처리 → 완료 후 Lock 해제
  └─ Lock 획득 실패 → 409 Conflict 즉시 반환

409 Conflict 응답 예시

json
{
  "success": false,
  "error": {
    "code": "DUPLICATE_REQUEST",
    "message": "이미 동일한 요청이 처리 중입니다. 잠시 후 다시 시도해주세요.",
    "retryAfter": 30
  }
}
분산 락의 TTL은 30초입니다. 정상적인 요청 처리가 완료되면 즉시 락이 해제되므로, 실제로 30초를 기다릴 필요는 거의 없습니다.

Best Practices

1. 고유한 Reference ID 사용

주문 생성 시 referenceId 필드에 클라이언트 측에서 생성한 고유 ID를 전달하세요. 동일한 referenceId로 중복 주문을 방지할 수 있습니다.

json
{
  "referenceId": "my-system-order-20250315-001",
  "bookUid": "bk_e4d5c6b7",
  "quantity": 1,
  "shippingAddress": { ... }
}

2. 409 응답을 정상적으로 처리

409 Conflict 응답을 받으면 에러로 처리하지 말고, 이전 요청이 성공적으로 처리되고 있다는 의미로 해석하세요. 기존 주문을 조회하여 상태를 확인하는 것이 좋습니다.

3. Exponential Backoff 재시도

네트워크 오류나 5xx 응답을 받았을 때는 지수 백오프(Exponential Backoff) 전략으로 재시도하세요. 과도한 재시도는 서버 부하를 가중시킬 수 있습니다.

재시도 횟수대기 시간
1차1초
2차2초
3차4초
4차 (최대)8초

멱등적 주문 생성 패턴

아래는 referenceId를 활용하여 안전하게 주문을 생성하고, 409 응답과 네트워크 오류를 올바르게 처리하는 예시입니다.

JavaScript

javascript
async function createOrderSafely(bookUid, quantity, shippingAddress) {
  const referenceId = `order-${Date.now()}-${crypto.randomUUID()}`;

  for (let attempt = 0; attempt < 4; attempt++) {
    try {
      const response = await fetch('https://api.sweetbook.com/v1/orders', {
        method: 'POST',
        headers: {
          'Authorization': 'Bearer YOUR_API_KEY',
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          referenceId,
          bookUid,
          quantity,
          shippingAddress,
        }),
      });

      if (response.ok) {
        return await response.json();
      }

      if (response.status === 409) {
        // 이미 처리 중 — 기존 주문 조회
        console.log('중복 요청 감지, 기존 주문을 조회합니다.');
        const existing = await fetch(
          `https://api.sweetbook.com/v1/orders?referenceId=${referenceId}`,
          { headers: { 'Authorization': 'Bearer YOUR_API_KEY' } }
        );
        return await existing.json();
      }

      if (response.status >= 500) {
        // 서버 오류 — 재시도
        const delay = Math.pow(2, attempt) * 1000;
        console.log(`서버 오류 (${response.status}), ${delay}ms 후 재시도...`);
        await new Promise(resolve => setTimeout(resolve, delay));
        continue;
      }

      // 4xx 오류 (409 제외) — 재시도 불필요
      throw new Error(`요청 실패: ${response.status}`);

    } catch (error) {
      if (error.name === 'TypeError') {
        // 네트워크 오류 — 재시도
        const delay = Math.pow(2, attempt) * 1000;
        console.log(`네트워크 오류, ${delay}ms 후 재시도...`);
        await new Promise(resolve => setTimeout(resolve, delay));
        continue;
      }
      throw error;
    }
  }

  throw new Error('최대 재시도 횟수 초과');
}

Python

python
import requests
import uuid
import time

def create_order_safely(book_uid, quantity, shipping_address):
    reference_id = f"order-{int(time.time())}-{uuid.uuid4()}"
    url = "https://api.sweetbook.com/v1/orders"
    headers = {
        "Authorization": "Bearer YOUR_API_KEY",
        "Content-Type": "application/json",
    }

    for attempt in range(4):
        try:
            response = requests.post(url, json={
                "referenceId": reference_id,
                "bookUid": book_uid,
                "quantity": quantity,
                "shippingAddress": shipping_address,
            }, headers=headers, timeout=30)

            if response.ok:
                return response.json()

            if response.status_code == 409:
                # 이미 처리 중 — 기존 주문 조회
                print("중복 요청 감지, 기존 주문을 조회합니다.")
                existing = requests.get(
                    f"{url}?referenceId={reference_id}",
                    headers=headers
                )
                return existing.json()

            if response.status_code >= 500:
                delay = (2 ** attempt)
                print(f"서버 오류 ({response.status_code}), {delay}초 후 재시도...")
                time.sleep(delay)
                continue

            response.raise_for_status()

        except requests.exceptions.ConnectionError:
            delay = (2 ** attempt)
            print(f"네트워크 오류, {delay}초 후 재시도...")
            time.sleep(delay)
            continue

    raise Exception("최대 재시도 횟수 초과")

관련 문서