상태를 바꾸는 모든 곳은 충전을 비관적 락으로 읽는다. 늦게 온 쪽은 바뀐 상태를 보고 멈춘다.
문제 해결 과정
결과를 모르는 토스 승인 — 실패로 단정하지 않기
문제
토스 승인 호출이 시간 초과나 5xx로 끝나면, 고객 돈이 빠져나갔는지 알 수 없다
원인 — 어떻게 찾았나
실패로 처리하면 이미 결제된 돈이 입금되지 않고, 다시 승인하면 두 번 입금될 수 있다. 승인 호출을 DB 트랜잭션 안에 두면 토스를 기다리는 동안 커넥션을 붙잡고, 롤백되면 승인을 시도했다는 기록마저 사라진다
해결 — 무엇을 바꿨나
승인을 세 단계로 나눴다. 금액 대조 · 만료 확인 뒤 '승인 중'을 먼저 커밋하고, 트랜잭션 밖에서 토스 주문번호를 멱등 키로 승인한 다음 결과를 반영한다. 결과를 모르면 승인 중으로 두고 202로 응답하고, 스케줄러가 1분마다 5분 넘게 승인 중인 충전을 토스에 다시 조회해 승인은 입금, 거절은 실패, 모름은 다음 실행으로 넘긴다
결과
결과를 모르는 승인도 토스 조회로 확정될 때 한 번만 입금된다. 승인 금액이 충전 금액과 다르면 반영하지 않고 ERROR로 남긴다
사용자 승인 · 스케줄러 · 웹훅이 겹칠 때 — 한 번만 입금
문제
같은 충전을 사용자의 승인 요청, 정리 스케줄러, 토스 웹훅이 동시에 건드릴 수 있다
원인 — 어떻게 찾았나
셋이 각자 '아직 승인 중'을 보고 입금하면 같은 충전이 두 번 입금된다
해결 — 무엇을 바꿨나
상태를 바꾸는 모든 곳(승인 시작 · 반영 · 실패 · 만료 · 취소)에서 충전을 비관적 락으로 읽어 늦은 쪽이 바뀐 상태를 보고 멈추게 했다. 락은 항상 충전 → 지갑 순서로 잡고, 스케줄러가 집는 기준(5분)은 토스 읽기 시간 제한(30초)보다 길게 둬 진행 중인 승인과 겹치지 않게 했다. 마지막으로 원장의 (type, ref_id) 유니크 키가 같은 원인의 입금을 한 번만 허용한다
결과
세 경로가 겹쳐도 원장에는 한 줄만 남는다. 잔액의 기준은 한 번 쓰면 바꾸지 않는 원장이다
결제와 주문 취소가 동시에 올 때 — 커밋 뒤 이벤트
문제
결제 제한 시간 만료로 주문이 취소되는 순간 결제 요청이 함께 오면, 취소된 주문에서 돈이 빠지거나 환불이 누락될 수 있다
원인 — 어떻게 찾았나
주문과 결제는 다른 도메인이라 서로의 트랜잭션 안을 볼 수 없다
해결 — 무엇을 바꿨나
도메인 사이는 발행한 쪽이 커밋된 뒤에만 전달되는 이벤트(order.payment-requested → payment.completed · payment.failed)로만 연결했다. 결제와 취소는 같은 회원 지갑 락을 먼저 잡고 상대의 기록을 보게 해, 취소가 먼저면 차감하지 않고 결제가 먼저면 남은 금액을 환불한다. 주문당 결제는 payment.order_id 유니크 키로 한 번만 허용하고, 취소 처리 실패는 재처리 테이블과 1분 주기 스케줄러로 메운다
결과
같은 취소가 여러 번 와도 환불은 한 번만 반영돼, 실패한 처리를 몇 번이고 다시 돌려도 안전하다
설계 결정
결제 키 암호화
토스 paymentKey는 AES-256-GCM으로 암호화해 저장한다. IV는 암호화할 때마다 새로 만들고, 암호화 키 환경 변수가 없으면 앱이 뜨지 않는다.
웹훅은 믿지 않고 다시 조회
토스 웹훅에는 서명이 없어 본문을 믿지 않는다. 주문번호로 토스를 다시 조회해 전액 취소일 때만 반영하고, 조회 결과를 모르면 503을 돌려 토스가 다시 보내게 한다.
취소 중에는 돈부터 묶기
충전 취소는 토스를 부르기 전에 충전액을 먼저 빼 둔다. 토스가 응답하는 동안 그 돈으로 결제하지 못하게 하려는 것이고, 거절되면 다시 넣고 원장에 되돌림을 남긴다.
추적용 행동 로그
상태가 바뀐 일만 action.payment 로거에 한 줄로 남긴다. 사람은 한글 설명으로, 검색은 key=value로 읽고, 롤백된 일은 남기지 않는다.