주문 단말기 양옆에 한 장의 요청 카드와 여러 장의 처리 결과 카드가 놓인 대표 이미지

멱등성 설계: idempotency key·재시도·중복 요청·TTL 충돌 처리

같은 문자열 K가 왔다고 모두 같은 요청은 아니다. 다른 계정의 K, 같은 계정이 금액을 바꿔 보낸 K, 첫 처리가 끝나기 전에 다시 온 K, 이틀 뒤 도착한 K는 서로 다른 판단을 요구한다. 멱등성 설계는 같은 요청을 여러 번 받아도 사용자가 의도한 효과가 한 번만 생기게 하는 방법이지만, 키 하나만 저장해서는 이 차이를 설명할 수 없다.

이 글은 API(Application Programming Interface, 앱과 서버가 정해진 요청과 응답으로 통신하는 접점)로 주문하는 사용자를 기준으로 여섯 결정을 따라간다. scope는 키가 통하는 범위, fingerprint는 요청 의미의 비교값, state는 처리 상태, replay는 저장 결과의 재사용, expiry는 기억의 만료, downstream은 API 뒤의 결제·재고 처리다. 이 여섯 구분은 같은 키라도 다른 판단이 필요한 이유를 놓치지 않게 한다. 2026년 8월 16일 기준 공개 표준과 공식 문서를 대조한 제품 중립 설계이며 특정 운영 시스템을 직접 시험하지 않았다. 처리량과 저장량은 선택을 비교하기 위한 설계 가정이다.

20초 핵심 요약

  • 무엇: 주문 의도를 키 범위, 요청 fingerprint, 처리 상태, 저장 결과, TTL(Time To Live, 키 기록을 보존하는 시간)과 메시지 처리 기록으로 기억하는 계약이다.
  • 왜: 같은 키가 다른 계정·금액과 섞이면 정보가 노출되거나 과거 성공이 새 주문으로 오인되고, 늦은 재전송은 중복 주문·청구를 만들 수 있다.
  • 어떻게: 범위와 요청 의미를 확인해 최초 요청만 선점하고, 완료 결과를 재생하며, API 기록이 만료된 뒤에는 업무 고유 키와 소비 기록으로 중복 효과를 막는다.

주 사용자는 연결이 잠시 끊길 수 있는 이동통신망이나 일반 웹 브라우저에서 주문을 제출하는 구매자다. 사용자는 주문이 처리 중, 완료, 실패 중 어디에 있는지 알고 싶어 하며, 다시 전송해도 주문과 결제가 늘어나지 않기를 기대한다. 한 사용자의 키로 다른 사용자의 결과가 보이지 않아야 하고, 금액이나 상품이 달라진 요청은 과거 성공으로 위장되지 않아야 한다.

범위는 인증된 사용자의 POST /v1/orders 주문 생성, GET /v1/orders/{id} 상태 조회, 뒤이어 전달되는 결제·재고 이벤트다. API(Application Programming Interface, 앱과 서버가 정해진 요청과 응답으로 통신하는 접점)는 다루지만 카드 네트워크 전체가 정확히 한 번 처리된다고 가정하지 않는다. 단일 지역에서 시작하며 여러 지역으로 확장할 조건만 뒤에서 밝힌다.

1. scope: 같은 키가 통하는 울타리를 먼저 긋는다

scope는 키가 같은 의도를 뜻하는 적용 범위다. 여기서는 인증된 계정, API 동작, 키를 합친 tenant_id + POST:/v1/orders + key를 저장 키로 쓴다. 이 울타리가 있어야 다른 사용자가 우연히 같은 K를 보내도 주문 결과가 섞이지 않는다.

