멱등성과 멱등키 — "한 번만 일어나게"를 어디서, 어떻게 보장할까
앞 글(MSA · EDA · CDC · CQRS 비교)의 4장에서 “Outbox 릴레이는 같은 메시지를 두 번 보낼 수 있으므로 소비자는 멱등해야 한다“고 한 줄로 넘어갔다. 이 글은 그 한 줄을 펼친다. 분산 시스템에서 “정확히 한 번”은 네트워크가 주는 것이 아니라 수신 쪽이 만들어 내는 것이다. 그 도구가 멱등성이고, HTTP 경계에서는 그 손잡이가 멱등키(Idempotency-Key)다.
이 블로그의 CS300 #138 멱등성과 재시도가 개념과 재시도 간격(백오프)을 다뤘다면, 이 글은 멱등키를 서버에 실제로 구현할 때 부딪히는 결정들에 집중한다.
1. 왜 “그냥 다시 보내기”가 위험한가
클라이언트가 POST /payments 를 보냈는데 타임아웃이 났다. 가능한 상태는 세 가지다.
- 요청이 서버에 닿지도 않았다.
- 서버가 처리했지만 응답이 돌아오는 길에 끊겼다.
- 서버가 아직 처리 중이다.
클라이언트는 이 셋을 구분할 수 없다. IETF 초안도 정확히 이 장면으로 시작한다 — 타임아웃이 나면 클라이언트는 리소스가 만들어졌는지, 서버가 처리를 끝냈는지조차 모르고, 따라서 안전하게 재시도할 수 있는지도 모른다(IETF draft §1). 재시도하면 2번 상황에서 이중 결제가 나고, 재시도하지 않으면 1번 상황에서 결제가 누락된다.
AWS의 Malcolm Featonby는 이 딜레마의 대안 — 매번 “있는지 먼저 조회해 보는” 대사(reconciliation) — 이 드문 엣지 케이스를 위해 너무 많은 일을 시키며, 그래도 “그 리소스를 내가 만든 건지, 다른 프로세스가 만든 건지”는 여전히 모른다고 지적한다(AWS Builders’ Library). 해법은 연산 자체를 재시도해도 부작용이 추가로 생기지 않게, 즉 멱등하게 만드는 것이다.
2. 두 종류의 멱등성을 구분하자
2-1. HTTP 메서드의 멱등성 (프로토콜 약속)
RFC 9110 §9.2.2는 “같은 요청을 여러 번 보냈을 때 서버에 대한 의도된 효과가 한 번 보낸 것과 같으면” 그 메서드를 멱등하다고 정의한다. PUT, DELETE, 그리고 안전한 메서드(GET, HEAD, OPTIONS, TRACE)가 여기 속하고, POST와 PATCH는 아니다.
중요한 단서가 두 가지 있다.
- RFC는 이 속성이 사용자가 요청한 것에만 적용되며, 서버가 요청마다 로그를 남기는 등 별도의 부작용을 갖는 것은 자유라고 명시한다. 멱등성은 “아무 일도 안 일어난다”가 아니라 “요청된 효과가 한 번분”이라는 뜻이다.
- 이건 메서드 의미론에 대한 약속이지 구현 보장이 아니다.
PUT /accounts/1/balance를 “현재 잔액 + 100” 으로 구현하면 메서드 이름과 상관없이 멱등하지 않다.
2-2. 비즈니스 연산의 멱등성 (애플리케이션 책임)
“주문 생성”, “결제 승인”, “포인트 적립”은 본질적으로 POST다. 여기서 멱등성은 프로토콜이 주지 않으므로 애플리케이션이 중복을 식별하는 수단을 따로 가져야 한다. 그 수단이 두 가지 축으로 나뉜다.
| 축 | 식별자 | 대표 위치 |
|---|---|---|
| 동기 요청 경계 | 클라이언트가 만든 멱등키 | HTTP API (결제·주문 생성) |
| 비동기 메시지 경계 | 메시지/이벤트 ID | 이벤트 소비자 (Outbox 이후) |
둘은 같은 아이디어 — “이 ID를 본 적이 있나?” — 를 다른 경계에 적용한 것이다. 3~5장은 앞의 것, 6장은 뒤의 것이다.
3. 왜 “요청 내용 해시”가 아니라 “클라이언트가 만든 키”인가
중복 판별을 요청 파라미터 해시로 하면 클라이언트는 아무것도 안 해도 된다. 편해 보인다. AWS는 이 접근이 모든 경우에 통하지는 않는다고 경험적으로 말한다. 같은 호출자가 짧은 간격으로 똑같은 DynamoDB 테이블 생성 요청을 두 번 보냈다면 중복일 가능성이 높지만, 똑같은 EC2 인스턴스 기동 요청 두 번은 정말로 인스턴스 두 대를 원한 것일 수 있다(AWS).
즉 “같은 내용”과 “같은 의도”는 다르다. 의도를 아는 쪽은 클라이언트뿐이므로, 클라이언트가 고유 식별자로 의도를 표현하게 하는 것이 AWS의 선호 방식이고(EC2에선 ClientToken), Stripe와 IETF 초안의 Idempotency-Key도 같은 발상이다. 덤으로 그 식별자가 로그(CloudTrail 등)에 남아 감사 추적이 쉬워진다(AWS).
4. Idempotency-Key 헤더 — 표준화 현황
IETF HTTPAPI 워킹그룹에서 Idempotency-Key 요청 헤더를 표준화하고 있다. 이 글을 쓰는 시점의 최신본은 draft-ietf-httpapi-idempotency-key-header-08(2026-10-22 게시, Standards Track 지향)이다. 아직 RFC가 아닌 Internet-Draft이므로 “작업 중인 문서”로 인용해야 하며, 세부는 바뀔 수 있다.
초안의 핵심 규칙을 요약하면:
- 문법: Structured Field의 String. 예:
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324". UUID 같은 랜덤 식별자를 권장(RECOMMENDED). - 유일성: 키는 유일해야 하고, 다른 페이로드의 요청에 재사용해서는 안 된다(MUST NOT).
- 만료: 리소스는 시간 기반 만료를 둘 수 있고(MAY), 그 정책을 문서로 공개해야 한다(SHOULD).
- 지문(fingerprint): 키와 함께 페이로드 체크섬 등으로 만든 지문을 써서 요청 동일성을 판단할 수 있다(MAY).
- 오류 응답
| 상황 | 상태 코드 | 클라이언트 행동 |
|---|---|---|
| 키가 필요한 연산인데 헤더 없음 | 400 Bad Request |
고쳐서 재시도 |
| 같은 키, 다른 페이로드 | 422 Unprocessable Content |
고쳐서 재시도 |
| 같은 키, 원 요청이 아직 처리 중 | 409 Conflict |
그대로 재시도 가능 |
| 원 요청이 끝난 뒤 재시도 | 원 요청 결과와 일관된 응답 | — |
초안의 구현 현황 절에는 Stripe, Adyen, Dwolla, WorldPay 등이 이미 Idempotency-Key라는 이름의 커스텀 헤더를 쓰고 있다고 기록돼 있다 — 표준이 관행을 뒤따라 정리하는 형태다.
5. 서버 구현에서 내려야 하는 다섯 가지 결정
결정 1. 키의 범위(scope) — “누구의” 키인가
키를 전역 유일로 다루면 서로 다른 고객의 키가 우연히(혹은 악의로) 겹칠 때 남의 응답을 돌려줄 수 있다. AWS는 멱등 “세션”을 고객 식별자 + 클라이언트 요청 식별자 조합으로 잡는다고 설명한다(AWS). 실무적으로는 (tenant_or_account_id, endpoint, idempotency_key)를 유니크 키로 두는 것이 안전하다.
결정 2. 같은 키, 다른 내용 — 거부한다
키는 같은데 파라미터가 다르면? AWS는 “고객이 다른 결과를 의도했다고 가정하는 것이 가장 안전하다”며 파라미터 불일치 검증 오류를 반환하고, 이를 위해 최초 요청의 파라미터를 키와 함께 저장한다(AWS). Stripe도 들어온 파라미터를 원 요청과 비교해 다르면 오류를 낸다(Stripe). IETF 초안에서는 이게 422다. 그래서 저장 레코드에는 요청 지문(해시)이 들어가야 한다.
결정 3. 처리 중인 요청과 겹칠 때 — 409로 밀어낸다
첫 요청이 아직 끝나지 않았는데 같은 키의 재시도가 들어오는 경우가 가장 까다롭다. 두 요청이 동시에 “본 적 없음”을 확인하고 둘 다 처리하면 멱등키가 무력화된다. 그래서 “키 선점”은 원자적이어야 한다 — 보통 유니크 제약이 걸린 행을 INSERT(또는 INSERT … ON CONFLICT DO NOTHING)하는 것으로 잡는다. 선점에 실패한 쪽은 기존 행의 상태를 보고, 아직 IN_PROGRESS면 409 Conflict(초안 §2.7)를 돌려준다. Stripe도 동시 실행 중인 요청과 충돌한 경우는 결과를 저장하지 않으므로 다시 시도할 수 있다고 문서화한다(Stripe).
결정 4. 무엇을 되돌려 줄 것인가 — “같은 바이트” vs “같은 의미”
여기서 사업자마다 선택이 갈린다.
- Stripe API v1: 엔드포인트 실행이 시작된 뒤의 상태 코드와 본문을 저장하고, 같은 키의 재시도엔 그 저장된 응답을 그대로 준다.
500오류까지 포함해서다. 반면 API v2는 성공한 작업을 반복하지 않되, 실패하거나 부분 실패한 작업은 가능하면 완료까지 재시도한 뒤 갱신된 응답을 준다(Stripe). - AWS (EC2 RunInstances): 바이트 동일이 아니라 의미적으로 동등한(semantically equivalent) 응답을 준다. 처음 응답이
pending이었다면 몇 분 뒤 같은ClientToken재시도엔running이 나온다. 같은 인스턴스 ID를 가리키므로 의미는 같다(AWS).
AWS는 또 “이미 존재함(ResourceAlreadyExists)”을 돌려주는 방식을 비판한다. 서버 입장에선 부작용이 없으니 멱등하지만, 클라이언트 입장에선 자신이 만든 건지 남이 만든 건지 모르게 되고 재시도 후 실행 흐름이 바뀌므로 “클라이언트 쪽 부작용”이 생긴다는 것이다(AWS). 재시도를 SDK 안에 숨기려면 첫 응답과 같은 의미의 응답이 필요하다.
결정 5. 키를 얼마나 보관할까 — TTL
영원히 보관할 수는 없다. 저장 비용 문제도 있지만, AWS는 아주 오래전 키와 새 키가 충돌하면 오히려 고객 경험을 해친다고 말한다. EC2는 리소스 수명 + 늦게 도착하는 요청이 다 도착했거나 더는 유효하지 않다고 볼 만한 간격으로 보관한다(AWS). Stripe v1은 최소 24시간이 지난 키를 자동 정리할 수 있고, 정리된 뒤 같은 키가 오면 새 요청으로 처리한다(Stripe). 즉 TTL 밖에서는 멱등성이 보장되지 않으며, IETF 초안이 만료 정책을 문서로 공개하라고 요구하는 이유가 이것이다.
6. 키 기록과 비즈니스 변경은 한 트랜잭션에
AWS 글에서 가장 중요한 문장은 이것이다: 멱등 토큰을 기록하는 일과 요청을 처리하는 모든 변경 연산을 하나의 ACID 연산으로 묶어야 한다. 그렇지 않으면 “토큰은 기록됐는데 리소스는 안 만들어진” 상태나 그 반대가 생긴다(AWS).
같은 DB 안에서 끝나는 연산이면 간단하다.
-- 멱등키 테이블 (PostgreSQL 예시)
CREATE TABLE idempotency_keys (
account_id BIGINT NOT NULL,
idem_key VARCHAR(255) NOT NULL,
request_hash CHAR(64) NOT NULL, -- 결정 2: 같은 키·다른 내용 감지
status VARCHAR(16) NOT NULL, -- IN_PROGRESS / COMPLETED
response_code INT,
response_body JSONB, -- 결정 4: 재생할 응답
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, idem_key) -- 결정 1: 범위
);
BEGIN
INSERT INTO idempotency_keys(account_id, idem_key, request_hash, status)
VALUES (:acct, :key, :hash, 'IN_PROGRESS')
ON CONFLICT DO NOTHING -- 결정 3: 원자적 선점
→ 0 행이면: 기존 행 조회
hash 다름 → 422
IN_PROGRESS → 409
COMPLETED → 저장된 응답 재생
→ 1 행이면: 비즈니스 처리(주문 INSERT 등)
+ idempotency_keys 를 COMPLETED·응답으로 UPDATE
COMMIT
문제는 처리 도중 외부 시스템(PG사 결제 API, 메일 발송)을 부르는 경우다. 외부 호출은 내 DB 트랜잭션으로 롤백되지 않는다. Stripe 엔지니어 Brandur Leach는 이를 “외부 상태 변경(foreign state mutation)”이라 부르고, 요청을 원자적 단계(atomic phase)와 복구 지점(recovery point)으로 쪼개 키 레코드에 진행 단계를 기록한 뒤, 재시도 시 마지막 복구 지점부터 이어가는 설계를 제시한다(Leach 2017). 그리고 같은 글에서, 로컬 ACID 저장소만 건드리는 엔드포인트라면 요청을 트랜잭션 하나에 매핑하는 훨씬 단순한 방법을 쓸 수 있으니 가능하면 그쪽을 택하라고 권한다. 외부 호출 자체도 가능하면 하위 시스템의 멱등키(예: PG사가 받는 키)를 다시 넘겨 연쇄적으로 멱등하게 만든다.
7. 메시지 소비자의 멱등성 — 앞 글로 돌아가서
Outbox + CDC/릴레이 구조에서 브로커는 보통 at-least-once 전달을 보장한다. microservices.io의 Idempotent Consumer 패턴은 그 대가를 이렇게 정리한다: 같은 메시지로 소비자가 여러 번 호출될 수 있으므로, 예컨대 AccountDebited 메시지를 받아 잔액에서 빼는 소비자가 멱등하지 않으면 잔액이 틀어진다(Richardson).
해법은 HTTP 멱등키와 같은 모양이다.
- 소비자는 트랜잭션 안에서 처리한 메시지 ID를
PROCESSED_MESSAGES테이블에INSERT한다. 기본키가(subscriberId, messageId)이므로 이미 처리한 메시지면INSERT가 실패하고, 트랜잭션을 롤백해 무시한다. - 또는 별도 테이블 대신 생성·갱신하는 비즈니스 엔티티 자체에 메시지 ID를 저장할 수도 있다(Richardson).
여기서도 핵심은 6장과 같다 — “처리했음” 기록과 비즈니스 변경이 같은 트랜잭션이어야 한다. 기록만 먼저 커밋하고 처리에서 죽으면 메시지가 유실되고, 처리만 커밋하고 기록에서 죽으면 중복 처리된다.
Kafka 멱등 프로듀서는 이걸 대신해 주지 않는다
Kafka의 enable.idempotence=true는 “프로듀서가 스트림에 각 메시지의 정확히 한 사본만 쓰도록 보장”하며, false면 브로커 장애 등으로 인한 프로듀서 재시도가 중복을 쓸 수 있다고 문서화돼 있다. 활성화 조건은 acks=all, retries > 0, max.in.flight.requests.per.connection ≤ 5이고, 충돌 설정이 없으면 기본으로 켜진다(Apache Kafka 4.1 Producer Configs).
문서가 말하는 보장 범위는 프로듀서 클라이언트 내부 재시도로 생기는 중복이다. 다음은 이 보장 바깥이다.
- Outbox 릴레이가 발행 후 “발행 완료” 표시 전에 죽어서, 재기동 뒤 애플리케이션이 같은 레코드를 새로
send()하는 경우. - 소비자가 처리 후 오프셋 커밋 전에 죽어서 같은 레코드를 다시 읽는 경우.
그러므로 브로커 설정과 별개로 소비자 측 멱등성(7장)은 여전히 필요하다. 앞 글에서 “소비자는 멱등해야 한다”고 한 것은 이 때문이다.
8. 체크리스트
POST/PATCH중 “두 번 일어나면 안 되는” 엔드포인트에 멱등키를 필수로 받는가? (없으면400)- 키 유니크 범위에 계정/테넌트가 포함되는가?
- 요청 지문을 저장해 같은 키·다른 내용을
422로 거부하는가? - 키 선점이 유니크 제약 기반의 원자적 연산인가? 처리 중 재시도에
409를 주는가? - 키 기록과 비즈니스 변경이 같은 트랜잭션인가? 외부 호출은 복구 지점으로 쪼갰는가?
- 재시도 응답 정책(저장된 응답 재생 vs 의미적 동등 응답)이 정해져 있고 문서화됐는가?
- TTL을 정하고 공개했는가? TTL 밖 재사용은 새 요청이라는 점이 명시됐는가?
- 이벤트 소비자가 처리한 메시지 ID를 비즈니스 변경과 같은 트랜잭션에 기록하는가?
- 키에 개인정보(이메일·주민번호 등)를 넣지 않는가? (Stripe도 민감정보를 키로 쓰지 말라고 안내한다 Stripe)
마치며
멱등성은 “중복이 안 생기게 하는 기술”이 아니라 “중복이 생겨도 결과가 같게 만드는 설계”다. 네트워크는 at-least-once까지만 약속하고, exactly-once 효과는 수신자가 ID를 기억하는 것으로 완성된다. HTTP 경계에선 멱등키로, 메시지 경계에선 처리된 메시지 ID로 — 둘 다 “이 ID를 봤는가”를 트랜잭션 안에서 묻는다는 한 가지 원리다.
근거의 한계: 멱등키 구현 방식(응답 재생 vs 의미적 동등, TTL 길이)의 우열을 중립적으로 비교한 실증 연구는 찾지 못했다. 이 글의 권고는 IETF 초안(작업 중 문서)과 AWS·Stripe의 벤더 1차 문서에 기술된 설계 선택을 정리한 것이며, 각 방식의 장단은 해당 문서가 스스로 밝힌 근거를 옮긴 것이다.
References
- J. Jena, S. Dalal, D. Tishkin, “The Idempotency-Key HTTP Header Field”, IETF Internet-Draft draft-ietf-httpapi-idempotency-key-header-08, 2026-10-22 (work in progress).
- R. Fielding, M. Nottingham, J. Reschke (Eds.), “RFC 9110: HTTP Semantics”, §9.2.2 Idempotent Methods, IETF, 2022.
- Malcolm Featonby, “Making retries safe with idempotent APIs”, Amazon Builders’ Library.
- Stripe, “Idempotent requests”, Stripe API Reference.
- Brandur Leach, “Implementing Stripe-like Idempotency Keys in Postgres”, 2017-10-27.
- Chris Richardson, “Pattern: Idempotent Consumer”, microservices.io.
- Apache Kafka, “Producer Configs — enable.idempotence”, Kafka 4.1 documentation.
- 앞 글: MSA · EDA · CDC · CQRS 비교 — 넷은 같은 층의 선택지가 아니다