POST /charge 요청을 처리하던 중 서비스가 크래시됩니다. 클라이언트는 타임아웃을 보고 재시도합니다. 이제 두 건의 결제가 생겼습니다. 고객은 화가 났습니다. 데이터베이스는 일관적입니다. 비즈니스 로직은 그렇지 않습니다.

이건 엣지 케이스가 아닙니다. 분산 시스템의 기본 동작입니다. 네트워크는 패킷을 드롭합니다. 컨테이너는 요청 도중 OOM-kill됩니다. 로드밸런서는 이미 백엔드에 도달한 요청에 대해 502를 반환합니다. API가 “에러를 받았으니 이 연산은 확실히 실행되지 않았을 거야”라고 가정한다면, 당신은 버그 공장을 짓고 있는 겁니다.

피할 수 없는 at-least-once 현실

HTTP는 기본적으로 at-least-once입니다. TCP는 손실된 패킷을 재전송합니다. HTTP 클라이언트는 타임아웃 시 재시도합니다. 인프라는 5xx에 대해 재시도합니다. 모든 계층은 이전 계층이 실패할 수 있다고 가정하고 다시 보낸다.

문제는 재시도가 아닙니다. 문제는 멱등성이 없는 연산입니다.

GET은 두 번 읽는 게 한 번 읽는 것과 같으므로 재시도해도 안전합니다. POST /chargePOST /orders는 그렇지 않습니다. 두 번 실행하면 두 개의 리소스가 생성됩니다. 한 번도 실행하지 않으면 판매가 누락됩니다. 신뢰할 수 없는 네트워크에서 “exactly once”를 선택할 수는 없습니다. 선택지는 중복 제거와 함께 “at least once”를 쓰거나, 데이터 손상과 함께 “아마도 0번, 아마도 2번”을 쓰는 것뿐입니다.

멱등성(idempotency)은 “at least once”를 안전하게 만드는 방법입니다.

idempotency key가 실제로 동작하는 방식

Stripe가 이 패턴을 대중화했지만, 아이디어 자체는 더 오래되었습니다. 클라이언트는 고유한 키(UUID)를 생성해 헤더에 담아 보낸다. Idempotency-Key: <uuid>. 서버는 부수 효과를 실행하기 전에 튜플 (key, request_body, response)를 저장합니다. 같은 키가 다시 도착하면, 서버는 연산을 다시 실행하지 않고 저장된 응답을 반환합니다.

핵심 통찰: 서버는 작업을 수행한 뒤가 아니라, 작업을 하기 전에 키를 저장해야 합니다. charge 행을 먼저 쓰고 키를 저장하기 전에 크래시가 나면, 재시도가 두 번째 charge를 만듭니다. idempotency key의 저장과 비즈니스 변경은 원자적(atomic)이어야 합니다.

실무에서는 두 가지 중 하나를 의미합니다:

  1. idempotency 저장소와 비즈니스 저장소가 데이터베이스 트랜잭션을 공유한다. 같은 BEGIN ... COMMIT 안에서 idempotency_keyscharges에 삽입합니다. 커밋이 성공하면 둘 다 존재합니다. 실패하면 둘 다 없습니다.

  2. idempotency 저장소가 곧 비즈니스 저장소다. charges 테이블에 UNIQUE 제약이 있는 client_idempotency_key 컬럼이 있습니다. 재시도가 unique 제약을 위반하면 기존 행을 반환합니다.

옵션 2가 더 단순하며, 대부분의 팀이 이것부터 시작해야 합니다.

원자적 저장소를 사용하는 실제 구현

다음은 SQLite를 사용하는 최소하지만 완전한 Python 서버입니다. idempotency key는 비즈니스 변경과 같은 트랜잭션에 존재합니다. 서버가 COMMIT 이후에 크래시되면, 재시도가 캐시를 타게 됩니다. COMMIT 전에 크래시되면 아무것도 지속되지 않으므로 재시도가 안전하게 다시 실행됩니다.

import sqlite3
import json
import uuid

