멱등성을 전제로 한 재시도: 언제 어떤 기준으로 해야할까?

빠른 요약과 정리

  • 재시도는 멱등성이 보장될 때만 안전하다. 멱등키 없이 결제 승인을 재시도한다면 곧바로 이중 결제 위험이 된다.
  • 재시도의 대상은 결과가 불확실한 실패다. 네트워크 타임아웃, 연결 끊김 처럼 서버 상태를 모르는 경우만 재시도하고, 비즈니스 거절처럼 결과가 확정된 실패는 재시도하지 않는다.
  • 재시도 시에 멱등키는 절대 바꾸지 않는다. 키를 새로 만들면 멱등성이 깨져서 중복 처리가 된다. 하의 논리적 요청 = 하나의 멱등키를 재시도 내내 유지한다.
  • 정책은 백오프, 지터, 짧은 최대 횟수, 명시적 타임아웃 결제처럼 민감한 작업일수록 보수적으로 잡는다.

어떤 개념인가요 ?

멱등성

같은 요청을 몇 번 반복하더라도 결과가 항상 동일하다는 성질이다. 단순히 응답이 같은 것이 아니라, 서버 상태(데이터)까지 첫 요청과 동일해야 진짜 멱등하다고 할 수 있다.

  • 결제 승인을 3번 보냈는데, 응답이 다 200이어도, 실제 3번이 결제됐다면 멱등하지 않다.
  • 결제 승인을 3번 보냈는데, 실제 결제는 1번만 일어나고 나머지는 첫 응답을 그대로 돌려준다면 멱등하다.

HTTP 명세상 메서드별 멱등성은 GET, PUT, DELETE는 정의상 멱등하다. 하지만 POST는 호출할 때마다 새 리소스가 들기 때문에 멱등하지 않다.

그래서 멱등키가 필요한 곳은 주로 POST의 경우이다. 토스의 경우도 POST에만 멱등키를 받고, 나머지는 자체 HTTP에서 보장하므로 멱등키를 무시한다.


어떤 문제를 해결하려고 나왔을까? 왜 사용 할까?

네트워크는 언제든 끊기게 되고, 클라이언트가 요청을 보냈는데 응답을 못 받으면 두 가지 가능성이 동시에 존재한다.

  1. 서버가 처리하지 못해서 재시도를 해야함
  2. 서버가 처리는 했지만, 응답만 유실 → 재시도한다면 이중 처리이다.

응답을 받지 못했다는 사실만으로 1,2인지 클라이언트는 구분하기 어렵다.

그래서 멱등성이 있다면, 1,2이든 신경 쓸 필요 없이 그냥 같은 멱등키로 재시도를 하고, 2번이라면 서버가 첫 결과를 그대로 돌려주고, 1였다면 정상 처리가 된다.

즉, 멱등성은 안심하고 재시도할 수 있는 권리를 제공해준다고 생각한다. 재시도 전략과 멱등성을 같이 생각해야할 것이다.


어떻게 동작하나?

멱등키로 중복 식별

토스의 서버 기준, 멱등 요청의 식별은 다음 조합으로 한다.

멱등키(Idempotency-Key) + API 키 + API URL + HTTP 메서드

  • 이 조합이 같다면 중복 요청으로 판단하고 첫 요청과 동일한 응답으로 반환한다.
  • API 키, URL, 메서드가 다르다면 같은 멱등키여도 새 요청으로 받아들인다.

토스 블로그 기준 서버의 내부 의사 흐름

  1. 요청에 멱등키가 있는지 확인
  2. 멱등키 저장소 DB, Redis를 조회한다.
  3. 같은 키 기록이 이미 있으면, 저장된 응답을 그대로 반환한다.
  4. 없다면 실제 처리 후 응답을 멱등키와 함께 저장한다.
  5. 저장 기간이 지나면 같은 키로 새로운 요청이 가능하도록 한다. (멱등키의 유효기간)

저장소가 단순 K-V이라면 Redis로, 복잡하다면 별도 DB/테이블을 권장한다고 한다.


언제 쓰고, 언제 안 쓰나?

쓸 때:

  • 부수 효과가 있고, 중복 실행이 되면 손해가 나는 모든 쓰기 작업

안 쓸 때:

  • 부수 효과가 없고 본질적으로 멱등한 조회 작업
  • 중복 실행되도 무해하고, 멱등 관리 비용이 더 큰 단발성 로직

재시도 정책: 횟수과 관격, 예외처리

재시도 정책

항목권장이유
횟수결제 같은 민감 작업 2~3회, 무한 재시도 금지재시도가 길어지면 사용자 대기·부하 폭증
간격지수 백오프 (예: 1s → 2s → 4s)고정 간격은 회복 안 된 서버를 계속 때림
지터(jitter)백오프에 무작위 흔들기 추가다수 클라이언트 동시 재시도(thundering herd) 방지
타임아웃connection / read 타임아웃 분리 설정read 타임아웃 없으면 영원히 매달림
전체 데드라인누적 재시도 시간 상한 설정총 N초 안에 못 하면 포기 예산 관리

