- 승인 누락·중복
- 0건 (알림 이력 기준 실측)
- 누적 결제 (2026.11.06 기준)
- 약 2억 원 / 2,944건
- 에러코드 매핑
- PG 에러 51종 한국어·영어
- 도메인 확장
- 신규 서비스에 파이프라인 복제 없이 재사용
문제
2025년 1월부터 처음 설계·구현해 운영하고 있는 결제 흐름입니다. 이 결제는 승인 요청을 클라이언트가 보내는 구조입니다.
- 클라이언트가 결제창 호출 (구매 목록·금액)
- 사용자가 카드 인증·결제 (앱을 잠시 떠남)
- PG가 성공 URL로 복귀 (결제 키·주문 ID·금액 전달)
- 클라이언트가 "승인"을 보낸다 → 백엔드
- 백엔드가 PG에 최종 확인 → 차감 + 티켓 발급
핵심은 4번입니다. 승인 요청을 클라이언트가 보내니, 프론트가 "무슨 주문을, 누가, 어떤 정보로" 승인할지를 스스로 구성해 보냅니다. 어긋날 수 있는 건 셋입니다. 상태가 안 갖춰졌는데 보내는 것, 같은 승인이 두 번 나가는 것, 요청 내용이 실제 구매와 다른 것.
앱을 떠나는 구간: 허용 도메인
2번에서 사용자는 카드 인증을 위해 잠시 앱을 떠납니다. 그런데 iOS 앱은 허용된 외부 도메인만 열도록 되어 있어서, 결제 과정에서 거치는 도메인이 목록에 없으면 앱 웹뷰 안에서 흐름이 끊깁니다.
결제 과정에서 거치는 카드사 도메인을 전부 정리해 앱 쪽에 공유했습니다. 웹 코드만 봐서는 보이지 않는, 앱과 웹의 경계에서 생기는 문제였습니다.
준비가 됐을 때만 보낸다
로그인 상태, 프로필 로드 완료, 구매 정보(세션) 셋이 모두 갖춰졌을 때만 승인을 보냅니다. 셋 중 하나라도 없으면 요청을 완성할 수가 없기 때문입니다.
여기서 함정이 있었습니다. 처음엔 단순 로딩 플래그를 기준으로 삼았는데, "로딩이 끝났다"와 "데이터가 실제로 채워졌다"는 다릅니다. SDK가 초기화를 마쳐도 프로필이 비어 있을 수 있습니다. 기준을 실제 데이터가 채워졌는지로 바꿨습니다.
결제창에 다녀오면 React 상태는 모두 사라지므로, 상태는 두 겹으로 되살립니다. 결제에 들어갈 때 sessionStorage에 임시 세션을 만들어 두고, 돌아왔을 때는 SSR 단계의 파라미터 검증과 함께 이 세션으로 상태를 복원합니다. 결제 SDK가 아직 초기화되지 않았으면 결제 시도 자체를 일찍 막아, 아무 표시 없이 실패하는 경우를 없앴습니다.
중복 방지와 그 한계
조건들이 비동기로 채워지며 화면 로직이 여러 번 평가되면 승인이 두 번 나갈 수 있습니다. 상태값과 별개로 즉시 잠기는 ref 플래그를 뒀습니다.
그런데 이건 완벽한 해법이 아닙니다. 새로고침하면 플래그가 초기화되고, 동시성 렌더에서 실행 순서가 달라지면 취약하고, 애초에 "이 탭 메모리 안의 표시"라 진짜 멱등성을 보장하는 도구가 아닙니다.
그래서 이걸 "중복을 막은 핵심"이라 부르지 않습니다. 흔한 실수를 싸게 거르는 1차 방어일 뿐이고, 진짜 안전망은 서버입니다. 결제 세션은 한 번 승인되면 소진되고 백엔드가 주문 ID 기준으로 멱등 처리합니다. 클라 가드 하나로 다 막으려 하지 않고 각 층의 역할을 나눈 게 요점입니다.
클라 저장소에 신뢰를 두지 않기
결제 정보를 sessionStorage에 30분 뒀습니다. 한 탭 안의 짧은 흐름이라 서버를 왕복하지 않는 게 가볍기 때문입니다.
문제는 이 저장소가 위조·삭제가 가능하고 금액까지 담긴다는 것입니다. 그래서 이걸 신뢰의 근거로 쓰지 않았습니다. 돈에 관한 최종 판단은 두 곳이 합니다. PG는 승인 요청이 결제 요청 때의 값과 일치해야 통과시키고, 백엔드는 DB의 티켓 가격으로 금액을 다시 계산해 요청 금액과 비교한 뒤 다르면 승인 전에 거절합니다.
도메인에 붙어 있던 결제를 떼어내기
처음 결제 API는 eventId를 직접 받아 특정 도메인에 강결합돼 있었습니다. 신규 서비스에 붙이려는 순간 벽이 됐고, 그대로 두면 결제 로직 전체를 복사해야 했습니다. 돈을 다루는 코드가 두 벌로 갈라지면 정합성 규칙도 두 벌이 됩니다.
판별 유니온으로 도메인을 타입 수준에서 갈랐습니다.
type PaymentDomainParams =
| { domain: 'event'; eventId: number }
| { domain: 'openPlay'; openPlayId: number };
domain: 'event'인데 openPlayId를 쓰려 하면 컴파일 에러가 납니다. 런타임 검증이 아니라 타입으로 막은 이유는, 결제에서 도메인을 잘못 잡는 실수는 배포 후에 발견되면 이미 늦기 때문입니다.
"0건"을 주장하려면 알 수 있어야 한다
결제 결과를 7개 유형(정상 승인 / 서버 세션 없음 / 중복 처리 / 승인 에러 / 잘못된 접근 / 세션 만료 / 파싱 실패)으로 나누고, 비정상은 전부 Sentry 기록 + Google Chat 실시간 알림을 보냈습니다.
PG 에러코드 51종은 한국어·영어로 매핑했습니다. 친절 때문만은 아닙니다. 한도 초과나 잔액 부족처럼 사용자가 조치하면 다시 시도할 수 있는 경우가 많은데, "결제에 실패했습니다"로 뭉뚱그리면 그냥 이탈합니다.
이 관측이 있어서 "승인 누락·중복 0건"이 감이 아니라 알림 이력에 근거한 실측이 됩니다. 이 알림은 성공 결제에도 발화하므로, 실패 알림 0건은 "관측이 죽은 것"이 아니라 실제로 문제가 없었다는 근거입니다.

"결제했는데 내역이 없다"
관측이 있어도 문의는 들어왔습니다. "결제했는데 내역이 없다"는 문의를 DB의 결제 데이터를 직접 조회해 추적했습니다.
승인 누락이 아니었습니다. 카카오페이의 페이머니 충전 단계를 결제 완료로 착각하고, 실제 결제 전에 앱을 떠나는 사용자 흐름이 원인이었습니다. 코드의 결함이 아니라, 사용자가 흐름의 어디에서 멈췄는지의 문제였습니다.