Skip to content
Code Card

멱등성(Idempotency) Key API 경계

멱등성 키(Idempotency Key)는 중복 요청 방지 장치가 아니라, 불확실한 외부 I/O를 가진 POST API의 상태 전이 경계 설계다.

연습 메모 · 29 min read · Hard

알아두어야할 사전 지식:

Key와 Fingerprint

  • Key: 클라이언트가 생성한 재시도 작업 단위 식별자. 보통 UUID 같은 고엔트로피 opaque string을 쓴다.

  • Fingerprint: 작업 '내용의 해시' (예: 결제 금액+상품 ID의 해시값)

둘을 조합해야 동일한 Key로 다른 내용이 들어오는 버그나 공격을 방지할 수 있다.

Durable Replay (영속적 재생)

서버가 죽었다가 살아나도, 같은 Key의 요청이 같은 결과를 반환해야 한다.요청의 결과를 디스크나 데이터베이스에 저장해두어야 서버가 재시작되더라도 멱등성을 보장할 수 있다. 즉, 저장소는 휘발성이 아닌 영속적이어야 한다.

실패 해제 (Failure Release)

"처리 중" 상태에서 영원히 멈추는 것을 방지해야 한다.
요청을 받아서 처리하는 중에 서버가 다운되면, 해당 Key는 영원히 '처리 중' 상태로 남을 수 있다. 이를 방지하려면:
TTL(Time-To-Live) 설정: 일정 시간이 지나면 '처리 중' 상태를 만료시킨다.

명시적 실패 처리:

  • 실행되지 않았음이 확실한 실패는 failed_retryable 또는 evict로 재시도를 허용한다.

  • 실행됐을 수도 있는 실패는 unknown으로 두고 reconcile한다.

들어가며

멱등성 키(Idempotency Key)는 클라이언트가 같은 요청을 안전하게 재시도할 수 있도록, 서버가 "이미 처리한 요청인가"를 식별하는 API 경계 패턴이다.
HTTP 자체에서도 idempotent method는 여러 번 같은 요청을 보내도 서버에 의도된 효과가 한 번 보낸 것과 같아야 한다고 정의된다.
RFC 9110 기준으로 PUT, DELETE, safe method는 idempotent지만, POST는 보통 리소스 생성이나 명령 실행에 쓰이므로 별도 장치가 없으면 중복 실행 위험이 있다.1

POST/PATCH를 재시도 안전하게 만드는 Idempotency-Key 요청 헤더는 IETF HTTPAPI WG에서 Standards Track Internet-Draft로 논의된 바 있다 (draft-ietf-httpapi-idempotency-key-header).
다만 이 draft는 만료·갱신되는 문서이므로, 사용 시 최신 상태와 각 API 제공자의 실제 구현 문법을 확인해야 한다.2

보통의 문제는 네트워크가 애매하게 실패할 때다.(조금 고급지게 말하면 불확실한 외부 I/O라고 한다) 클라이언트가 결제 생성 요청을 보냈고 서버는 실제로 결제를 만들었지만, 응답을 보내기 전에 연결이 끊겼다고 하자. 클라이언트 입장에서는 성공했는지 실패했는지 알 수 없다.
이때 같은 POST /payments를 다시 보내면 결제가 두 번 만들어질 수 있다. Idempotency key는 이 "성공했을 수도 있고 실패했을 수도 있는 회색 지대"를 줄이기 위한 장치이다.

Stripe 문서는 idempotency key를 사용하면 연결 오류 뒤에도 같은 요청을 반복할 수 있고, 서버는 같은 key의 첫 요청 결과를 저장해 이후 요청에 같은 결과를 돌려준다고 설명한다. 또한 key는 충분한 entropy를 가진 UUID 같은 값이 좋고, 이메일이나 개인식별정보 같은 민감정보를 넣지 말라고 권장한다. 같은 key로 다른 파라미터를 보내면 오용으로 판단해야 한다는 점도 중요하다.3

이 패턴의 핵심은 "중복 요청을 막는다"가 라기보다는 같은 작업 단위를 식별하고, 같은 key와 같은 fingerprint에 대해 같은 결과를 재사용한다는 것이다.
key만 같다고 무조건 같은 요청으로 보면 안되는 걸 해결하기 위해 나온 것이다.
amountCents=1000으로 보낸 결제와 amountCents=9000으로 보낸 결제가 같은 key를 쓰면, 그것은 재시도가 아니라 client bug 또는 공격 가능성이다.

즉 같은 key라도 페이로드가 다르면 다른 작업으로 간주해야하기때문에 등장했다.
의미적으로 동일 작업 식별하고 재사용하는 것이라고 요약할 수 있다.

핵심 암기 공식:

Text
Idempotency Key     = 재시도되는 같은 작업 단위의 식별자
Request Fingerprint = key 오용을 막기 위한 의미 기반 요청 해시 (canonical JSON + SHA-256)
Reserve -> Execute -> Complete | RetryableFail | Unknown
                    = 실행권 선점 후 결과를 replay/retry/reconcile 가능 상태로 확정
Fail(evict)         = '실행되지 않았음이 확실한 실패'에만 예약 해제 (zombie key 방지)
Unknown/Reconcile   = 실행됐을 수도 있는 실패(gateway timeout 등)는 evict 금지, reconcile
Replay              = 같은 key + 같은 fingerprint에 저장된 응답 반환
Boundary Policy     = 도메인 중복과 인프라 실패를 분리

1. 문제 상황

결제 생성 API를 만든다고 하자.