사용자가 처음 주문할 때의 흐름은 다음과 같다.

  1. 앱은 새 주문 의도마다 UUID(Universally Unique Identifier, 우연히 같은 값이 생길 가능성을 매우 낮춘 범용 식별자)를 만들고 Idempotency-Key에 넣는다.
  2. 재시도할 때는 키를 바꾸지 않는다. 새 키는 서버에 새 주문으로 보인다.
  3. API 경계는 먼저 인증과 권한을 확인한 뒤 키 형식과 길이를 검사한다.
  4. 멱등성 저장소는 인증된 tenant_id, operation, key 조합을 찾는다. 이 저장소의 역할은 같은 울타리 안에서 최초 요청과 재시도를 구분하는 것이다.

AWS EC2 문서도 같은 client token의 효과 범위를 리전 또는 가용 영역에 따라 다르게 둔다. 이는 AWS 내부 구현을 이 사례에 복제한다는 뜻이 아니라, 범위를 API 계약에 드러내야 한다는 근거다. 여러 지역으로 확장할 때 지역을 scope에 넣으면 구현은 쉬워지지만 같은 키가 지역마다 별도 효과를 낼 수 있다. 반대로 키의 단일 소유 지역을 정하면 중복 방지는 강해지는 대신 지역 장애 때 가용성이 낮아질 수 있다.

키는 로그인 증명이 아니다. 조회는 언제나 인증된 tenant_id 안에서 수행하고, 키 길이·문자 집합·사용자별 신규 키 수와 요청률을 제한한다. 추측하기 쉬운 키는 다른 사용자의 결과 노출과 저장 공간 고갈 공격에 이용될 수 있다.

2. fingerprint: 같은 울타리 안에서도 주문 의미를 비교한다

fingerprint는 요청에서 주문 의미를 바꾸는 필드를 일정한 형식으로 정리해 만든 비교값이다. 같은 키에 같은 fingerprint가 오면 재시도로 보고, 금액·통화·상품·수취 대상이 달라졌다면 키 오용으로 거절한다. 키가 우연히 같다는 이유로 4만9천 원 주문에 과거 3만 원 주문의 성공을 돌려주지 않기 위해 필요하다.

대표 요청은 다음과 같다. HTTP(Hypertext Transfer Protocol, 앱과 서버가 요청과 응답을 주고받는 통신 규칙) 헤더의 키와 본문을 함께 비교한다.

POST /v1/orders HTTP/1.1
Authorization: Bearer <redacted>
Idempotency-Key: 2b9b5a8d-4f34-4af0-9f5e-1f0872b24f0e
Content-Type: application/json

{
  "cart_id": "cart_123",
  "amount": 49000,
  "currency": "KRW",
  "shipping_address_id": "addr_456"
}

JSON(JavaScript Object Notation, 이름과 값을 짝지어 데이터를 표현하는 형식)의 공백과 필드 순서처럼 의미 없는 차이는 제거한다. API 버전, 금액, 통화, 상품·수량, 배송지 참조처럼 의미를 바꾸는 값은 포함한다. 가격이 변할 수 있다면 서버가 확정한 견적 ID나 가격 버전을 넣는다. 인증 토큰, 추적 ID, 전송 시각은 제외한다.

같은 범위와 키인데 fingerprint가 다르면 덮어쓰거나 기다리지 않는다.

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://api.example.com/problems/idempotency-key-reused",
  "title": "Idempotency key was reused with a different request",
  "status": 409
}

409가 유일한 정답은 아니다. 2026년 4월 18일 만료된 IETF(Internet Engineering Task Force, 인터넷 기술 표준을 논의하는 기구)의 Idempotency-Key 작업 초안은 다른 요청에 같은 키를 썼을 때 422, 진행 중 중복에는 409를 예로 들지만 확정 표준은 아니다. 선택한 상태 코드와 재시도 가능 여부를 함께 문서화해야 한다.

원문 전체를 저장하면 진단은 쉽지만 개인정보와 비용이 늘어난다. 이 설계는 의미 필드의 정규화 해시와 안전한 진단 정보만 보존한다. fingerprint에 인증 토큰이나 원문 카드 번호를 넣지 않는다. 개인정보 비교가 꼭 필요하면 HMAC(Hash-based Message Authentication Code, 비밀 키와 입력으로 검증값을 만드는 방식)을 검토하고 원문 보존을 최소화한다.

