이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Idempotency Key는 retry와 timeout 장애에서 어떤 문제를 해결하는가?
- 같은 key로 다른 payload가 들어오거나 동시에 들어오면 어떻게 처리해야 하는가?
- 결제, 주문, 예약 API에서 idempotency 저장소는 어떤 상태와 제약을 가져야 하는가?
개요
Idempotency Key는 “같은 의도의 요청”을 하나의 처리 결과로 수렴시키기 위한 키다. 네트워크 timeout, client retry, 모바일 재전송, 사용자의 버튼 중복 클릭이 있어도 주문, 결제, 예약, 포인트 적립이 두 번 실행되지 않게 만드는 실무 장치다.
중요한 점은 key 자체가 마법이 아니라는 것이다. key를 어디에 저장하는지, payload fingerprint를 어떻게 검증하는지, 처리 중 상태를 어떻게 다루는지, 완료 결과를 얼마 동안 재사용하는지가 설계의 핵심이다.
왜 백엔드 개발자가 알아야 하는가
timeout 장애에서 caller는 실패를 보지만 server는 성공했을 수 있다. 이 상태에서 caller가 retry하면 같은 업무 요청이 두 번 실행될 수 있다.
- 결제 승인 요청이 read timeout 났지만 결제사는 승인 완료했다.
- 주문 생성 응답을 못 받은 모바일 앱이 같은 주문을 다시 보낸다.
- 예약 API가 502를 반환했지만 downstream 좌석 잠금은 성공했다.
- queue consumer가 ack 전에 죽어 같은 message를 다시 처리한다.
- 사용자가 결제 버튼을 연속 클릭해 같은 요청이 동시에 들어온다.
Idempotency Key는 이런 “응답 불명확성”을 다룬다. 단순히 중복 클릭 방지가 아니라 distributed system에서 성공 여부를 모르는 요청을 안전하게 재시도하기 위한 장치다.
요청 흐름에서의 위치
idempotency는 side effect가 발생하기 전에 검사되어야 한다.
Client creates Idempotency-Key
→ API receives request
→ validate key and request fingerprint
→ reserve key with unique constraint
→ perform business side effect
→ store final result
→ same key retry returns stored result외부 결제나 주문 생성 같은 side effect를 먼저 실행한 뒤 key를 저장하면 race condition이 생긴다. “처리 예약”이 먼저이고, side effect는 그 다음이다.
원리
idempotency 저장소에는 보통 다음 정보가 필요하다.
| 필드 | 의미 |
|---|---|
| key | client가 보낸 idempotency key |
| scope | 사용자, merchant, endpoint 등 key가 유효한 범위 |
| request_fingerprint | method, path, normalized body hash |
| status | processing, succeeded, failed 등 처리 상태 |
| response_status | 완료 후 반환할 HTTP status |
| response_body | 완료 후 재사용할 응답 |
| resource_id | 생성된 주문/결제 id |
| expires_at | key 보관 만료 시간 |
| created_at / updated_at | 추적과 청소 기준 |
같은 key로 같은 요청이 다시 오면 저장된 결과를 반환한다. 같은 key로 다른 payload가 오면 409 Conflict나 422 계열로 거부하는 편이 안전하다.
상태 전이
idempotency key는 단순 boolean보다 상태를 가지는 편이 좋다.
absent
→ processing
→ succeeded
→ failed_retryable
→ failed_final
→ expiredprocessing 상태에서 같은 key가 다시 들어오면 정책을 정해야 한다.
- 바로 409/425/202를 반환하고 나중에 조회하게 한다.
- 짧게 대기한 뒤 완료 결과를 반환한다.
- 처리 중 요청의 lock timeout을 둔다.
정답은 서비스 특성에 따라 다르지만, 무작정 같은 처리를 다시 실행하면 idempotency가 깨진다.
실무에서 자주 만나는 문제
- key만 같으면 무조건 같은 요청으로 본다.
- 악의적이거나 실수로 다른 body가 같은 key로 들어올 수 있다.
- request fingerprint를 저장하고 다르면 거부한다.
- key 저장 전에 외부 side effect를 실행한다.
- 동시에 들어온 요청 두 개가 모두 외부 결제를 호출할 수 있다.
- unique constraint로 먼저 key를 선점한다.
- 실패 결과를 어떻게 저장할지 정하지 않는다.
- 일시 실패와 최종 실패를 구분하지 않으면 retry 정책이 흔들린다.
- retry 가능한 실패는 status와 재시도 가능성을 별도로 남긴다.
- key TTL이 너무 짧다.
- client가 늦게 retry했을 때 중복 처리가 발생할 수 있다.
- 업무 위험도에 맞는 보관 기간을 둔다.
- key scope가 없다.
- 다른 사용자나 다른 endpoint의 같은 key가 충돌할 수 있다.
- user id, endpoint, merchant id 등과 함께 unique를 잡는다.
디버깅 방법
중복 처리 사고에서는 idempotency table과 business table을 같이 본다.
select *
from idempotency_keys
where key = 'req_20260630_abc'
order by created_at desc;
select *
from orders
where idempotency_key = 'req_20260630_abc';확인할 것은 다음이다.
- 같은 key가 여러 scope에서 쓰였는가?
- 같은 key에 다른 fingerprint가 들어왔는가?
processing상태가 너무 오래 남아 있는가?- business resource가 두 개 생성되었는가?
- timeout이 난 첫 요청과 retry 요청의 request id가 서로 연결되는가?
- 외부 결제사의 transaction id가 내부 key와 매핑되어 있는가?
로그에는 최소한 key, fingerprint, status transition, resource id를 남긴다. 단, 결제 정보나 개인정보 원문을 fingerprint 대신 그대로 남기면 안 된다.
코드로 확인하기
저장소 제약은 코드보다 먼저 중요하다.
create table idempotency_keys (
id bigserial primary key,
scope varchar(100) not null,
key varchar(200) not null,
request_fingerprint varchar(128) not null,
status varchar(30) not null,
response_status integer,
response_body jsonb,
resource_id varchar(100),
expires_at timestamptz not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (scope, key)
);요청 처리 흐름은 대략 이렇게 잡는다.
@Transactional
public OrderResponse createOrder(CreateOrderCommand command, String idempotencyKey) {
String scope = command.userId() + ":POST:/orders";
String fingerprint = fingerprint(command);
IdempotencyRecord record = idempotencyRepository.findByScopeAndKeyForUpdate(scope, idempotencyKey)
.orElseGet(() -> idempotencyRepository.insertProcessing(scope, idempotencyKey, fingerprint));
if (!record.hasSameFingerprint(fingerprint)) {
throw new IdempotencyConflictException();
}
if (record.isSucceeded()) {
return record.toOrderResponse();
}
if (record.isProcessingByOtherRequest()) {
throw new RequestAlreadyProcessingException();
}
Order order = orderService.create(command);
idempotencyRepository.markSucceeded(record.id(), 201, order.id(), toJson(order));
return OrderResponse.from(order);
}이 예시의 관찰 포인트는 다음이다.
- unique constraint와 transaction으로 동시에 들어온 요청을 막는다.
- 같은 key라도 fingerprint가 다르면 거부한다.
- 완료된 요청은 동일 결과를 반환한다.
- 처리 중 요청을 다시 실행하지 않는다.
주니어가 자주 하는 오해
- idempotency key를 header로 받기만 하면 충분하다고 생각한다.
- key 저장소, unique constraint, fingerprint 검증, 상태 전이가 있어야 한다.
- POST는 idempotent하지 않으니 retry를 절대 하면 안 된다고 생각한다.
- POST 자체는 idempotent가 아니지만 idempotency key를 설계하면 같은 의도의 중복 실행을 막을 수 있다.
- key를 서버가 매번 새로 만들면 된다고 생각한다.
- retry 요청이 같은 key를 재사용해야 하므로 client나 호출자가 같은 의도에 대해 같은 key를 보내야 한다.
- 완료 응답만 저장하면 된다고 생각한다.
- 처리 중, 실패, 만료 상태가 없으면 race와 장애 복구가 어려워진다.
시니어의 설계 판단 기준
- idempotency scope를 endpoint, user, merchant, tenant 기준으로 명확히 잡는다.
- key TTL은 업무 위험도와 client retry 기간을 기준으로 정한다.
- 외부 결제사나 예약 시스템의 transaction id와 내부 idempotency key를 매핑한다.
- processing 상태가 오래 남는 경우를 위한 recovery job과 운영 절차를 둔다.
- idempotency key는 중복 실행 방지 장치이지 인증이나 권한 검증 장치가 아니다.
인프라 협업 포인트
- API Gateway나 WAF가
Idempotency-Key헤더를 제거하지 않는지 확인한다. - CDN cache가 idempotency key를 cache key로 잘못 다루지 않도록 한다.
- 여러 region active-active 구조에서는 idempotency 저장소의 일관성 수준을 논의해야 한다.
- queue 기반 처리에서는 message id, deduplication id, idempotency key의 관계를 맞춘다.
실전 팁
- 결제, 주문, 예약, 포인트, 쿠폰 발급 API는 idempotency key 요구 여부를 API 문서에 명확히 쓴다.
- 같은 key로 같은 payload를 보냈을 때, 같은 key로 다른 payload를 보냈을 때, 동시에 같은 key가 들어왔을 때를 테스트한다.
- 결과 재사용 응답에는 원래 처리 결과와 동일한 status/body를 반환할지, 200으로 조회 결과를 줄지 정책을 정한다.
- key 보관 만료 후 retry가 들어오는 rare case를 문서화한다.
- 운영 화면에서 key로 business resource와 외부 transaction id를 찾을 수 있게 한다.
위험 신호!
- idempotency key table에 unique constraint가 없다.
- 같은 key와 다른 payload를 구분하지 않는다.
- 외부 결제 호출 후에 key를 저장한다.
- processing 상태가 무한히 남아도 감지하지 못한다.
- 중복 처리 사고가 났는데 key, request id, external transaction id를 연결할 수 없다.
확인 질문
확인 질문
- Idempotency Key가 해결하는 핵심 문제는 무엇인가?
- timeout이나 retry로 같은 의도의 요청이 여러 번 들어와도 side effect가 한 번으로 수렴하게 하는 것이다.
- 같은 key로 다른 payload가 들어오면 어떻게 해야 하는가?
- 저장된 request fingerprint와 비교해 다르면 409 Conflict 등으로 거부해야 한다.
- key 저장은 외부 결제 호출 전과 후 중 언제 해야 하는가?
- 외부 side effect 전에 unique constraint로 key를 먼저 선점해야 race condition을 줄일 수 있다.