Text
POST /payments
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
{
  "customerId": "cus-1",
  "amountCents": 12000,
  "currency": "KRW"
}

위 예제는 Stripe식 opaque string(따옴표 없음) 관행을 따른 형태다.

헤더 문법 주의: IETF draft(-07)를 엄격히 따르면 Idempotency-Key는 RFC 8941 Structured Header의 String이라 값에 따옴표가 붙는다 (Idempotency-Key: "550e8400-e29b-41d4-a716-446655440000"). 반면 Stripe 등 많은 실서비스 API는 역사적으로 따옴표 없는 opaque string을 쓴다.
그래서 구현은 '준수 대상'이 IETF draft 문법인지 특정 provider의 de facto 문법인지 명확히 해야 한다.24

클라이언트가 timeout을 만났다고 해서 같은 요청을 다시 보내면 서버는 다음 중 하나를 판단해야 한다.

Text
처음 보는 key인가            -> 결제 생성 실행
완료된 같은 key + 같은 fp     -> 저장된 응답 replay
같은 key가 아직 실행 중인가   -> 409 Conflict 또는 202 Accepted + Retry-After
같은 key인데 fp가 다른가      -> idempotency key misuse (422)
직전 실행이 side effect 없음이 확실한 실패인가 -> 재실행 허용
직전 실행 결과가 불명확한가                    -> 재실행 금지, reconcile

이 카드의 도메인은 한가지다.
결제 생성 POST 요청을 idempotency key로 보호한다.


2. 핵심 표현

공통 예제는 in-memory idempotency store다.
실제 운영에서는 이 저장소를 DB, Redis, DynamoDB, PostgreSQL unique constraint, transaction 등으로 바꿔야 한다.

여기서는 문법 매핑을 위해 동일한 상태 모델을 네 언어로 표현하되, 동시성(락)과 실패 해제 (fail)까지 포함한다.

주의: 이 in-memory 예제는 문법 매핑을 위한 최소 모델이라 InProgress/Completed만 표현한다. 운영 상태 모델(failed_retryable, unknown, expired)은 7절 durable store에서 다룬다.

C#

C#
using System;
using System.Collections.Generic;

public enum IdempotencyStatus { InProgress, Completed }

public enum IdempotencyDecisionKind { Execute, Replay, InProgress, KeyMisuse }

public sealed record ApiResponse(int StatusCode, string Body);

public sealed record IdempotencyDecision(
    IdempotencyDecisionKind Kind,
    ApiResponse? Response);

public sealed class IdempotencyEntry
{
    public IdempotencyEntry(string fingerprint)
    {
        this.Fingerprint = fingerprint;
        this.Status = IdempotencyStatus.InProgress;
    }

    public string Fingerprint { get; }
    public IdempotencyStatus Status { get; private set; }
    public ApiResponse? Response { get; private set; }

    public void Complete(ApiResponse response)
    {
        this.Response = response;
        this.Status = IdempotencyStatus.Completed;
    }
}

public sealed class InMemoryIdempotencyStore
{
    private readonly object gate = new();
    private readonly Dictionary<string, IdempotencyEntry> entries = new();

    public IdempotencyDecision Reserve(string key, string fingerprint)
    {
        lock (this.gate)
        {
            if (!this.entries.TryGetValue(key, out IdempotencyEntry? entry))
            {
                this.entries[key] = new IdempotencyEntry(fingerprint);
                return new IdempotencyDecision(IdempotencyDecisionKind.Execute, null);
            }
            if (entry.Fingerprint != fingerprint)
            {
                return new IdempotencyDecision(IdempotencyDecisionKind.KeyMisuse, null);
            }
            if (entry.Status == IdempotencyStatus.InProgress)
            {
                return new IdempotencyDecision(IdempotencyDecisionKind.InProgress, null);
            }
            return new IdempotencyDecision(IdempotencyDecisionKind.Replay, entry.Response);
        }
    }

    public void Complete(string key, ApiResponse response)
    {
        lock (this.gate)
        {
            this.entries[key].Complete(response);
        }
    }

    // 실행되지 않았음이 확실한 실패에만 예약을 제거해 같은 key 재시도를 허용한다.
    // 실행 여부가 불명확한 실패는 운영 모델에서 unknown/reconcile로 처리한다.
    public void Fail(string key)
    {
        lock (this.gate)
        {
            if (this.entries.TryGetValue(key, out IdempotencyEntry? entry)
                && entry.Status == IdempotencyStatus.InProgress)
            {
                this.entries.Remove(key);
            }
        }
    }
}

TypeScript

TypeScript
type ApiResponse = Readonly<{ statusCode: number; body: unknown }>;

type IdempotencyStatus = "inProgress" | "completed";

type IdempotencyDecision =
  | Readonly<{ kind: "execute" }>
  | Readonly<{ kind: "replay"; response: ApiResponse }>
  | Readonly<{ kind: "inProgress" }>
  | Readonly<{ kind: "keyMisuse" }>;

type IdempotencyEntry = {
  fingerprint: string;
  status: IdempotencyStatus;
  response?: ApiResponse;
};

export class InMemoryIdempotencyStore {
  readonly #entries = new Map<string, IdempotencyEntry>();

  public reserve(key: string, fingerprint: string): IdempotencyDecision {
    const entry = this.#entries.get(key);
    if (entry === undefined) {
      this.#entries.set(key, { fingerprint, status: "inProgress" });
      return { kind: "execute" };
    }
    if (entry.fingerprint !== fingerprint) {
      return { kind: "keyMisuse" };
    }
    if (entry.status === "inProgress") {
      return { kind: "inProgress" };
    }
    return { kind: "replay", response: entry.response! };
  }