3. state: 최초 요청만 선점하고 처리 중 중복을 갈라낸다

state는 요청이 지금 PROCESSING, SUCCEEDED, FAILED_RETRYABLE, FAILED_FINAL 중 어디에 있는지 나타내는 값이다. in-flight 중복은 첫 요청이 아직 처리 중일 때 같은 요청이 다시 온 상태다. 사용자가 앱과 웹에서 거의 동시에 누르더라도 새 주문을 시작하지 않으려면 완료된 재시도와 따로 구분해야 한다.

처리 순서는 다음과 같다.

  1. 지속성 저장소가 (tenant_id, operation, idempotency_key)PROCESSING 레코드를 조건부로 만든다. 유일 조건을 얻은 요청만 주문을 실행한다.
  2. 주문 데이터베이스는 주문, 멱등성 상태, outbox 이벤트를 한 트랜잭션에 기록한다. 트랜잭션은 여러 변경이 함께 성공하거나 함께 취소되는 처리 단위다.
  3. outbox는 나중에 보낼 이벤트를 주문과 같은 트랜잭션에 적는 테이블이다. 주문만 생기고 결제·재고 이벤트는 사라지는 틈을 막는다.
  4. 서버는 상태를 SUCCEEDED로 바꾸고 주문 번호를 반환한다.
  5. 그사이 같은 요청이 오면 PROCESSING을 읽는다. 짧은 작업은 제한된 시간만 기다리고, 긴 작업은 202 Accepted와 상태 조회 주소를 주거나 재시도 가능한 충돌을 반환한다.
idempotency_record
- tenant_id             인증된 고객·계정 범위
- operation             POST:/v1/orders 같은 동작 범위
- idempotency_key       재시도에 재사용하는 값
- request_fingerprint   정규화한 업무 입력의 비교값
- state                 PROCESSING | SUCCEEDED | FAILED_RETRYABLE | FAILED_FINAL
- resource_id           생성된 주문 참조
- response_status       재생할 HTTP 상태 코드
- response_body_ref     암호화한 본문 또는 결과 참조
- owner_token           작업 세대를 구분하는 값
- created_at
- locked_until          처리 작업 임대 만료 시각
- expires_at            중복 방지 계약 종료 시각

UNIQUE (tenant_id, operation, idempotency_key)
INDEX (expires_at)

locked_until은 작업자가 상태를 마칠 수 있는 임대 시간이다. 임대가 끝났다고 외부 결제를 바로 다시 호출하면 안 된다. 먼저 내부 주문과 외부 결과를 조회하고, 효과가 있으면 SUCCEEDED로 복구한다. 효과가 없다고 확인됐을 때만 새 owner_token을 받은 작업자가 재개하며, 확인할 수 없으면 수동 검토로 격리한다. 오래된 작업자가 뒤늦게 새 결과를 덮어쓰는 것도 이 세대 값으로 막는다.

다음 그림은 왼쪽의 사용자 제출부터 읽고, 가운데의 최초 선점 뒤 위쪽 완료와 아래쪽 처리 중·충돌 분기를 따라가면 된다. 마지막에는 TTL 만료 전후와 메시지 소비 기록까지 이어진다.

같은 키의 범위 확인부터 fingerprint 비교, 최초 상태 선점, 결과 재생, TTL 만료와 메시지 소비 중복 제거까지 보여주는 여섯 단계 상태도

그림의 결론은 새 주문 효과가 최초 선점에 성공한 경로 하나에서만 난다는 점이다. 재시도는 기존 상태를 읽고, 다른 의미의 요청은 충돌하며, API 뒤 재전달은 마지막 소비 기록에서 다시 막는다.

