결제는 됐는데 예약이 안 됐다 — '과금-미예약' 불일치를 구조로 막은 이야기
멀티테넌트 예약 SaaS에서 PG 결제와 예약 등록이 분리된 구조가 만드는 '돈은 나갔는데 예약은 없는' 불일치를, 자동 취소·선저장·응답 판정 정교화로 닫은 기록.
배경
멀티테넌트 예약 SaaS에서 숙박·레저·티켓의 예약 / 결제 / 취소 전체 플로우를 프론트엔드에서 단독으로 개발했다. 결제는 PG(KICC EasyPay)를 iframe 인증 방식으로 연동했고, 흐름은 결제 인증 → 복귀 → 예약 등록(확정) 순서로 이어진다.
문제는 이 구조가 본질적으로 "결제 성공"과 "예약 완료"를 분리한다는 점이다.
문제 1: 결제 성공 ≠ 예약 완료
PG 승인(결제)과 예약 등록(우리 서버)이 별도 단계라, PG 승인은 났는데 등록이 실패하면 "돈은 나갔는데 예약은 없는" 상태가 생긴다. 금융 플로우에서 가장 치명적인 불일치다.
해결은 순서를 뒤집는 게 아니라, 등록 성공이 확정될 때까지 결제를 '확정'으로 취급하지 않는 것이었다. 복귀 후 등록 호출이 실패하면 결제를 자동 취소하도록 설계해, 사용자에게는 과금 없이 실패만 안내되게 했다.
sequenceDiagram
participant U as 사용자
participant FE as 프론트엔드
participant PG as PG(iframe)
participant API as 예약 서버
U->>FE: 결제하기
FE->>PG: ready 요청 + 인증창 오픈
PG-->>FE: 인증 완료 → returnUrl 복귀
FE->>API: 예약 등록 요청
alt 등록 성공
API-->>FE: 예약 확정
FE->>U: 예약 상세로 이동
else 등록 실패
FE->>PG: 결제 자동 취소
FE->>U: 실패 안내 (과금 없음)
end문제 2: 복귀 시점의 레이스 컨디션
복귀 핸들러(iframe)가 등록 성공 후 상세 페이지 링크를 만들 때 게스트 세션을 읽는데, 부모의 결과 핸들러가 그 값을 쓰는 시점이 iframe이 읽는 시점보다 늦어 세션이 비는 레이스가 있었다. 특히 레저 결제는 cross-origin 라운드트립이라 sessionStorage 자체가 비기도 했다.
해결:
- 게스트 세션을 결제 시작 시점에 미리 저장해, 복귀 시 read가 항상 값을 얻도록 순서를 보장.
- 보조로 상품 유형(숙박/레저)을 쿠키에 기록해, sessionStorage가 비어도 등록 엔드포인트 선택이 견디도록 폴백을 뒀다.
문제 3: 정상 응답을 실패로 오판
레저 PG는 정상일 때 status: "PAYMENT_READY", resultCode: "0000"을 내려준다. 그런데 PAYMENT_READY를 실패로 처리하면 resultMessage("정상")가 에러로 떠서 인증 iframe이 열리지 않았다.
PG 문서만 믿지 않고 실제 응답을 로그로 확인해, 성공 판정을 status ∈ {SUCCESS, OK, PAYMENT_READY, READY} 그리고 resultCode ∈ {0000, 0} 조합으로 정확히 정의했다.
보안 설계
- 예약 식별정보를 AES-256-GCM 봉인 토큰으로 인계 (TTL 10분, URL 평문 노출 0).
- BFF 프록시로 PG·API 키를 서버사이드에 은닉.
배운 점
- 결제는 "성공/실패"가 아니라 **"돈과 서비스가 일치하는가"**의 문제다. 단계가 나뉘는 순간 불일치 가능성이 생기고, 그건 UI 처리가 아니라 **구조(실패 시 자동 취소)**로 닫아야 한다.
- PG 연동은 문서보다 실제 응답이 진실이다. status/resultCode 조합은 실측으로 확정했다.
- 복귀·cross-origin처럼 타이밍이 얽히는 곳은 "쓰기를 읽기보다 앞당기고, 폴백을 둔다"가 안전하다.