멱등한 결제 API는 처음부터 끝까지 하나의 계약이어야 한다
안전한 재시도는 API 앞단의 중복 제거만으로 완성되지 않는다. 접수부터 비동기 작업, 체인 제출, 웹훅, 대사, 운영자 복구까지 하나의 결제 의도를 같은 식별자로 추적해야 한다.
목차
요청이 서버를 떠난 뒤부터 문제가 시작된다
클라이언트가 결제를 요청했지만 응답 대신 타임아웃을 받았다. 이때 알 수 있는 것은 통신이 끝나지 않았다는 사실뿐이다. 서버가 요청을 아예 받지 못했을 수도 있고, 결제를 저장한 뒤 응답만 유실됐을 수도 있으며, 아직 처리가 진행 중일 수도 있다. 무작정 다시 요청하면 한 번의 결제 의도가 두 건의 이체로 이어질 수 있다. 반대로 재시도하지 않으면 정상 요청이 중간에 멈춘 채 남는다.
HTTP에서 멱등성은 같은 요청을 한 번 보내든 여러 번 보내든 서버에 의도한 효과가 같다는 뜻이다. 결제 생성에 주로 사용하는 POST에는 이 성질이 저절로 생기지 않는다. API가 계약으로 만들어야 한다.
멱등성은 중복 요청을 거르는 장치가 아니다. 하나의 결제 의도가 접수, 비동기 실행, 재시도, 웹훅, 운영자 복구를 거치는 동안에도 같은 식별자를 유지하게 만드는 규칙이다.
따라서 API 앞단의 캐시만으로는 부족하다. 큐 작업자와 블록체인 제출, 웹훅 재전송까지 같은 의도를 추적할 수 있어야 한다.
결제 의도에 변하지 않는 식별자를 부여한다
클라이언트는 새로운 결제 의도마다 멱등성 키를 만들고, 같은 의도를 재시도할 때만 그 키를 다시 사용해야 한다. 서버는 키 문자열 하나를 전역으로 비교하지 말고, 어느 가맹점의 어떤 작업인지까지 묶어 범위를 정해야 한다.
실무에서 쓸 수 있는 고유성 경계는 다음과 같다.
(merchant_id, operation_name, idempotency_key)
저장할 때는 요청 본문을 정규화한 지문도 함께 남긴다. 같은 키로 금액, 자산, 수신 주소, 상점이 다른 요청을 보내는 실수를 막기 위해서다. 단순히 JSON 원문을 비교하면 필드 순서만 달라도 다른 요청으로 오해할 수 있으므로, 어떤 방식으로 정규화하는지 계약에 포함해야 한다.
이 기준이 있으면 서버의 응답도 일관되게 정할 수 있다.
| 들어온 요청 | 서버의 처리 |
|---|---|
| 처음 보는 키와 유효한 본문 | 작업을 한 건만 만들고 그 식별자를 반환 |
| 기존 키와 같은 본문 지문 | 기존 작업 또는 현재 상태를 반환 |
| 기존 키와 다른 본문 지문 | 충돌로 거절하고 키의 의미를 바꾸지 않음 |
| 기존 키의 작업이 처리 중 | 같은 작업을 반환하고 두 번째 작업자는 시작하지 않음 |
Stripe의 공개 API도 같은 원리를 문서화한다. 키는 재시도를 식별하고, 첫 결과를 보관하며, 같은 키에 다른 매개변수가 들어오면 오류로 처리한다. 세부 구현은 달라도 계약이 답해야 할 질문은 같다. “이 요청은 앞선 요청과 같은 의도인가, 그렇다면 그 의도의 현재 결과는 무엇인가?”
일을 시작하기 전에 접수 사실부터 저장한다
멱등성 기록과 실제 결제 작업은 하나의 트랜잭션으로 저장하는 편이 안전하다. 영속 상태를 만들기 전에 접수 응답을 보내면 프로세스 장애 때 요청을 받았다는 증거가 사라진다. 반대로 백그라운드 작업을 먼저 시작하고 나중에 저장하면 같은 작업이 두 번 실행될 수 있다.
일반적인 접수 트랜잭션에는 다음 세 가지가 함께 들어간다.
- 멱등성 기록
- 고유 식별자를 가진 결제 작업
- 작업자에게 전달할 아웃박스 이벤트 또는 그에 준하는 영속 지시
이 트랜잭션이 커밋된 뒤에만 접수 응답을 돌려준다. 아웃박스 이벤트 발행이 늦어지거나 여러 번 시도되더라도 작업자는 같은 결제 식별자를 보게 되고, 이미 존재하는 상태로 수렴할 수 있다.
이 과정에서 세 가지 식별자를 구분해야 한다.
- 요청 식별자 — 클라이언트가 보낸 멱등성 키
- 작업 식별자 — 실제로 처리할 결제 또는 이체
- 시도 식별자 — API, 큐, 체인 제출, 웹훅의 개별 시도
시도는 여러 번 생길 수 있다. 결제 작업은 한 건이어야 한다.
재시도 여부는 상태 머신이 결정한다
멱등성 테이블만으로는 실패한 결제를 다시 실행해도 되는지 판단할 수 없다. 그 결정은 결제 상태 머신의 책임이다.
예를 들어 접수 → 처리 중 → 확인됨 → 완료는 단순한 불리언 값 묶음이 아니다. 각 전이에는 선행 조건, 영속 시각, 근거가 필요하다. 같은 큐 메시지를 두 작업자가 받더라도 동일한 결제 행을 잠그거나 조건부 갱신한 뒤, 현재 상태에서 허용되는 전이만 한 번 적용해야 한다.
실패도 종류를 나눠야 한다. 잘못된 수신 주소는 최종 실패가 될 수 있다. 일시적인 공급자 타임아웃은 재시도할 수 있다. 외부 제출 뒤 결과를 모르는 경우는 둘 다 아니다. 다시 효과를 일으키기 전에 조회하거나 대사해야 한다.
안전한 원칙을 한 줄로 줄이면 다음과 같다.
상태 조회는 자유롭게 재시도한다.
효과를 일으키는 작업은 이전 시도가 반영되지 않았다고 확인된 경우에만 다시 실행한다.
블록체인 결제에서는 이 구분이 특히 중요하다. 제출 API의 응답은 잠정 결과일 수 있고, 검증 또는 최종 확정된 원장의 결과가 나중에 도착한다. XRP Ledger 공식 문서는 제출과 검증을 분리하고, 제출 전에 트랜잭션 정보를 저장하라고 권고한다. Ethereum도 브로드캐스트, 블록 포함, 정당화, 최종 확정을 구분한다. 따라서 “RPC가 받았다”를 곧바로 “결제가 끝났다”로 바꾸면 안 된다.
큐와 웹훅에도 독립된 계약이 필요하다
큐와 웹훅은 같은 메시지를 한 번 이상 전달할 수 있다. 중복은 예외 상황이 아니라 전달 방식의 일부다.
내부 이벤트에는 변하지 않는 이벤트 식별자를 두고, 각 소비자가 어떤 이벤트를 반영했는지 기록한다. 중복 처리 기록과 실제 상태 변경은 같은 트랜잭션에 있어야 한다. 처리 완료 표시를 먼저 남기면 작업이 유실될 수 있고, 상태를 바꾼 뒤 표시하다 장애가 나면 같은 작업을 다시 적용할 수 있다.
외부 웹훅에서는 업무 이벤트와 전송 시도를 분리한다.
- 이벤트 식별자와 본문은 유지한다.
- HTTP 전송마다 시각과 결과를 별도로 남긴다.
- 재시도 횟수를 제한하고 지수 백오프와 지터를 적용한다.
- 수신자는 이벤트 식별자로 중복을 제거할 수 있어야 한다.
- 운영자가 최종 실패한 전송을 다시 보내더라도 새 업무 이벤트가 생기지 않아야 한다.
멱등성이 무한 재시도를 뜻하는 것은 아니다. 재시도 예산, 최종 상태, 실패 큐 또는 운영자 처리 목록이 있어야 고장 난 의존성이 전체 시스템의 트래픽을 증폭시키지 않는다.
복구 절차도 제품 계약에 포함한다
정상 흐름에서만 설명 가능한 결제 시스템은 운영 가능한 시스템이 아니다. 운영자는 멱등성 키, 결제 식별자, 외부 트랜잭션 해시, 웹훅 이벤트 식별자로 검색할 수 있어야 한다. 타임라인에는 접수, 각 실행 시도, 외부 관측, 상태 전이, 수동 조치를 구분해 보여줘야 한다.
대사는 내부 상태와 권위 있는 외부 원장을 비교하고 차이를 명시적으로 만들어야 한다. 금액을 몰래 고치거나 두 번째 이체를 만들어서는 안 된다. 수동 복구도 멱등적이어야 한다. 재전송 버튼은 기존 이벤트나 작업을 가리켜야 하고, 사유와 실행자를 기록해야 한다.
목표는 중복 결제를 막는 데서 끝나지 않는다. 결과가 불분명한 상황을 추측 없이 설명하고 복구할 수 있어야 한다.
멱등성만으로 해결되지 않는 문제
멱등성은 필요하지만, 정확히 한 번 실행을 보장하지도 않고 회계 통제를 대신하지도 않는다.
다음 문제는 별도의 설계가 필요하다.
- 보관 기간이 지난 키를 다시 사용하는 경우
- 같은 사람의 의도에 서로 다른 키가 두 개 발급된 경우
- 외부 공급자가 안정적인 요청 식별자를 제공하지 않는 경우
- 타임아웃 뒤 외부 효과가 있었는지 알 수 없는 경우
- 체인의 최종성, 재구성, 트랜잭션 교체 규칙
- 잘못된 금액을 정확히 한 번 처리한 경우
- 결제, 원장, 준비금, 정산 기록 사이의 불일치
이 문제에는 도메인 제약, 외부 상태 관측, 변경 불가능한 회계 기록, 운영자 검토가 필요하다.
설계 검토 체크리스트
결제 API를 멱등하다고 부르기 전에 다음을 확인해야 한다.
- 키의 범위가 가맹점과 작업 단위로 정해져 있는가?
- 요청 본문의 지문을 저장하고, 같은 키에 다른 본문이 오면 거절하는가?
- 접수, 결제 작업, 비동기 전달 의도가 영속적으로 함께 저장되는가?
- 큐 소비자가 중복 메시지를 받아도 같은 결과로 수렴하는가?
- 외부 제출에 안정적인 참조값과 ‘결과 불명’ 상태가 있는가?
- 웹훅 이벤트와 개별 전송 시도를 다른 식별자로 관리하는가?
- 재시도 한도, 백오프, 최종 실패, 수동 재생 절차가 보이는가?
- 운영자가 전체 이력을 재구성하고 외부 원장과 대사할 수 있는가?
BEATOZ CPG, Treasury, XRPL Payment Gateway 사례에서는 이 문제가 서로 다른 경계에서 나타난다. API 접수, 결제 상태 오케스트레이션, 큐 작업, 체인 검증, 웹훅 전송, 정산 인계가 그 경계다. 특정 구현보다 중요한 원칙은 하나다. 모든 경계가 최초 결제 의도의 식별자를 보존할 때에만 멱등성이 끝까지 작동한다.
수정 기록
첫 공개본. 공개 표준과 공식 문서, 검증된 포트폴리오 프로젝트 설명만 근거로 사용했다.