메인 글에서 다루지 않은 것들을 다루는 글이다.

멱등성(Idempotency)

HTTP 메서드에도 멱등성이 존재한다.
GET, PUT은 여러 번 호출해도 서버 상태가 변하지 않기 때문에 호출 결과가 항상 동일하다. 반면 POST, PATCH 처럼 서버 상태를 바꾸는 메서드는 기본적으로 멱등하지 않기에, 서버 측 구현을 통해 멱등성을 따로 보장해야 한다.
멱등한 API라면, 요청을 여러 번 보내도 결과는 최초 요청과 동일해야 하며, 서버(DB) 상태도 중복해서 바뀌지 않아야 한다. 이 덕분에 네트워크 장애나 재시도 상황에서도 안전하게 API 호출을 수행할 수 있다.
notion image
notion image

결제에서의 멱등성

현재 서비스는 결제 준비 → 결제 승인의 두 단계로 이루어져 있다. 문제는, 승인 단계로 넘어가기 전에 서로 다른 결제 고유번호(tid)가 계속 생성될 수 있다는 점이다.
서버가 이 요청들이 같은 주문인지 식별하지 못한다면, 한 번만 승인해야 할 결제를 여러 번 승인해버리는 오류가 발생할 수 있다.
 
또한, 결제 API에서 멱등성이 보장되지 않으면 다음과 같은 문제가 발생할 수 있다.
  • 결제가 실제로 완료됐는지 매번 운영자가 수동으로 확인해야 함
  • 네트워크 장애나 더블 클릭으로 같은 결제가 여러 번 요청될 위험
  • 사용자는 “결제가 됐는지” 불확실한 상태에서 다시 결제를 시도해야 함 (결제 요청은 정상 처리 → 네트워크 오류로 응답만 유실될 수 있음)
 
따라서 멱등키(idempotency key) 또는 주문 단위의 고유 식별자를 사용해 같은 결제는 한 번만 처리되도록 한다.

멱등한 요청이란

멱등성을 보장하려면 클라이언트가 API 요청마다 멱등 키(Idempotency-Key)를 포함해야 한다.
서버는 같은 멱등키를 가진 요청이 들어오면 이를 중복 요청으로 판단하고, 실제 로직을 다시 수행하지 않고, 첫 번째 요청과 동일한 응답을 그대로 반환한다.
notion image
@RequestHeader(value = "Idempotency-Key") 가 멱등 키에 해당한다. 헤더에 키를 추가했다고 해서, 서버는 같은 요청이 반복된 것인지 어떻게 판단 할 수 있을까? 결제 취소 플로우를 통해 살펴보자.

출처 : 토스페이먼츠
출처 : 토스페이먼츠
Step 1. 멱등키 검증
  • 클라이언트가 요청 헤더에 멱등키(Idempotency-Key)를 포함해 전송
  • 서버는 요청을 수신하면 멱등키 존재 여부를 확인 ⇒ 없을 경우 요청 거부 또는 일반 요청으로 처리
Step 2. 멱등키 기록 관리
  • 멱등키와 요청 결과를 저장하는 전용 테이블 운영(혹은 캐싱)
  • 새로운 요청이 들어오면, 해당 멱등키가 이미 존재하는지 조회
  • 멱등키 기록은 일정 기간 후 만료 또는 삭제 가능 ⇒ 기간이 지나면 같은 키로 새 요청 가능
Step 3. 요청 처리 로직
(3-1) 기존 멱등키 존재 시 → 실제 비즈니스 로직을 다시 수행하지 않고, 저장해 뒀던 응답 데이터를 그대로 반환
(3-2) 새로운 멱등키일 경우 → 정상적으로 비즈니스 로직을 수행하고, 응답 데이터와 함께 멱등키·결과를 DB에 저장
멱등 로직이 도입되면, 중복 요청은 비즈니스 로직을 우회하고 캐시된 응답만 반환되기 때문에 불필요한 DB 트랜잭션과 외부 API 호출이 줄어들어 API 성능 개선 효과도 있다.
 

관련 상태 코드

