결제 시스템의 멱등성과 트랜잭션 무결성
중복 도달한 결제 요청을 한 번만 과금하는 멱등키 설계부터, 2PC와 Saga와 Outbox로 갈리는 분산 트랜잭션 선택지, 마지막에 남는 불일치를 걷어내는 대사까지 정리합니다.
결제 요청은 몇 번이든 다시 도착할 수 있고, 과금이 한 번만 남게 만드는 책임은 전부 서버 쪽에 있습니다.
같은 요청이 두 번 도착할 때
결제 요청은 한 번 보내도 여러 번 도착할 수 있습니다. 네트워크가 끊기거나 타임아웃이 나면 클라이언트는 승인 성공 여부를 알 수 없고, 확인할 방법은 재시도뿐입니다. 사용자가 결제 버튼을 연달아 누르는 경우도 같은 결과를 만듭니다.
서버가 이를 그대로 받으면 한 주문에 두 건의 승인이 남습니다. 카드 결제에서 이 상황이 특히 나쁜 이유는 자금이 실제로 움직이는 시점이 뒤로 밀려 있기 때문입니다. 인가(Authorization)는 한도만 잡고, 매입(Capture)에서 금액이 확정되며, 정산(Settlement)에서 자금이 배치로 이동합니다. 화면에 뜬 성공은 회계상 완료가 아니므로 중복은 몇 시간 뒤 정산 단계에서야 드러납니다.
HTTP 메서드부터 이 문제를 안고 있습니다. RFC 7231은 GET·PUT·DELETE를 멱등한 메서드로 규정합니다. 여러 번 호출해도 서버 상태가 같은 값으로 수렴하기 때문입니다. 반면 결제 생성과 취소가 쓰는 POST와 PATCH는 호출마다 상태가 달라지므로 멱등하지 않습니다.
멱등성(idempotency)은 처음 수행한 뒤 몇 번을 다시 적용해도 결과가 변하지 않는 성질입니다. 안전성(safety)과는 구분해야 합니다. 안전한 메서드는 리소스를 바꾸지 않으므로 항상 멱등하지만, PUT과 DELETE처럼 멱등하면서 리소스를 바꾸는 메서드도 있습니다. POST에 멱등성을 주려면 프로토콜이 아니라 서버가 직접 구현해야 합니다.
멱등키가 흡수하는 재시도
멱등키(Idempotency Key)는 클라이언트가 요청에 붙이는 고유 식별자입니다. 서버는 같은 키의 요청을 다시 받으면 실제 처리를 건너뛰고 첫 요청의 응답을 그대로 돌려줍니다. UUID v4처럼 충돌 가능성이 낮은 무작위 값을 쓰고, 재시도할 때는 같은 키를 유지해야 합니다.
키를 어디에 실을지는 본문·쿼리 파라미터·헤더 중에 고를 수 있지만, IETF는 요청 헤더를 표준으로 제안합니다. Stripe가 모든 POST에 Idempotency-Key 헤더를 도입한 이후 토스페이먼츠를 포함한 여러 결제사가 같은 방식을 씁니다. 헤더에 두면 도메인 로직에 닿기 전에 앞단에서 요청을 끊을 수 있습니다.
서버 동작은 저장소 조회 결과에 따라 세 갈래로 갈립니다.
| 멱등키 상태 | 요청 본문 비교 | 서버 동작 |
|---|---|---|
| 기록 없음 | — | 정상 처리 후 결과를 키와 함께 저장하고 응답 |
| 기록 있음 | 첫 요청과 같음 | 비즈니스 로직을 재실행하지 않고 저장된 응답 재생 |
| 기록 있음 | 첫 요청과 다름 | 클라이언트 오류로 보고 반려 |
성공 응답만 저장하면 절반짜리 방어가 됩니다. 승인 거절 결과가 저장되지 않으면 재시도가 새 요청으로 처리되어 두 번째 시도에서 승인이 날 수 있습니다. 실패도 확정된 결과이므로 함께 저장해야 합니다.
키의 유효 범위도 정해야 합니다. 토스페이먼츠는 멱등키 하나만 보지 않고 멱등키·API 키·API 주소·HTTP 메서드 조합으로 동일 요청 여부를 판정합니다(토스페이먼츠 개발자센터, 2023-01). 넷 중 하나라도 다르면 같은 키를 써도 새 요청이 됩니다. 이 범위를 두지 않으면 다른 가맹점이 우연히 같은 키를 보냈을 때 남의 응답을 받게 됩니다.
보관 기간은 사실상 멱등키의 유효기간입니다. 저장된 응답을 24시간 안팎 보관하고 그 뒤 정리하는 방식이 통상적입니다. 기간이 지나면 같은 키가 새 요청으로 처리되므로, 클라이언트 재시도 정책의 창이 보관 기간보다 짧아야 합니다.
멱등키만으로 막지 못하는 동시 진입
저장소 조회와 결과 저장 사이에는 틈이 있습니다. 두 요청이 같은 키로 거의 동시에 들어오면 둘 다 기록 없음을 읽고 함께 승인으로 넘어갑니다. 애플리케이션 캐시 조회만으로는 이 찰나를 막을 수 없다는 점을 카카오페이 기술 블로그가 지적합니다.
대응은 층을 나눠 쌓습니다.
- 분산 락: Redis SETNX나 Redisson으로 멱등키에 뮤텍스를 걸어 선점한 요청만 진입시킵니다.
- DB 유니크 제약:
UNIQUE(order_id, charge_status)같은 제약을 두면 늦게 도착한 트랜잭션이 제약 위반으로 롤백됩니다. - 낙관적 락:
PENDING → CHARGING → CHARGED상태머신 UPDATE에 version 조건을 붙여 이중 전이를 막습니다.
셋의 역할은 다릅니다. 락은 불필요한 승인 호출을 앞에서 걷어내는 성능 장치이고, 정합성의 최종 보증은 DB 유니크 제약입니다. Redis가 죽어도 제약은 DB 안에 남아 동작하기 때문입니다.
동시 진입과 요청 불일치는 응답 코드로 구분해 알려야 합니다. IETF 명세는 세 가지를 제안합니다.
| 응답 | 상황 |
|---|---|
| 400 Bad Request | 멱등키가 누락되었거나 형식이 맞지 않음 |
| 409 Conflict | 같은 키의 이전 요청이 아직 처리 중 |
| 422 Unprocessable Entity | 같은 키인데 요청 본문이 첫 요청과 다름 |
409는 잠시 뒤 재시도하라는 뜻이고, 422는 클라이언트가 키를 잘못 재사용했다는 뜻입니다. 두 경우를 한 코드로 뭉치면 재시도해도 되는 상황과 코드를 고쳐야 하는 상황이 섞여 버립니다.
이 로직은 API마다 따로 짜지 않는 편이 낫습니다. 멱등키 조회·잠금·응답 저장만 담당하는 컴포넌트를 하나 두고 결제·취소·환불이 공유하면, 도메인 로직은 키의 존재를 몰라도 됩니다.
2PC를 걷어낸 자리의 Saga
결제 한 건은 주문·승인·재고·정산 등 여러 서비스를 건드립니다. 이들을 하나의 원자적 트랜잭션으로 묶는 고전적 방법이 2단계 커밋(Two-Phase Commit, 2PC)입니다. 조정자가 참여자 전원에게 준비를 요청하고, 전원이 동의해야 커밋을 지시합니다.
문제는 참여자가 커밋 지시를 받을 때까지 락을 쥔 채 기다린다는 점입니다. 서비스 하나가 느려지면 나머지가 함께 묶이고, 조정자가 죽으면 전원이 멈춥니다. 마이크로서비스 환경에서 2PC가 배제되는 이유는 강결합과 단일 장애점을 동시에 만들기 때문입니다.
Saga는 반대 방향을 택합니다. 하나의 글로벌 트랜잭션을 서비스별 로컬 트랜잭션 체인으로 쪼개고, 각 단계는 즉시 커밋합니다. 뒷단계가 실패하면 앞 단계를 되돌리는 보상 트랜잭션을 역순으로 실행합니다.
단계는 성격에 따라 네 종류로 나뉩니다.
| 유형 | 역할 |
|---|---|
| 정상(forward) | 주문 생성 → 결제 승인 → 재고 차감 순으로 로컬 커밋 |
| 보상(compensating) | 후속 단계 실패 시 이전 커밋을 역순으로 취소 |
| 피벗(pivot) | 되돌릴 수 없는 지점. 외부 송금 완료가 대표적 |
| 재시도 가능(retryable) | 피벗 이후 단계. 멱등하게 반복해 결국 성공시킴 |
피벗의 존재가 설계를 바꿉니다. 외부 기관으로 자금이 나간 뒤에는 취소가 아니라 반대 방향 거래로만 되돌릴 수 있고, 그 반대 거래도 실패할 수 있습니다. 피벗 이전 단계는 보상 가능하게, 이후 단계는 재시도로 결국 성공하게 설계해야 합니다.
조정 방식은 둘로 갈립니다. 중앙 조정자가 다음 단계를 지시하는 오케스트레이션과, 각 서비스가 이벤트를 구독해 스스로 움직이는 코레오그래피입니다. 결제처럼 보상 경로가 복잡한 도메인은 오케스트레이션이 유리합니다. 지금 어느 단계인지와 무엇을 보상해야 하는지가 한곳에 모여야 장애를 추적할 수 있습니다.
이중 쓰기 함정과 Outbox
Saga의 각 단계는 다음 단계에 이벤트를 넘기며 이어집니다. 여기서 무너지는 지점이 이중 쓰기(dual-write)입니다. DB 커밋과 브로커 발행은 서로 다른 시스템이라 하나의 트랜잭션으로 묶이지 않습니다.
승인 상태를 커밋한 직후 발행이 실패하면 상태는 남고 이벤트는 사라집니다. 다음 단계는 영원히 오지 않고 보상도 실행되지 않습니다. 순서를 바꿔 발행을 먼저 해도 커밋 실패 시 반대 방향의 불일치가 생깁니다.
Transactional Outbox 패턴은 이 문제를 DB 안으로 밀어 넣습니다. 비즈니스 상태 변경과 발행할 이벤트를 같은 트랜잭션에서 Outbox 테이블에 함께 INSERT하면, 둘의 원자성은 DB가 보장합니다. 발행은 Debezium 같은 CDC(Change Data Capture) 커넥터가 binlog를 읽어 커밋 순서대로 비동기 처리합니다.
이 구조의 전달 보장은 at-least-once입니다. CDC 커넥터가 재시작하면서 이미 보낸 레코드를 다시 읽을 수 있기 때문입니다. 소비자 쪽 멱등성이 선택이 아니라 전제인 이유가 여기 있습니다. 이벤트에 실린 결제 ID나 이벤트 ID를 소비자가 멱등키처럼 다뤄야 중복 적재를 막습니다.
마지막에 남는 불일치와 대사
멱등키와 Outbox를 다 갖춰도 우리 DB 밖에서 생기는 불일치는 남습니다. 한국 실무에서 가장 위험한 형태가 망취소입니다. 금융기관은 정상 승인했는데 응답 패킷이 돌아오는 도중 네트워크 장애나 스레드풀 고갈로 가맹점 쪽이 타임아웃을 내는 상황입니다.
이때 가맹점 DB는 실패이고 고객 카드는 출금입니다. 대응은 타임아웃 트랜잭션을 예외 상태로 남기고, 재시도 큐를 통해 즉시 승인취소 API를 호출해 양쪽을 강제로 맞추는 것입니다. 토스 기술 블로그는 여기에 더해 내부 승인 서버의 최대 처리 속도를 측정하고, 앞단 게이트웨이와 연동 서버의 타임아웃 경계를 그 값에 맞춰 정렬했다고 밝혔습니다.
정합성의 마지막 그물은 대사(reconciliation)입니다. 내부 원장과 PSP·은행·카드 네트워크의 명세서를 주기적으로 대조해 누락·중복·금액 불일치를 찾아내는 배치입니다. exactly-once 전달이 현실적으로 불가능하므로, at-least-once 처리와 멱등성과 대사를 묶어 정합성을 사후에 수렴시킵니다.
대사가 제 역할을 하려면 원장 설계가 받쳐 줘야 합니다. 복식부기에서는 모든 거래가 차변과 대변 두 계정에 같은 금액으로 기록되고 합은 항상 0이 됩니다. 잔액을 직접 갱신하는 대신 불변 분개를 쌓고 잔액을 그 파생값으로 도출하면, 어느 시점의 값이든 다시 계산해 대조할 수 있습니다.
주기도 설계 대상입니다. 토스페이먼츠가 공개한 무중단 원장 마이그레이션 사례에는 약 5분 간격의 정합성 검증 배치가 있습니다. 이 배치는 복제 지연으로 누락될 수 있는 데이터를 레거시 원장 기준으로 재적재해 불일치를 해소했습니다. 일 단위 대사만 두면 그사이의 불일치를 고객이 먼저 발견합니다.
정리
결제 시스템의 무결성은 한 겹으로 만들어지지 않습니다. 클라이언트가 보내는 멱등키가 재시도를 흡수하고, 분산 락과 DB 유니크 제약이 동시 진입을 막고, Saga의 보상 트랜잭션이 실패한 체인을 되돌립니다. 서비스 사이의 이벤트는 Transactional Outbox와 CDC로 DB 트랜잭션에 묶여야 유실되지 않으며, 그 대가로 생기는 중복은 소비자 멱등성이 받아냅니다. 그래도 남는 외부 불일치는 망취소와 주기적 대사가 사후에 걷어냅니다. 층을 쌓는 순서로 보면 멱등키가 첫 층이고, DB 제약과 이벤트 전달을 거쳐 대사가 마지막 층입니다.