상태 저장소가 고장 나면 결제·주문 요청은 503 Service Unavailable로 일시 거절한다. 감지는 조건부 쓰기 오류, 복제 지연, 유일 제약 오류율로 한다. 저장소 회복 뒤 같은 키를 다시 받고, 장애 중 우회 생성된 주문이 없는지 주문·키·감사 기록을 대사한다. 중복 방지 확인 없이 실행하는 것보다 사용자가 잠시 실패를 보는 편이 정확성 기대에 맞다.

state와 expiry를 바꾸는 설계 입력

아래 수치는 실제 서비스 통계나 측정값이 아니다.

항목 설계 입력 가정 계산 결과 선택에 미치는 영향
하루 주문 시도 1,000만 건 평균 약 116요청/초 평균만으로 저장소를 고르면 피크를 놓친다
최대 트래픽 평균의 20배 약 2,315요청/초 조건부 키 쓰기와 유일 인덱스가 피크를 받아야 한다
재시도·중복 비율 5% 피크 약 116중복/초 결과 재생이 업무 처리 부하를 줄인다
키 보존 시간 48시간 최대 약 2,000만 원본 키 24시간 지연과 안전 여유를 둔 예시다
메타데이터 크기 레코드당 1KB 복제 전 약 20GB 큰 응답 대신 결과 참조를 저장할 이유가 생긴다
3중 복제·인덱스 여유 원본의 약 4배 약 80GB 비용은 보존 시간과 인덱스 수에 민감하다

정상 UUID는 저장 구간에 퍼지지만 버그 난 앱이 한 키를 반복하면 핫키, 즉 한 저장 지점에 요청이 몰리는 병목이 된다. 같은 키의 대기 요청을 짧게 합치고 사용자·키별 속도를 제한한다. 재시도에는 지수 백오프와 jitter를 쓴다. 지수 백오프는 실패할수록 대기를 늘리는 방식이고, jitter는 같은 순간에 재시도가 몰리지 않게 대기에 작은 무작위 차이를 주는 방식이다. 둘은 부하를 낮출 뿐 중복 효과를 직접 막지는 않는다.

4. replay: 완료된 요청은 최초 결과와 최신 상태를 분리한다

replay는 완료된 요청과 같은 키가 다시 왔을 때 저장한 결과를 돌려주는 동작이다. 사용자는 같은 주문 번호를 받아야 하지만, 최초 생성 결과와 현재 배송·취소 상태까지 같은 응답이어야 하는지는 별도 결정이다.

이 설계는 생성 API에서 최초의 안정된 상태 코드, 최소 응답과 주문 번호를 재생한다.

HTTP/1.1 201 Created
Location: /v1/orders/ord_789
Content-Type: application/json

{"order_id":"ord_789","status":"CONFIRMED","amount":49000,"currency":"KRW"}

최신 상태는 GET /v1/orders/ord_789로 조회한다. 생성 재시도의 결정성을 지키면서 이후 취소나 배송 변화를 오래된 성공 응답과 섞지 않는 선택이다. 큰 응답 전체보다 주문 ID, 최초 상태 코드, 안정된 최소 응답을 저장하면 개인정보와 용량 비용도 줄어든다.

Stripe API v1 문서는 첫 상태 코드와 본문을 오류까지 저장해 돌려주는 정책을 설명한다. PayPal 문서는 이전 요청의 현재 최신 상태를 제공한다고 설명한다. 두 정책은 서로 다르며 어느 하나가 보편 규칙은 아니다.

처리 중 중복을 기다릴지, 202 + 상태 조회를 줄지, 충돌과 재시도 시점을 줄지도 작업 시간에 맞춰 고른다. 연결을 유지하면 한 응답에서 결과를 얻지만 서버 자원과 긴 지연이 늘어난다. 202는 긴 외부 작업을 표현하기 좋지만 상태 자원과 조회가 필요하다. 즉시 충돌은 서버가 단순하지만 잘못된 클라이언트가 재시도 폭주를 만들 수 있다.