에러 코드
시나리오
멱등해야 하는 API 요청에 멱등키가 누락됐거나 형식에 맞지 않는 키값이 들어왔을 때
이전 요청 처리가 아직 진행 중인데 같은 멱등키로 새로운 요청이 올 때
재시도 된 요청 본문(payload)이 처음 요청과 다른데 같은 멱등키를 또 사용했을 때

클라이언트 기반 멱등 헤더 방식

현재 클라이언트에서 전달하는 멱등 키(Idempotency Key)를 기준으로, 서버가 요청의 중복 여부를 판단하여 멱등성을 보장하는 방식을 사용 중 이다.

1. 클라이언트 기반 멱등 헤더 방식 (현재)

구분
내용
방식
클라이언트가 생성한 멱등 키(Idempotency Key)를 요청 헤더로 전달하고, 서버는 해당 키의 존재 여부로 중복 요청을 판단
장점
- 네트워크 장애 시 동일 키로 재시도 가능 (중복 방지) - 서버는 키 조회만 수행 → 로직 단순- 중복 요청에 대해 즉시 응답 가능 (UX 개선)
단점
- 클라이언트가 고유 키를 생성·관리해야 하는 부담 - 키 재사용/조작 시 정상 요청이 중복으로 처리될 위험- 클라이언트 신뢰성에 의존

2. 대안 방식 비교 — 서버 토큰 발행, 요청 파라미터 해싱

방식
동작 방식
장점
단점 (Trade-offs)
서버 토큰 발행 방식
서버가 멱등 토큰을 생성·저장 후 클라이언트에 전달, 이후 해당 토큰으로 요청 수행
- 키 생성 주체가 서버 → 신뢰성 높음 - 클라이언트 오류/조작에 대한 방어 가능
- 토큰 발급을 위한 추가 RTT 발생 - 토큰 상태 및 만료 관리 필요 - 발급 시점/정책 설계 복잡도 증가
요청 파라미터 해싱 방식
요청 데이터를 해시로 변환하여 멱등 키로 사용
- 별도 키 생성 없이 구현 가능 - 서버 중심 제어 가능
- 동일 요청 판단 기준 모호 - 작은 값 차이에도 다른 요청으로 인식 - 의도적 재요청 구분 어려움
 

멱등성 보장이 필요한 결제 기능

구분
페이로드
멱등성 목적
결제 준비 (/ready)
userId + orderId
동일 주문에 대해 중복 tid 생성 방지
결제 승인 (/approve)
tid + pg_token
중복 승인 방지 및 이중 결제 차단
결제 취소 (/cancel)
tid + cancelId
중복 환불 방지 및 이미 취소된 상태 유지

멱등성 코드 구현 흐름

IdempotencyRecord - 멱등성 상태 저장

멱등 키를 PK로 저장해, 동일 키에 대한 동시 Insert를 DB 레벨에서 차단했다. 레코드에는 요청 Payload, 처리 상태(PROCESSING, DONE), 최종 응답 값을 함께 저장했다.
여기서는 PK + unique를 같이 둘 필요는 없고, PK만으로도 유일성 보장이 충분하다.
 

validate() - 멱등키 선점 및 재요청 검증

멱등성 레코드 처리는 비즈니스 트랜잭션과 분리하여 REQUIRES_NEW를 할당하여, 본 결제 트랜잭션이 롤백되더라도, 멱등키 상태와 요청 이력은 독립적으로 관리할 수 있다.
이 구조로 다음을 보장했다.
  • 동일 시점 병렬 요청 차단
  • 중복 요청 재실행 방지
  • 멱등 키 재사용 시 payload 불일치 감지
 

cachePayload(key, payload) — 요청 본문 일관성 보장

이후 동일한 멱등키로 들어오는 요청에 대해
  • 이전 payload와 다르면 즉시 422(멱등성 불일치) 발생
  • 동일한 요청인지 안전하게 비교 가능
 

saveResponse() - 완료 응답 저장

결제가 성공하면 응답 DTO를 직렬화해 저장하고, 상태를 DONE으로 전환한다.
이후 동일한 멱등 키로 요청이 다시 들어오면, 실제 결제 로직을 다시 수행하지 않고 기존 응답을 그대로 반환한다.
 

결제 승인 후 아이템 지급 — Facade 기반 오케스트레이션