  public complete(key: string, response: ApiResponse): void {
    const entry = this.#entries.get(key);
    if (entry === undefined) {
      throw new RangeError("예약되지 않은 idempotency key입니다.");
    }
    entry.status = "completed";
    entry.response = response;
  }

  public fail(key: string): void {
    const entry = this.#entries.get(key);
    if (entry !== undefined && entry.status === "inProgress") {
      this.#entries.delete(key);
    }
  }
}

(참고: Node 단일 프로세스의 이벤트 루프에서는 reserve가 await 없이 동기라 check-then-set이 원자적이다. 그러나 멀티 인스턴스/스케일아웃에서는 이 in-memory Map이 즉시 깨지므로 7절의 durable store가 필요하다.)

Python5

Python
import threading
from dataclasses import dataclass
from enum import Enum


@dataclass(frozen=True)
class ApiResponse:
    status_code: int
    body: object


class IdempotencyStatus(Enum):
    IN_PROGRESS = "in_progress"
    COMPLETED = "completed"


class IdempotencyDecisionKind(Enum):
    EXECUTE = "execute"
    REPLAY = "replay"
    IN_PROGRESS = "in_progress"
    KEY_MISUSE = "key_misuse"


@dataclass
class IdempotencyEntry:
    fingerprint: str
    status: IdempotencyStatus
    response: ApiResponse | None = None


@dataclass(frozen=True)
class IdempotencyDecision:
    kind: IdempotencyDecisionKind
    response: ApiResponse | None = None


class InMemoryIdempotencyStore:
    def __init__(self) -> None:
        self._entries: dict[str, IdempotencyEntry] = {}
        # GIL은 단일 바이트코드 원자성만 보장한다. get -> if -> set 같은
        # check-then-act 복합 연산은 원자적이지 않으므로 Lock으로 임계구역을 보호한다.
        self._lock = threading.Lock()

    def reserve(self, key: str, fingerprint: str) -> IdempotencyDecision:
        with self._lock:
            entry = self._entries.get(key)
            if entry is None:
                self._entries[key] = IdempotencyEntry(
                    fingerprint=fingerprint,
                    status=IdempotencyStatus.IN_PROGRESS,
                )
                return IdempotencyDecision(kind=IdempotencyDecisionKind.EXECUTE)
            if entry.fingerprint != fingerprint:
                return IdempotencyDecision(kind=IdempotencyDecisionKind.KEY_MISUSE)
            if entry.status == IdempotencyStatus.IN_PROGRESS:
                return IdempotencyDecision(kind=IdempotencyDecisionKind.IN_PROGRESS)
            return IdempotencyDecision(
                kind=IdempotencyDecisionKind.REPLAY,
                response=entry.response,
            )

    def complete(self, key: str, response: ApiResponse) -> None:
        with self._lock:
            entry = self._entries[key]
            entry.status = IdempotencyStatus.COMPLETED
            entry.response = response

    def fail(self, key: str) -> None:
        with self._lock:
            entry = self._entries.get(key)
            if entry is not None and entry.status == IdempotencyStatus.IN_PROGRESS:
                del self._entries[key]

Rust