사용자가 결과를 모르는 타임아웃은 지연 백분위수, 타임아웃률, 같은 키 재시도 수로 감지한다. 같은 키로 제한된 재시도를 허용하고 긴 작업은 상태 조회로 완화한다. 복구 뒤 주문·결제·멱등성 레코드가 같은 업무 ID를 가리키는지 확인한다. 완료가 한 번인데 응답만 느린 경우에는 replay 비율과 키별 대기자 수를 보고, 저장된 첫 결과를 돌려준 뒤 키당 주문이 하나인지 유일 제약으로 검증한다.

5. expiry: TTL이 끝나는 순간 사용자 의도의 기억도 약해진다

TTL(Time To Live)은 저장 항목을 보존하는 시간이며, 여기서는 같은 사용자 의도를 기억하는 시간 경계다. 저장 공간 때문에 필요하지만 이 시간이 끝나면 어제의 늦은 재시도와 오늘의 새 주문을 키만으로 구분하지 못할 수 있다. 그래서 만료 시간과 만료 뒤 행동을 사용자와 클라이언트에 공개해야 한다.

Stripe API v1은 적어도 24시간 지난 키를 제거할 수 있고 제거 뒤 재사용을 새 요청으로 처리한다. PayPal은 API별 보존 기간을 확인하라고 하며 환불 요청에는 최대 45일 예시가 있다. 이는 각 제품 정책이지 보편적인 권장 TTL이 아니다.

TTL은 클라이언트 최대 재시도 시간, 오프라인 요청 체류 시간, 메시지·웹훅의 늦은 도착, 운영 재처리 시간 가운데 가장 긴 값에 안전 여유를 더해 정한다. 개인정보 최소 보존과 저장 비용이 상한을 만든다. 48시간에서 7일로 늘리면 위 가정의 원본 키 수와 공간은 약 3.5배가 된다.

만료 선택 사용자 보호 비용과 남는 위험
긴 API TTL 늦은 재시도를 더 오래 식별한다 응답·개인정보 저장 비용이 늘어난다
업무 자원의 긴 client_order_id 유일 제약 응답 기록을 지워도 중복 주문을 막는다 같은 식별자의 합법적 재사용에는 세대가 필요하다
작은 만료 표식 큰 응답은 지우고 키·fingerprint·주문 ID를 오래 남긴다 표식에도 마지막 만료 경계가 남는다

client_order_id는 앱이 한 주문 의도에 붙이는 업무 고유 식별자이며, API 키 기록이 사라진 뒤에도 같은 주문 생성을 막기 위해 필요하다. 결제·주문처럼 중복 손실이 큰 업무는 짧은 응답 캐시와 더 긴 업무 고유 제약을 분리한다. 캐시는 자주 읽는 결과의 임시 복사본이며 결과 재생 속도만 높일 뿐, 권위 있는 중복 방지 기록으로 쓰지 않는다. 만료된 레코드는 expires_at 인덱스로 작은 묶음씩 지우거나 날짜별 저장 구간을 삭제한다. 요청 판단에는 논리적 만료 시각을 쓰고 실제 삭제는 비동기로 분리해 대량 정리가 주문 쓰기를 막지 않게 한다.

TTL 직후 들어온 요청은 키 기록이 없어도 client_order_id나 업무 고유 필드가 남아 있는지 확인한다. 만료 경계 요청과 기존 업무 키 충돌로 감지하고, 장기 표식이나 유일 제약으로 완화한다. 중복 결제가 이미 생겼다면 외부 결과를 대사한 뒤 안전할 때만 취소·보상하고 사용자에게 알린다. 보상은 처음부터 중복이 없었던 것과 같지 않다. Google AIP-155도 오래된 요청 ID에 묶인 과거 성공을 잘못 재생하는 문제를 별도로 다룬다.

6. downstream: API 뒤의 재전달도 같은 업무 효과로 묶는다