notion image
결제 성공 이후의 후속 처리는 단순히 아이템만 지급하는 문제가 아니다.
실제 로직에서는 지급 가능 여부 판단, 아이템 지급, 예외 상황에서의 보상 처리(결제 취소), 그리고 장애 상황에 대비한 Outbox 기록까지 함께 고려해야 한다.
이 과정에서 각 서비스가 개별적으로 호출되면, 결제 이후 흐름이 여러 곳에 분산되어 호출 순서 보장, 트랜잭션 경계 관리, 실패 시 보상 처리 책임이 섞이게 될 수 있다.
이를 방지하기 위해, 결제 성공 이후에 오케스트레이션 역할을 할 수 있도록 Facade 패턴을 도입하였다. PaymentItemGrantFacade는 결제 성공 이후 필요한 여러 처리 단계를 하나의 진입점에서 조율하는 오케스트레이터 역할을 한다.

왜 Facade가 필요했는가

notion image
이 구조에서 Facade 패턴을 적용한 핵심 이유는 복잡한 후속 처리 흐름을 상위 계층에서 단일한 유스케이스로 캡슐화하기 위해서다.
만약 컨트롤러나 결제 서비스가 직접 아래와 같은 세부 로직을 모두 알아야 했다면 구조가 빠르게 비대해진다.
  • 지급 가능 여부 판단
  • 아이템 지급 호출
  • 실패 시 트랜잭션 롤백
  • 보상 트랜잭션으로 결제 취소 처리
  • Outbox 기록
  • 응답 반환 시점 제어
이런 책임이 상위 계층에 퍼지면, 결제 성공 이후 흐름이 바뀔 때마다 여러 서비스가 함께 수정되어야 하고, 장애 대응이나 보상 처리 로직도 중복되기 쉽다.
반면 PaymentItemGrantFacade를 두면 상위 계층은 단순히 “결제 성공 후 지급 처리 수행” 의 책임만 갖게 되고, 세부 분기와 처리 순서는 Facade가 관리하므로 복잡성은 내부로 감춰지고 변경 영향 범위도 줄어든다.
 

트랜잭션 관점에서의 Facade 패턴의 의미

이 구조의 또 다른 핵심은 트랜잭션 경계를 Facade 수준에서 통제했다는 점이다. 지급 가능한 경우에는 아이템 지급과 Outbox 기록을 같은 물리 트랜잭션 안에서 처리하여 정합성을 확보했다.
반대로 지급 불가인 경우에는 해당 트랜잭션을 롤백하고, 별도의 보상 흐름으로 전환하여 결제 취소 API 호출까지 이어지도록 설계했다.
즉, Facade는 단순한 메서드 묶음이 아니라,
  • 어떤 경우에 커밋할지
  • 어떤 경우에 롤백할지
  • 어떤 후속 작업을 동기/비동기로 연결할지
를 하나의 흐름으로 조정하는 계층이다.
 

PG사 호출 — 동기 or 비동기

PG사 호출 방식은 동기 / 비동기 구조를 모두 검토했으나, 최종적으로는 동기 호출 방식을 선택하였다.
결제는 사용자 요청 흐름 내에서 즉시 성공/실패를 판단해야 하는 특성이 강하고, 비동기 구조로 전환할 경우 추가적인 상태 관리, 폴링 또는 콜백 처리, 사용자 응답 지연 등의 복잡도가 증가하기 때문이다.
notion image
다만 동기 호출은 외부 API 지연 시 스레드가 블로킹되는 구조적 한계가 존재한다. 특히 PG사 응답 지연이 길어질 경우, 커넥션 점유 시간이 늘어나면서 전체 시스템의 처리량 저하로 이어질 수 있다.
이를 해결하기 위해 단순히 구조를 바꾸는 대신, 지연을 제어하는 방향으로 접근하였다.
  • Connection / Read Timeout을 명시적으로 설정하여 장시간 대기 방지
  • 서킷 브레이커 적용으로 지연 구간을 빠르게 차단 (Fail-Fast)
  • Fallback 응답 + 비동기 배치 재처리 구조로 정합성 보완
이러한 전략을 통해, 동기 호출 구조를 유지하면서도 스레드 점유 시간과 장애 전파를 최소화하고, 안정적인 응답 흐름을 확보할 수 있었다.