← 염승준
03

결제 리다이렉트 정합성

승인 누락·중복 0건. "0건"을 주장하려면 알 수 있어야 합니다.

문제
  • PG 결제창에서 돌아온 뒤 클라이언트가 승인 요청을 직접 구성해 보내는 구조
  • 어긋날 수 있는 셋: 상태가 갖춰지기 전의 전송, 같은 승인의 중복 전송, 실제 구매와 다른 요청 내용
해결
  • 로딩 플래그가 아니라 로그인·프로필·구매 세션 데이터가 실제로 채워졌을 때만 승인 전송
  • 클라이언트 ref 플래그는 1차 방어로만 두고, 멱등성은 결제 키 기준 서버 처리에 맡겨 층별 역할 분리
  • sessionStorage는 신뢰 근거로 쓰지 않고 금액 검증은 PG와 백엔드가 담당
  • 판별 유니온으로 결제 도메인을 타입 수준에서 분리
  • 결제 결과를 7개 유형으로 나누고 비정상은 Sentry 기록 + Google Chat 실시간 알림
결과
  • 승인 누락·중복 0건 (알림 이력 기준 실측)
  • 누적 결제 약 1.3억 원 / 1,878건
  • 신규 서비스에 결제 파이프라인 복제 없이 재사용, PG 에러코드 51종 한국어·영어 매핑
승인 누락·중복
0건 (알림 이력 기준 실측)
누적 결제
약 1.3억 원 / 1,878건
에러코드 매핑
PG 에러 51종 한국어·영어
도메인 확장
신규 서비스에 파이프라인 복제 없이 재사용

문제

이 결제는 승인 요청을 클라이언트가 보내는 구조입니다.

  1. 클라이언트가 결제창 호출 (구매 목록·금액)
  2. 사용자가 카드 인증·결제 (앱을 잠시 떠남)
  3. PG가 성공 URL로 복귀 (결제 키·주문 ID·금액 전달)
  4. 클라이언트가 "승인"을 보낸다 → 백엔드
  5. 백엔드가 PG에 최종 확인 → 차감 + 티켓 발급

핵심은 4번입니다. 승인 요청을 클라이언트가 보내니, 프론트가 "무슨 주문을, 누가, 어떤 정보로" 승인할지를 스스로 구성해 보냅니다. 어긋날 수 있는 건 셋입니다. 상태가 안 갖춰졌는데 보내는 것, 같은 승인이 두 번 나가는 것, 요청 내용이 실제 구매와 다른 것.

준비가 됐을 때만 보낸다

로그인 상태, 프로필 로드 완료, 구매 정보(세션) 셋이 모두 갖춰졌을 때만 승인을 보냅니다. 셋 중 하나라도 없으면 요청을 완성할 수가 없기 때문입니다.

여기서 함정이 있었습니다. 처음엔 단순 로딩 플래그를 기준으로 삼았는데, "로딩이 끝났다"와 "데이터가 실제로 채워졌다"는 다릅니다. SDK가 초기화를 마쳐도 프로필이 비어 있을 수 있습니다. 기준을 실제 데이터가 채워졌는지로 바꿨습니다.

중복 방지 — 그리고 그 한계

조건들이 비동기로 채워지며 화면 로직이 여러 번 평가되면 승인이 두 번 나갈 수 있습니다. 상태값과 별개로 즉시 잠기는 ref 플래그를 뒀습니다.

그런데 이건 완벽한 해법이 아닙니다. 새로고침하면 플래그가 초기화되고, 동시성 렌더에서 실행 순서가 달라지면 취약하고, 애초에 "이 탭 메모리 안의 표시"라 진짜 멱등성을 보장하는 도구가 아닙니다.

그래서 이걸 "중복을 막은 핵심"이라 부르지 않습니다. 흔한 실수를 싸게 거르는 1차 방어일 뿐이고, 진짜 안전망은 서버입니다. 결제 세션은 한 번 승인되면 소진되고 백엔드가 결제 키 기준으로 멱등 처리합니다. 클라 가드 하나로 다 막으려 하지 않고 각 층의 역할을 나눈 게 요점입니다.

클라 저장소에 신뢰를 두지 않기

결제 정보를 sessionStorage에 30분 뒀습니다. 한 탭 안의 짧은 흐름이라 서버를 왕복하지 않는 게 가볍기 때문입니다.

문제는 이 저장소가 위조·삭제가 가능하고 금액까지 담긴다는 것입니다. 그래서 이걸 신뢰의 근거로 쓰지 않았습니다. 돈에 관한 최종 판단은 두 곳이 합니다. PG는 결제 키·주문 ID·금액이 최초 요청과 일치해야 승인을 통과시키고, 백엔드는 주문 ID로 원 주문을 조회해 가격 유효성을 판단합니다.

도메인에 붙어 있던 결제를 떼어내기

처음 결제 API는 eventId를 직접 받아 특정 도메인에 강결합돼 있었습니다. 신규 서비스에 붙이려는 순간 벽이 됐고, 그대로 두면 결제 로직 전체를 복사해야 했습니다. 돈을 다루는 코드가 두 벌로 갈라지면 정합성 규칙도 두 벌이 됩니다.

판별 유니온으로 도메인을 타입 수준에서 갈랐습니다.

type PaymentDomainParams =
  | { domain: 'event'; eventId: number }
  | { domain: 'openPlay'; openPlayId: number };

domain: 'event'인데 openPlayId를 쓰려 하면 컴파일 에러가 납니다. 런타임 검증이 아니라 타입으로 막은 이유는, 결제에서 도메인을 잘못 잡는 실수는 배포 후에 발견되면 이미 늦기 때문입니다.

"0건"을 주장하려면 알 수 있어야 한다

결제 결과를 7개 유형(정상 승인 / 서버 세션 없음 / 중복 처리 / 승인 에러 / 잘못된 접근 / 세션 만료 / 파싱 실패)으로 나누고, 비정상은 전부 Sentry 기록 + Google Chat 실시간 알림을 보냈습니다.

PG 에러코드 51종은 한국어·영어로 매핑했습니다. 친절 때문만은 아닙니다. 한도 초과나 잔액 부족처럼 사용자가 조치하면 다시 시도할 수 있는 경우가 많은데, "결제에 실패했습니다"로 뭉뚱그리면 그냥 이탈합니다.

이 관측이 있어서 "승인 누락·중복 0건"이 감이 아니라 알림 이력에 근거한 실측이 됩니다. 이 알림은 성공 결제에도 발화하므로, 실패 알림 0건은 "관측이 죽은 것"이 아니라 실제로 문제가 없었다는 근거입니다.