예외처리

재시도를 해야하는것 - 일시적이고 결과가 불확실한 상황

  • 연결 거부, 리셋, read 타임 아웃 SocketTimeoutException, ResourceAccessException
  • 5xx 에러
  • 409 IDEMPOTENT_REQUEST_PROCESSING 첫 요청이 아직 처리중

재시도 안해도 되는것 - 영구적으로 결과가 정해진 상황

  • 4xx 에러
  • 비즈니스의 거절: 잔액 부족, 카드 한도 초과, 한도 정책 위반

주의 사항

멱등 요청이 에러 났을 때, 멱등키를 바꿔서 다시 보내면 안됨 첫 요청이 사실 서버에서 처리됐을 수도 있는데, 새 키로 보내게 된다면 이를 새 요청으로 보고 또 처리를 하게 된다. 즉, 이중 결제가 된다. 그래서 에러가 나더라도 같은 키를 유지하거나, 불확실하다면 결제 조회 API로 실제 상태를 확인하도록 한다.

Spring에서 재시도 구현

  • 멱등키는 요청 진입점에서 1회 생성해서 변수/DB에 보관하고, HTTP 클라이언트 호출과 재시도에서 동일한 키를 사용한다.
  • 재시도는 직접 루프보다 Resilience4j Retry 또는 Spring Retry로 분리한다.
    • @Retryable에서 retryOn / retryExceptions에 재시도가 가능한 예외만 등록하고, 비즈니스 거절을 noRetryOn으로 처리한다.
    • 백오프 설정에 multiplier 지수randomized 지터를 적용시킨다.
  • HTTP 상태별로 분리를 처리한다.
    • 5xx/타임아웃은 재시도로 신호로 변환하고,
    • 4xx 에러는 즉시 실패 예외로 변환하는 에러 핸들러를 둔다.

남에게 설명한다면 어떻게 설명할 것인가?

엘리베이터 버튼"으로 설명한다.

엘리베이터 버튼을 한 번 누르나 열 번 누르나 엘리베이터는 한 번만 온다. 이미 불이 켜진 버튼은 더 눌러도 아무 일이 안 일어난다. 멱등성이 바로 이 버튼의 성질이다.

그런데 만약 누를 때마다 엘리베이터가 한 대씩 따로 출동한다면(=비멱등), 무서워서 다시 못 누른다. 멱등성은 “불안하면 안심하고 다시 눌러도 된다"는 보장이고, 재시도는 그 보장을 믿고 다시 누르는 행위다.

멱등키는 “이 버튼 누름은 아까 그 누름과 같은 건지"를 식별하는 표식이다. 그래서 재시도할 때 표식(키)을 바꾸면, 서버 입장에선 “다른 버튼"이 되어 진짜로 한 번 더 출동한다.


추가 궁금한 질문들

  1. 왜 멱등키를 UUID v4 같은 무작위 값으로 만들라고 할까? 주문번호·시퀀스를 키로 쓰면 뭐가 문제일까? (예측 가능성·충돌·재사용 위험 관점)
  2. 멱등키 저장소를 자체 구현한다면 TTL은 토스의 15일에 맞춰야 하나, 비즈니스 요건에 맞춰야 하나? 저장 시점은 “요청 받자마자 vs 처리 성공 후”?
  3. “요청 처리 중(409)” 상태를 직접 구현한다면 동시성을 어떻게 잡나? (DB 유니크 제약 + 상태 컬럼 vs Redis 분산 락 — Level 3에서 본 락 경합 주제와 연결)
  4. 재시도 + 멱등키 컴포넌트화: 여러 외부 API에 공통으로 쓸 재시도/멱등 처리 로직을 어떻게 추상화할까? (AOP, 데코레이터, Resilience4j 조합)
  5. 결제 승인은 멱등키가 없어도 안전한가? 토스 결제 승인은 paymentKey가 이미 1회성이라 사실상 중복 승인이 막히는데, 그럼 멱등키의 추가 가치는 어디서 나오나? (네트워크 유실 시 동일 응답 회수 관점)
  6. 테스트 전략: 멱등성/재시도를 어떻게 검증할까? 응답 유실 상황을 어떻게 재현하고, 동일 키 중복 호출 시 부수효과가 1회인지 어떻게 단언할까?

참고 (토스페이먼츠 공식 문서)

  • 멱등키 헤더 / 인증 헤더 설정 — docs.tosspayments.com/reference/using-api/idempotency-key
  • 멱등성이 뭔가요? (블로그) — docs.tosspayments.com/blog/what-is-idempotency
  • 코어 API (결제 승인·취소) — docs.tosspayments.com/reference