Rust
use std::collections::HashMap;
use std::sync::Mutex;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiResponse {
    pub status_code: u16,
    pub body: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IdempotencyStatus { InProgress, Completed }

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IdempotencyDecision {
    Execute,
    Replay { response: ApiResponse },
    InProgress,
    KeyMisuse,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct IdempotencyEntry {
    fingerprint: String,
    status: IdempotencyStatus,
    response: Option<ApiResponse>,
}

#[derive(Debug, Default)]
pub struct InMemoryIdempotencyStore {
    entries: Mutex<HashMap<String, IdempotencyEntry>>,
}

impl InMemoryIdempotencyStore {
    // 학습용 예제라 lock/unwrap을 단순화했다. 운영 코드에서는 요청 경로에서
    // panic(expect)하지 말고, 락 poison과 누락 응답을 Result로 처리해야 한다.
    pub fn reserve(&self, key: &str, fingerprint: &str) -> IdempotencyDecision {
        let mut entries = self.entries.lock().expect("mutex poisoned");
        match entries.get(key) {
            None => {
                entries.insert(
                    key.to_owned(),
                    IdempotencyEntry {
                        fingerprint: fingerprint.to_owned(),
                        status: IdempotencyStatus::InProgress,
                        response: None,
                    },
                );
                IdempotencyDecision::Execute
            }
            Some(entry) if entry.fingerprint != fingerprint => IdempotencyDecision::KeyMisuse,
            Some(entry) if entry.status == IdempotencyStatus::InProgress => {
                IdempotencyDecision::InProgress
            }
            Some(entry) => IdempotencyDecision::Replay {
                response: entry.response.clone().expect("completed response"),
            },
        }
    }

    pub fn complete(&self, key: &str, response: ApiResponse) {
        let mut entries = self.entries.lock().expect("mutex poisoned");
        let entry = entries.get_mut(key).expect("reserved key");
        entry.status = IdempotencyStatus::Completed;
        entry.response = Some(response);
    }

    pub fn fail(&self, key: &str) {
        let mut entries = self.entries.lock().expect("mutex poisoned");
        // get(불변 borrow) 직후 remove(가변 borrow)를 겹치지 않도록 판단을 먼저 끝낸다.
        let should_remove = entries
            .get(key)
            .is_some_and(|entry| entry.status == IdempotencyStatus::InProgress);
        if should_remove {
            entries.remove(key);
        }
    }
}

3. 호출부

호출부는 HTTP 요청을 검증하고, 요청 fingerprint를 만들고, idempotency store에 선점 여부를 묻는다. 실제 결제 생성 I/O는 PaymentGateway가 수행한다. 응답 정책은 controller가 담당한다.

fingerprint는 단순 join이 아니라 canonical JSON(키 정렬 등) 직렬화 후 SHA-256으로 만든다. 단순 구분자 join은 필드 안에 구분자(|)가 섞이면 서로 다른 요청이 같은 fingerprint를 갖는 충돌이 생길 수 있다.6

TypeScript
import { createHash } from "node:crypto";

type CreatePaymentRequest = Readonly<{
  idempotencyKey: string;
  customerId: string;
  amountCents: number;
  currency: "KRW" | "USD";
}>;

type PaymentGateway = Readonly<{
  createPaymentAsync: (request: CreatePaymentRequest) => Promise<Readonly<{ paymentId: string }>>;
}>;

// 최소 canonical 직렬화: top-level 키만 정렬한다. flat request DTO 전용 최소 예제다.
// 주의: nested 객체의 키 순서가 흔들리면 같은 의미의 재시도가 fingerprint 불일치로
// 422(key misuse)로 오탐될 수 있다. nested object, array 내부 object, number
// normalization, Unicode escaping, I-JSON 제약까지 필요하면 재귀 정렬하는 RFC 8785
// (JCS) 구현체를 쓰거나, payload를 flat DTO로 설계해 직렬화 복잡도를 물리적으로 차단한다.
function canonicalJson(value: Record<string, unknown>): string {
  const sorted = Object.keys(value)
    .sort()
    .reduce<Record<string, unknown>>((acc, k) => {
      acc[k] = value[k];
      return acc;
    }, {});
  return JSON.stringify(sorted);
}

function createPaymentFingerprint(request: CreatePaymentRequest): string {
  const canonical = canonicalJson({
    customerId: request.customerId,
    amountCents: request.amountCents,
    currency: request.currency,
  });
  return createHash("sha256").update(canonical, "utf8").digest("hex");
}

function validateCreatePaymentRequest(request: CreatePaymentRequest): void {
  // 길이는 entropy 검증이 아니라 "명백히 나쁜 key"를 거르는 휴리스틱일 뿐이다.
  if (request.idempotencyKey.trim().length < 16) {
    throw new RangeError("idempotencyKey가 너무 짧습니다.");
  }
  if (request.customerId.trim().length === 0) {
    throw new RangeError("customerId는 비어 있을 수 없습니다.");
  }
  if (!Number.isInteger(request.amountCents) || request.amountCents <= 0) {
    throw new RangeError("amountCents는 1 이상 정수여야 합니다.");
  }
}

// NOTE: 이 함수는 예제용 policy hook이며 기본값은 false다(비관적 정책).
// 네트워크 에러는 기본적으로 모두 'Unknown(실행 여부 불명)'으로 간주한다.
// 실제 구현에서는 HTTP client/error type, 전송 단계, gateway 계약을 근거로
// "요청이 하류에 도달하지 않았음"이 증명되는 경우(예: 외부 호출 전 로컬 예외)에만
// true를 반환해야 한다. 외부로 패킷이 나갔다면 무조건 reconcile 대상이다.
function isDefinitelyNotExecuted(error: unknown): boolean {
  void error;
  return false;
}

export async function createPaymentApiAsync(
  request: CreatePaymentRequest,
  store: InMemoryIdempotencyStore,
  gateway: PaymentGateway,
): Promise<ApiResponse> {
  validateCreatePaymentRequest(request);
  const fingerprint = createPaymentFingerprint(request);

  let decision: IdempotencyDecision;
  try {
    decision = store.reserve(request.idempotencyKey, fingerprint);
  } catch {
    // store 장애 시, 결제처럼 중복 비용이 큰 도메인은 fail-closed(요청 거절)한다.
    return { statusCode: 503, body: { code: "idempotency_store_unavailable" } };
  }

  if (decision.kind === "replay") {
    return decision.response;
  }
  if (decision.kind === "inProgress") {
    return { statusCode: 409, body: { code: "idempotency_key_in_progress" } };
  }
  if (decision.kind === "keyMisuse") {
    return { statusCode: 422, body: { code: "idempotency_key_reused_with_different_payload" } };
  }

  try {
    const payment = await gateway.createPaymentAsync(request);
    const response: ApiResponse = {
      statusCode: 201,
      body: { paymentId: payment.paymentId, status: "created" },
    };
    store.complete(request.idempotencyKey, response);
    return response;
  } catch (gatewayError) {
    // 실패 정책은 "실행됐는지"에 따라 갈린다.
    //  - 실행되지 않았음이 확실한 실패(gateway 호출 전 실패 등) -> store.fail로 예약
    //    해제(재실행 허용). zombie key 방지.
    //  - 실행됐을 수도 있는 실패(gateway timeout, 연결 끊김 등) -> evict 금지. 예약을
    //    in-progress로 남겨 reconcile 대상이 되게 하거나, 하류 PG에도 같은 idempotency
    //    key를 전달해 중복 결제를 막는다.
    if (isDefinitelyNotExecuted(gatewayError)) {
      store.fail(request.idempotencyKey);
    }
    throw gatewayError; // 상위에서 5xx로 매핑
  }
}

Fail(evict)는 "실행되지 않았음이 확실한 실패"에만 안전하다. 실행됐을 수도 있는 실패는 evict가 아니라 unknown/pending/reconcile 대상이다. 위 isDefinitelyNotExecuted 는 gateway가 요청을 보내기도 전에 실패했는지를 판별하는 술어의 자리표시자(Place Holder)다
(예: 연결 수립 전 실패, 전송 전 클라이언트 오류). 실무에서는 Stripe처럼 "실행이 시작된 뒤에는 500 같은 실패 결과도 저장해 같은 key 재시도에 그대로 반환"하는 정책이 흔하다.3

책임 분리:

Text
validateCreatePaymentRequest  = 외부 입력 검증
createPaymentFingerprint      = canonical JSON + SHA-256로 의미 기반 fingerprint 생성
IdempotencyStore.reserve      = key 선점 / replay / misuse / in-progress 판단
PaymentGateway                = 실제 결제 생성 I/O
IdempotencyStore.complete/fail= 실행 결과 저장 / 실패 시 예약 해제
createPaymentApiAsync         = API 응답 정책 조립

중요한 점은 멱등성 저장소(idempotency store)가 결제 도메인을 모른다는 것이다. store는 key, fingerprint, response만 안다. 결제 생성, DB 저장, 외부 PG 호출은 상위 레이어가 맡는다.

크래시 창(주의): store.fail로 zombie key는 줄지만, "gateway가 실제로 성공한 직후 complete/fail 전에 프로세스가 죽는" 창은 in-memory 방식으로 완전히 닫히지 않는다.
이 창을 닫으려면
(a) 7절처럼 domain write와 idempotency completion을 같은 DB 트랜잭션에 묶거나,
(b) idempotency key를 결제 gateway에도 전파해 재실행이 하류에서 dedup되게 해야 한다.


4. 읽는 순서

Text
Idempotency-Key가 있는가
-> key가 충분히 예측 불가능한가 (클라이언트가 UUID 등으로 생성)
-> payload fingerprint가 안정적으로 계산되는가 (canonical + hash)
-> 처음 보는 key인가
-> 같은 key인데 fingerprint가 다른가
-> 이미 완료된 key인가
-> 아직 실행 중인 key인가
-> 직전 실행이 side effect 없음이 확실한 실패인가 (unknown이면 재실행 금지)
-> 실제 I/O는 한 번만 수행되는가
-> 실패 시 예약이 해제되는가
-> 결과가 replay 가능한 형태로 저장되는가

Idempotency 코드는 "같은 key인가"에서 멈추는게 아니라
반드시 "같은 key이면서 같은 의미의 요청인가"까지 읽어야 한다.


5. 경계와 오해

멱등성 키(Idempotency key)는 인증이 아니다. 공격자가 임의 key를 만들 수 있다고 해서 권한이 생기면 안 된다. key는 반드시 tenant, account, user, API route 같은 scope와 함께 저장해야 한다. 전역 key 하나만으로 판단하면 서로 다른 사용자의 key 충돌이나 정보 누출이 생길 수 있다.

멱등성 키는 중복제거(deduplication)와도 다르다. 중복제거(deduplication)는 같은 데이터가 여러 번 들어오는 것을 합치는 일반적인 처리다. 멱등성 키(Idempotency key)는 클라이언트가 "이 요청은 이전 시도와 같은 작업 단위"라고 명시하는 API 계약이다. 서버가 payload만 보고 "아마 같은 요청"이라고 추측하는 것은 위험하다.

도메인 수준의 예측 가능한 거절과 인프라 실패를 분리해야 한다. 같은 key가 실행 중인 것은 도메인/API 경계의 충돌이다. 같은 key에 다른 payload가 온 것은 client misuse다. 반면 DB transaction 실패, Redis 장애, 결제 gateway timeout은 인프라 실패다. 이것을 모두 500이나 모두 409로 뭉개면 재시도 정책이 깨진다.

또 하나의 오해는 "응답이 항상 같아야 멱등성(idempotent)"라는 생각이다. HTTP의 idempotency 정의는 서버에 의도된 효과가 동일한가에 초점이 있다. 다만 idempotency key 기반 POST API에서는 운영 편의를 위해 첫 응답을 저장하고 같은 key 재시도에 같은 응답을 돌려주는 정책이 흔히 쓰인다. RFC 9110도 idempotent request는 반복되어도 의도된 효과가 같다는 점을 강조하며, 응답 자체는 다를 수 있음을 설명한다.1

프로덕션 실패 모드:

Text
- key만 비교하고 payload fingerprint를 비교하지 않음
- fingerprint를 단순 join으로 만들어 구분자 삽입/충돌에 취약함
- idempotency key에 이메일, 전화번호, 주민번호 같은 민감정보를 넣음
- tenant/account scope 없이 전역 key로 저장함
- reserve와 domain write가 원자적으로 묶이지 않아 중복 실행됨
- 실행 실패 시 예약을 해제하지 않아 zombie key가 영원히 in-progress로 남음
- 반대로, 결과가 불명한 실패(gateway timeout 등)를 무조건 evict해 중복 결제를 유발함
- response replay에 필요한 status/body/schema version을 저장하지 않음
- 같은 key 재시도에서 201 대신 200을 돌려도 되는지 API 계약이 없음
- idempotency store 장애를 무시하고 결제 생성으로 바로 진행함 (fail-closed 위반)

6. 잘못된 예제

TypeScript
const processedKeys = new Set<string>();

async function createPaymentBadAsync(
  request: CreatePaymentRequest,
  gateway: PaymentGateway,
): Promise<ApiResponse> {
  if (processedKeys.has(request.idempotencyKey)) {
    return { statusCode: 200, body: { status: "already_processed" } };
  }
  const payment = await gateway.createPaymentAsync(request);
  processedKeys.add(request.idempotencyKey);
  return { statusCode: 201, body: { paymentId: payment.paymentId } };
}

나쁜 이유:

Text
- 결제 생성 후 key를 기록하므로, gateway 성공 직후 프로세스가 죽으면 재시도 때 중복 결제가 가능하다.
- payload fingerprint를 비교하지 않아 같은 key로 다른 결제 요청을 보내도 막지 못한다.
- 저장된 원래 응답을 replay하지 않고 가짜 already_processed 응답을 만든다.
- tenant/account scope가 없다.
- in-progress 상태를 표현하지 못해 동시 요청 두 개가 모두 gateway로 들어갈 수 있다.
- 프로세스 메모리에만 저장하므로 서버 재시작, scale-out, multi-instance 환경에서 깨진다.

이 코드는 "중복 방지처럼 보이는 코드"이지 멱등성 경계(idempotency boundary)가 아니다. 운영 API에서는 key 선점, fingerprint 검증, 원자적 저장, replay 가능한 응답 보존, 실패 해제가 필요하다.


7. 프로덕션 확장

운영에서는 in-memory store 대신 durable store(한국에서는 번역 문서를 찾을 수 없었는데, 프로세스 재시작 이후에도 데이터가 보장되는 저장소를 뜻한다)를 사용한다. 핵심은 scope + key에 unique constraint를 걸고, 상태와 fingerprint와 응답을 함께 저장하는 것이다.

SQL
CREATE TABLE api_idempotency_keys (
    scope_id             TEXT NOT NULL,
    idempotency_key      TEXT NOT NULL,
    request_fingerprint  TEXT NOT NULL,
    status               TEXT NOT NULL CHECK (status IN
                           ('in_progress','completed','failed_retryable','unknown','expired')),
    response_status_code INTEGER NULL,
    response_body        JSONB NULL,
    response_schema_version INTEGER NULL,
    last_error_code      TEXT NULL,
    locked_until         TIMESTAMPTZ NULL,   -- in-progress zombie 회수용 실행권 임대 만료
    reconcile_after      TIMESTAMPTZ NULL,   -- unknown 상태 대사(reconcile) 예약 시각
    created_at           TIMESTAMPTZ NOT NULL,
    completed_at         TIMESTAMPTZ NULL,
    expires_at           TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (scope_id, idempotency_key)
);

트랜잭션 abort 함정과 올바른 흐름

보통 프로그래머가 자주 설계하는 방식인 "INSERT -> unique violation 에러 캐치 -> 같은 트랜잭션에서 SELECT"로 설계하면 안 된다.
PostgreSQL을 비롯한 엄격한 RDBMS는 트랜잭션 안에서 unique constraint violation이 나면 트랜잭션 전체가 abort되고, 이후 같은 트랜잭션의 SELECT가 "current transaction is aborted"로 거부된다. 그래서 에러를 던지지 않는 원자적 upsert 구문으로 실행권 획득과 조회를 처리한다.7

SQL
-- 실행권 획득 시도. ON CONFLICT DO NOTHING은 에러를 던지지 않으므로 트랜잭션이 abort되지 않는다.
INSERT INTO api_idempotency_keys
  (scope_id, idempotency_key, request_fingerprint, status, locked_until, created_at, expires_at)
VALUES ($1, $2, $3, 'in_progress', now() + interval '5 minutes', now(), now() + interval '48 hours')
ON CONFLICT (scope_id, idempotency_key) DO NOTHING
RETURNING scope_id;
-- RETURNING이 row를 주면 -> 이 요청이 실행권을 얻음
-- 0 rows(충돌) -> 이미 존재. 이어서 별도 SELECT로 상태/fingerprint/응답 조회

권장 처리 흐름:

Text
1. transaction 시작
2. INSERT ... ON CONFLICT DO NOTHING RETURNING 실행
3. RETURNING row 있음 -> 실행권 획득
4. 0 rows -> 별도 SELECT로 기존 row 조회 (트랜잭션 abort 없음)
5. fingerprint 다름     -> 422 misuse
6. status=in_progress AND locked_until >  now() -> 409 Conflict 또는 202 + Retry-After
   status=in_progress AND locked_until <= now() -> unknown으로 원자적 전환 후 reconcile
7. status=completed        -> 저장된 response replay
8. status=failed_retryable -> 재실행 허용 (side effect 없음이 확실한 실패)
   status=unknown          -> 재실행 금지, reconcile (side effect 여부 불명)
9. 실행권을 얻은 요청만 domain write 수행
10. domain write와 idempotency row completion을 같은 transaction에서 commit

failedretryable 재실행권과 lockeduntil 회수

failed_retryable row는 이미 존재하므로 INSERT ... ON CONFLICT DO NOTHING으로는 실행권을 얻지 못한다. 재실행은 상태를 원자적으로 다시 in_progress로 전환한 요청 만 갖는다.

SQL
UPDATE api_idempotency_keys
SET status = 'in_progress',
    locked_until = now() + interval '5 minutes',
    last_error_code = NULL
WHERE scope_id = $1
  AND idempotency_key = $2
  AND request_fingerprint = $3
  AND status = 'failed_retryable'
RETURNING scope_id;
-- RETURNING row 있음 -> 이 요청이 재실행권 획득
-- 0 rows -> 그 사이 다른 요청이 이미 가져갔거나 상태가 바뀜

locked_until은 in-progress zombie를 다루는 신호로 쓴다. 다만 결제에서는 lock이 만료됐다고 곧바로 재실행하면 위험하다. 프로세스는 죽었어도 외부 PG 요청은 성공 했을 수 있기 때문이다. 그래서 안전한 기본값은 재실행이 아니라 unknown 전환이다.

Text
status=in_progress AND locked_until >  now()  -> 409 Conflict 또는 202 + Retry-After
status=in_progress AND locked_until <= now()  -> 재실행 금지, unknown으로 전환 후 reconcile

locked_until은 "다시 실행해도 된다"의 근거가 아니라, "이 요청은 정상 처리 중이 아니므로 대사(reconcile)해야 한다"는 신호다. expired in_progress를 unknown으로 전환하는 것도 원자적 UPDATE로 한다.

SQL
UPDATE api_idempotency_keys
SET status = 'unknown',
    reconcile_after = now(),
    last_error_code = 'in_progress_lock_expired'
WHERE scope_id = $1
  AND idempotency_key = $2
  AND request_fingerprint = $3
  AND status = 'in_progress'
  AND locked_until <= now()
RETURNING scope_id;

위는 요청 경로용(특정 key + fingerprint)이다. 만료된 in_progress row를 훑는 배치/데몬은 fingerprint를 모를 수 있으니 조건을 줄인다.

SQL
UPDATE api_idempotency_keys
SET status = 'unknown',
    reconcile_after = now(),
    last_error_code = 'in_progress_lock_expired'
WHERE status = 'in_progress'
  AND locked_until <= now()
RETURNING scope_id, idempotency_key;

상태 전이 요약:

Text
- failed_retryable    : 원자적 UPDATE로 in_progress로 전환한 요청만 실행권을 갖는다.
- unknown             : 어떤 클라이언트 재시도도 실행권을 못 얻고, reconcile 파이프라인만
                        completed/failed_retryable로 전이시킨다.
- expired in_progress : 자동 재실행하지 말고 unknown으로 전환해 대사한다.

결제처럼 중복 실행 비용이 큰 도메인은 domain writeidempotency complete 사이가 벌어지면 위험하다. 내부 domain write와 idempotency completion은 가능하면 같은 DB transaction으로 묶는다.
다만 "같은 transaction"이 모든 경우의 해답은 아니라고 볼 수 있다.
외부 PG 호출은 같은 DB transaction 안에 오래 붙잡아두지 않는다.
긴 트랜잭션 + 네트워크 I/O는 lock 유지 시간을 늘리고 장애 복구를 어렵게 만든다.
외부 I/O는 PG 측 idempotency key, outbox, payment_intent 상태 머신, reconcile job으로 보강한다.
이것이 3절의 "크래시 창"을 실제로 닫는 방법이다.

Unknown 상태 해소: reconcile 파이프라인

unknown(실행 여부 불명)에 갇힌 건은 반드시 별도 비동기 파이프라인으로 최종 상태를 확정해야 한다. 관측·해소되지 않는 unknown은 그냥 숨은 장애 상태다.
보통 세 축을 조합하는 것으로 해소한다.

  1. PG webhook 수신(권위 소스): 결제 gateway가 보내는 성공/실패 webhook이 가장 신뢰할 수 있는 최종 상태다. webhook의 idempotency key나 결제 id로 해당 row를 찾아 completed 또는 failed_retryable로 확정한다.

  2. 주기적 polling 데몬(백업): webhook이 없거나 지연되면 reconcile_after가 지난

  3. unknown row를 큐잉해, PG의 조회 API(GET payment)로 실제 상태를 확인하여 조정한다.

  4. DLQ(마지막 그물): polling으로도 확정되지 않거나 재시도 한도를 초과한 건은 dead letter queue로 넘겨 운영자가 수동으로 확인하고 처리하도록 한다.

실패 확정도 종류가 갈린다.
PG에 결제 객체가 생성되지 않았음이 확정되면 failed_retryable로 전이할 수 있다. 반대로 결제 객체가 생성된 뒤 declined/failed가 된 경우에는, 도메인 결과가 실패이더라도 "작업 단위는 완료된 것"으로 보고 completed로 저장한 뒤 같은 실패 응답을 replay하는 편이 안전하다. failed_retryable은 "같은 작업을 다시 실행해도 side effect 중복이 없다"가 증명될 때만 쓴다.3

원칙을 정리해두면, 클라이언트는 unknown 동안 같은 key로 재실행하지 못하고, 오직 이 파이프라인만 상태를 completed/failed_retryable로 전이시킨다. 그래야 '결과 불명'이 중복 실행으로 번지지 않는다.

TTL 정책도 필요하다. Stripe 문서는 key를 최소 24시간 이후 pruning할 수 있다고 설명한다. 내부 시스템에서는 결제, 주문, 송금, 예약처럼 재시도 창이 긴 도메인은 24시간보다 긴 보존 기간이 필요할 수 있다. 보존 기간은 "클라이언트 재시도 기간 + 장애 복구 기간 + 감사 요구사항"으로 정해야 한다.3

expired는 물리 삭제할지 상태로 남길지 정책을 정한다.

Text
A. hard delete : expires_at이 지난 row를 pruning job이 물리 삭제한다.
B. soft expire : 감사/분석을 위해 status='expired'로 바꾸고 replay 대상에서 제외한다.

운영 metric:

Text
api.idempotency.reserve.created.count
api.idempotency.reserve.replay.count
api.idempotency.reserve.in_progress.count
api.idempotency.reserve.key_misuse.count
api.idempotency.reserve.failed_retry.count
api.idempotency.reserve.unknown.count
api.idempotency.store.error.count
api.idempotency.zombie_key.count
api.idempotency.ttl_pruned.count
api.idempotency.reconcile.scheduled.count
api.idempotency.reconcile.resolved.count
api.idempotency.reconcile.failed.count

특히 key_misuse는 보안 신호일 수 있다. 단순 client bug일 수도 있지만, 같은 key로 payload를 바꿔보는 재전송 공격이나 SDK 결함일 수도 있다.


8. C# / TypeScript / Python / Rust 비교 메모

언어

관용 표현

주의점

C#

record, enum, interface store, DB transaction

lock + Dictionary는 단일 프로세스 예제일 뿐 운영 저장소가 아님

TypeScript

discriminated union, Map, controller boundary

Node multi-instance에서는 in-memory Map이 즉시 깨짐

Python

dataclass, Enum, repository class

GIL은 check-then-act 원자성을 보장하지 않음 -> threading.Lock 필요

Rust

enum, Mutex<HashMap>, 명시적 ownership

요청 경로에서 expect/panic 금지, poison과 durable store 경계를 분리

C#은 record와 interface로 idempotency decision을 명확히 표현하기 좋다. TypeScript는 discriminated union으로 execute/replay/inProgress/keyMisuse를 깔끔하게 분기할 수 있다.
Python은 dataclass와 Enum으로 같은 모양을 만들 수 있지만,
GIL을 저장소 일관성 모델로 오해하면 안 되고 명시적 락이 필요하다. Rust는 enum이 decision modeling에 강하지만, 예제의 Mutex<HashMap>expect는 학습용이고 운영에서는 DB/분산 store와 Result 기반 오류 처리가 필요하다.

늘 적어두지만, 한 언어의 스타일을 다른 언어에 억지로 이식하면 안 된다.
TypeScript에서 C#식 class hierarchy를 과하게 만들 필요는 없고, C#에서 TypeScript처럼 문자열 union을 흉내 내면 안정성이 떨어진다. Rust에서는 Result로 모든 것을 몰아넣기보다, replay 가능한 decision enum을 별도로 두는 편이 보통 의도가 선명하다고 관행화 되있다.

결국 프로그래밍 핵심이라는 건 전반적으로 코드의 언어적 한계와 기법도 있지만, 어느정도 커뮤니티 관행에 따른다.


9. 추가로 생각해보기

  • 이 API는 HTTP method 자체로 idempotent한가, 아니면 idempotency key가 필요한 POST 명령인가?

  • fingerprint는 raw JSON 문자열 기준인가, canonical(RFC 8785 JCS) + hash 기준인가?

  • 같은 key로 다른 payload가 들어오면 400, 409, 422 중 어떤 API 계약이 가장 적합한가?

  • 외부 PG 호출과 내부 DB 저장을 하나의 원자적 작업으로 묶을 수 없는 경우, outbox/saga/PG idempotency key 중 무엇을 쓸 것인가?

  • replay 응답은 첫 응답과 완전히 같아야 하는가, 아니면 같은 resource id를 담은 다른 status code를 허용할 것인가?

  • key TTL은 클라이언트 재시도 기간, 장애 복구 기간, 감사 요구사항 중 무엇을 기준으로 잡을 것인가?

  • idempotency store가 장애일 때 요청을 막을 것인가(fail-closed), 중복 위험을 감수하고 진행할 것인가?

  • 실행 실패 시 예약을 evict할 것인가 failed로 마킹할 것인가? gateway가 성공했을 가능성이 있으면 어느 쪽이 안전한가?


10. 요약

  • 멱등성 키(Idempotency key)는 불확실한 네트워크 재시도에서 중복 실행을 막기 위한 API 경계 패턴이다.

  • key만 비교하면 부족하고, 같은 key에 같은 request fingerprint(canonical + hash)인지 확인해야 한다.

  • Reserve -> Execute -> Complete / RetryableFail / Unknown -> Replay 또는 Reconcile 흐름으로 실행권과 응답 재사용을 분리한다. 실행 전 실패만 예약을 해제하고, 결과 불명은 reconcile로 보낸다.

  • 결제, 주문, 예약처럼 중복 실행 비용이 큰 도메인에서는 in-memory store가 아니라 durable store와 unique constraint, 그리고 ON CONFLICT 원자적 upsert가 필요하다.

  • 도메인 충돌, key misuse, 인프라 실패는 서로 다른 응답 정책과 metric을 가져야 한다.

  • idempotency key에는 민감정보를 넣지 말고, tenant/account scope와 TTL 정책을 함께 설계해야 한다.

쉽게 암기하기:

Text
POST를 안전하게 재시도하려면 key, fingerprint, durable replay, 실패 해제를 함께 설계하라.

개인메모: 보통 멱등성을 '중복 요청 방지' 정도로 배우지만, 실제로는 '중복 요청을 안전하게 처리하는 동시에 실패한 요청을 깔끔하게 정리하는 것'이다.

각주

  1. IETF. RFC 9110: HTTP Semantics (idempotent methods)
  2. IETF. The Idempotency-Key HTTP Header Field (draft-ietf-httpapi-idempotency-key-header)
  3. Stripe. Idempotent requests
  4. IETF. RFC 8941: Structured Field Values for HTTP (Idempotency-Key 헤더 형식)
  5. Python. threading — Lock objects
  6. IETF. RFC 8785: JSON Canonicalization Scheme (JCS)
  7. PostgreSQL. INSERT ... ON CONFLICT (upsert)