downstream은 주문 API 뒤에서 결제·재고·알림을 처리하는 다음 단계다. API가 주문을 한 번만 만들었어도 outbox 이벤트는 다시 전달될 수 있다. at-least-once는 메시지를 잃지 않기 위해 재전달을 허용하는 방식이며, 소비자는 같은 이벤트를 여러 번 받을 수 있다.

  1. 소비자가 이벤트를 받는다.
  2. 같은 로컬 트랜잭션에서 processed_messages(event_id, consumer_name) 삽입을 시도한다.
  3. 유일 제약이 충돌하면 이미 처리한 메시지이므로 업무를 바꾸지 않고 완료로 확인한다.
  4. 새 메시지면 재고 차감 같은 업무 변경과 처리 기록을 함께 저장한다.
  5. 저장 뒤 메시지 전달 시스템에 완료를 알린다. 알림 전에 멈춰 재전달돼도 2번 기록이 중복 효과를 막는다.

exactly-once는 전체 경로에서 업무 효과가 정확히 한 번 생긴다는 뜻이다. Kafka 공식 설계 문서는 생산자 중복 제거와 Kafka 내부 트랜잭션을 설명하지만, 외부 시스템에 쓰는 결과와 소비 위치의 조정은 별도 문제라고 밝힌다. 외부 결제나 이메일은 같은 데이터베이스 트랜잭션에 묶을 수 없으므로 event_id에서 만든 안정적 키를 외부 사업자에 전달하고 결과를 조회·대사한다.

처리 기록의 TTL은 메시지 재전달과 운영 재처리 가능 시간보다 길어야 한다. API 키 TTL보다 먼저 사라지면 주문 생성은 막았는데 재고나 메일이 다시 실행될 수 있다. 정리 작업은 작은 묶음과 날짜별 저장 구간을 사용하고 삭제 지연을 측정해 실시간 소비와 분리한다.

초기에는 주문 데이터베이스에 주문·멱등성 레코드·outbox를 함께 두는 선택이 원자성, 즉 관련 기록이 함께 성공하거나 실패하는 성질을 설명하기 쉽다. 주 데이터베이스가 실제 병목이 되면 완료 결과 캐시와 날짜별 분할을 먼저 넣는다. 긴 작업에는 202 상태 자원과 작업 임대를, 메시지 규모가 커지면 outbox 전달과 소비 기록을 추가한다. 별도 분산 키 저장소는 독립 확장에 유리하지만 키 선점과 주문 변경 사이 틈, 복제 지연과 대사 운영을 감당할 때 선택한다.

여러 지역에서는 사용자를 홈 지역에 고정하거나 키의 단일 소유 지역을 정한다. 전역에서 하나의 최신 상태를 강제하면 지연·장애 시 가용성·비용이 늘어난다. 중복 손실 비용이 그 대가보다 클 때만 범위를 넓힌다.

여섯 결정 중 가장 먼저 사라지는 보장을 찾는다

이 설계가 택한 것은 tenant + operation + key 범위, 의미 필드 fingerprint, 조건부 상태 선점, 최초 안정 결과 재생, 공개 TTL과 더 긴 업무 유일 제약, 소비자 처리 기록이다. 자연 업무 키만 쓰면 별도 키는 줄지만 진행 상태와 응답 재생을 다시 만들어야 하고, 별도 키 저장소부터 두면 처리량은 늘어도 주문과 키 사이의 원자성 틈이 생긴다.

남은 한계는 외부 사업자와 로컬 기록을 하나의 트랜잭션에 묶을 수 없다는 점, TTL 뒤 아주 늦은 요청을 완벽히 판별할 수 없다는 점, 여러 지역의 강한 일관성이 지연과 가용성을 희생한다는 점이다. Idempotency-Key를 받는 것과 사용자 의도를 기억하는 것은 다르다. API 키 TTL, 업무 고유 제약, 메시지 소비 기록, 외부 사업자의 키 보존 기간을 한 줄에 놓고 가장 먼저 끝나는 값을 찾으면 전체 중복 방지 창의 실제 한계가 보인다.

참고 링크

비슷한 글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다