DB_PATH = "/data/inventory.db"

def init_db():
    conn = sqlite3.connect(DB_PATH)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS reservations (
            id TEXT PRIMARY KEY,
            item_id TEXT NOT NULL,
            quantity INTEGER NOT NULL,
            idempotency_key TEXT UNIQUE NOT NULL,
            created_at INTEGER
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS idempotency_responses (
            key TEXT PRIMARY KEY,
            response_body TEXT NOT NULL,
            created_at INTEGER
        )
    """)
    conn.commit()
    return conn

def reserve_inventory(conn, idempotency_key, item_id, quantity):
    # Step 1: Check for an existing response.
    cursor = conn.execute(
        "SELECT response_body FROM idempotency_responses WHERE key = ?",
        (idempotency_key,)
    )
    row = cursor.fetchone()
    if row:
        return json.loads(row[0])

    # Step 2: Do the work inside a single transaction.
    conn.execute("BEGIN")

    # Deduct inventory atomically.
    conn.execute(
        "UPDATE inventory SET available = available - ? WHERE item_id = ? AND available >= ?",
        (quantity, item_id, quantity)
    )
    if conn.total_changes == 0:
        conn.rollback()
        return {"error": "insufficient inventory"}

    # Record the reservation.
    reservation_id = str(uuid.uuid4())
    conn.execute(
        "INSERT INTO reservations (id, item_id, quantity, idempotency_key, created_at) VALUES (?, ?, ?, ?, strftime('%s','now'))",
        (reservation_id, item_id, quantity, idempotency_key)
    )

    # Cache the response for retries.
    response = {"reservation_id": reservation_id, "status": "reserved"}
    conn.execute(
        "INSERT INTO idempotency_responses (key, response_body, created_at) VALUES (?, ?, strftime('%s','now'))",
        (idempotency_key, json.dumps(response))
    )

    conn.commit()
    return response

idempotency_responses 테이블은 안전망입니다. 첫 번째 요청은 변경을 수행하고 결과를 커밋하며 응답을 캐시합니다. 같은 키를 가진 이후의 모든 요청은 작업을 건너뛰고 캐시된 JSON을 반환합니다. 예약과 캐시 엔트리는 같은 트랜잭션에 기록되므로, 둘 다 보이거나 둘 다 없거나 둘 중 하나입니다.

한계가 드러나는 지점: 부수 효과의 경계

idempotency key는 같은 클라이언트의 중복 요청을 처리합니다. 서로 다른 클라이언트의 동시 중복 요청은 처리하지 않습니다. 두 명의 사용자가 마지막 재고 상품의 “구매”를 클릭하면, 여전히 pessimistic lock이나 optimistic concurrency control가 필요합니다. 위 예시의 available >= ? 검사는 이를 위한 원시적인 형태이지만, 실제 인벤토리 시스템에는 더 많은 것이 필요합니다.

더 큰 문제는 트랜잭션 바깥의 부수 효과입니다. Stripe로 카드를 결제하고, SendGrid로 이메일을 보내고, 데이터베이스에 기록한다면, idempotency key는 데이터베이스 부분만 보호합니다. 이메일은 두 번 보내질 수 있습니다. Stripe 자체의 idempotency 윈도우가 만료되면 카드가 두 번 결제될 수도 있습니다. 진정한 안전을 위해서는 모든 다운스트림 시스템이 참여해야 합니다.

이것이 Stripe가 charge 생성 시 자체 Idempotency-Key를 받는 이유입니다. Stripe는 자신의 계층에서 중복을 제거합니다. 당신도 자신의 계층에서 똑같이 해야 합니다. 같은 키를 멱등성을 지원하는 다운스트림 서비스에 그대로 전달하세요. 지원하지 않는 서비스라면 호출을 로컬 트랜잭션으로 감싸거나 리스크를 감수하세요.

키 충돌, TTL, 그리고 다른 운영 함정들

UUID4는 122비트의 무작위성을 가집니다. 현실적인 규모에서 충돌 확률은 무시할 수 있습니다. 순차 정수, 타임스탬프, 요청 본문의 해시를 키로 사용하지 마세요. 클라이언트가 생성한 UUID가 업계 표준인 데는 이유가 있습니다.

오래된 엔트리를 만료시키지 않으면 키 저장소는 영원히 증가합니다. TTL을 설정하세요: 24시간이 표준입니다. 그 이후에는 오래된 키를 삭제하세요. 클라이언트가 TTL 이후에 재시도하면 중복이 발생합니다. 이것을 문서화하세요. 재시도 윈도우와 TTL은 기술적 세부사항이 아니라 비즈니스 계약입니다.

idempotency 저장소는 API만큼은 사용 가능해야 합니다. Redis 캐시가 다운되면 키를 검증할 수 없습니다. 어떤 팀은 “새로운 요청으로 간주”하는 폴백을 사용하는데, 이는 장애 중에 중복을 만듭니다. 어떤 팀은 요청을 거부하는데, 이는 더 안전하지만 다른 장애 모드를 만듭니다. 여기에는 공짜 점심은 없습니다.

클라이언트 측도 똑같이 중요합니다

클라이언트가 보내지 않으면 서버 측 idempotency key는 쓸모없습니다. 모든 상태 변경 요청은 호출 지점에서 키를 생성하고, 타임아웃 시 같은 키로 재시도해야 합니다:

import uuid
import requests

def safe_post(url, payload, max_retries=3):
    key = str(uuid.uuid4())
    for attempt in range(max_retries):
        try:
            resp = requests.post(
                url,
                json=payload,
                headers={"Idempotency-Key": key},
                timeout=10,
            )
            return resp
        except requests.Timeout:
            if attempt == max_retries - 1:
                raise
            # Retry with the SAME key. The server deduplicates.

키는 논리적 연산당 한 번 생성되어야 하지, HTTP 시도당 한 번이 아닙니다. 매 재시도마다 새로운 UUID를 생성한다면 핵심을 놓친 겁니다. 키는 재시도들을 묶는 계약입니다.

FAQ

클라이언트가 브라우저이고 사용자가 페이지를 새로고침하면 어떻게 되나요?

페이지 새로고침은 새로운 JavaScript 컨텍스트를 만듭니다. 키를 localStorage나 URL에 지속하지 않는 한 새로운 요청은 새로운 idempotency key를 갖게 됩니다. 대부분의 팀은 중요하지 않은 플로우에서는 신경 쓰지 않습니다. 결제의 경우에는 그렇게 해야 합니다.

GET 요청도 idempotency key를 사용해야 하나요?

아니요. GET은 HTTP 의미론상 이미 안전합니다. idempotency key는 상태를 변경하는 메서드—POST, PUT, PATCH, DELETE—를 위한 것입니다.

요청 본문의 해시를 idempotency key로 사용할 수 있나요?

본문이 결정적이고 타임스탬프나 무작위 값을 포함하지 않는 경우에만 가능합니다. 실무에서는 클라이언트가 생성한 UUID가 더 단순하고 신뢰할 수 있습니다.

idempotency key를 얼마나 오래 보관해야 하나요?

가장 긴 클라이언트 재시도 윈도우보다 길게. 클라이언트가 60초 동안 재시도한다면 24시간 동안 키를 보관하세요. 배치 작업 때문에 내일 재시도할 수도 있다면 일주일 동안 보관하세요.

오늘 첫 번째 요청을 멱등하게 만들어라

모든 상태 변경 엔드포인트에 Idempotency-Key 헤더를 추가하세요. 24시간 캐시부터 시작하세요. UUID4를 사용하세요. 저장소를 비즈니스 트랜잭션과 원자적으로 만드세요. 컨테이너가 요청 도중에 죽고 클라이언트가 재시도하는 첫 순간, 당신은 그렇게 해서 다행이라고 생각할 것입니다. 대안은 재무팀에게 왜 한 고객에게 똑같은 결제가 열일곱 건이나 있는지 설명하는 것입니다.