제가 있던 스타트업에서는 원래 개인 사업자로 운영을 하다가 2026년도에 법인을 설립하게 되며 사업자등록번호가 바뀌게 되었습니다. 이로 인해 기존에 PG사로 운용하던 토스페이먼츠에서도 상점 ID가 바뀌게 되었고, 구상점은 비활성화되어 결제와 환불이 모두 불가능해졌습니다. 결제는 어차피 신상점으로 받게 되니 큰 문제가 없었지만, 구상점에서 결제하셨던 모든 분들에 대한 환불이 서비스 내에서 더 이상 동작하지 않게 되었습니다.
저희는 셔틀 서비스의 특성상 취소율이 30%에 이르고, 결제 시점과 실제 셔틀 탑승 시점이 수개월 떨어져 있었습니다. 즉, 법인 전환 이후로도 구상점 결제 건의 환불 요청은 앞으로 수개월 동안 계속해서 들어올 예정이었습니다.
단기 해결책
당시 실제로 사용했던 단기 해결책은 제가 매일 오전 10시에 실패한 환불 요청 건들을 직접 확인하여 엑셀 명단으로 운영팀에게 전달하고, 운영팀에서는 해당 고객님들에게 일일이 연락을 드려 계좌 정보를 수집한 뒤, 환불 금액과 지연배상금을 함께 입금해드리고, 내부 어드민에서 예약을 수동으로 취소하는 구조로 진행했습니다.

이는 매일 3-4시간씩 걸리는 작업이었습니다. 이걸 몇 개월 동안 지속한다는 건 운영 코스트적으로 말이 되지 않았고, 그 사이 서비스 화면에서는 유저가 환불 버튼을 누르면 “환불이 실패하였습니다”라는 문구가 그대로 노출되고 있었기에 CS도 폭주하는 상황이었습니다.
따라서 저는 이를 해결하기 위해 장기적인 해결책으로 결제 및 환불 도메인 구조를 일부 재설계하게 되었습니다.
환불 완료가 아닌 환불 요청 시점에 예약 취소
기존 구조에서는 환불 도메인의 환불 요청이 환불 완료로 바뀌어야지만 예약 취소가 완료되는 이벤트 기반 흐름이었습니다. 이는 환불이 바로 처리되던 일반적인 케이스에서는 문제가 없었으나, 상점 이전으로 인해 구상점 결제 건들은 환불 완료가 되지 않으며 예약도 같이 취소되지 않게 되었습니다.
저는 이 트리거 조건을 환불 요청이 생성되는 그 시점에 곧바로 예약 취소 완료 이벤트를 발행하도록 변경했습니다. 이를 통해 환불의 성공/실패와 무관하게 환불 요청이 만들어지면 예약은 즉시 취소 확정으로 들어가게 됩니다.
직관적으로 생각했을 때 환불이 처리되지 않았음에도 예약이 취소되는 것이 어색하게 느껴질 수 있으나, 유저는 환불 버튼을 누르는 순간 이미 예약을 취소하겠다고 의사 결정한 상태이기에 환불금이 며칠 늦게 도착하더라도 예약은 즉시 취소된 상태로 보이고 환불은 별도로 처리 중이라고 안내하는 쪽이 일관된다고 판단했습니다.
이를 통해 시스템이 다운된 것처럼 보이던 현상을 방지하여 UX를 개선하고 CS 문의율도 빠르게 감소하였습니다.
상점에 따라서 환불 실행 능력을 분리
이후 저는 개발자가 매일 아침 실패한 환불 요청을 엑셀로 추리고, 운영팀이 고객 한 분 한 분께 연락드려 계좌번호를 수집하던 흐름을 시스템이 알아서 분기 처리할 수 있도록 자동화하고자 했습니다.
가장 처음 고려했던 해결책은 결제가 생성된 시점을 기준으로 상점 변경 이전의 결제 건이면 프론트에서 계좌번호를 입력하는 UI를 보여줘 계좌정보를 수집하는 방식이었습니다. 해당 방식은 빠르게 개발이 가능하지만, 실제 처리된 환불 내역이 payment 데이터에 반영되지 않아 정합성이 깨지고, 추후 지표 관리 측면에서도 평생 부정확한 데이터를 가지고 가게 되기에 조금 더 근본적인 방식의 구조 개선을 고려하게 되었습니다.
결제의 구/신상점 식별
가장 먼저 필요한 정보는 “이 결제가 구상점에서 일어났는가, 신상점에서 일어났는가” 였습니다.
저희는 토스페이먼츠를 사용하고 있었기에 매 결제 응답을 toss_payments 테이블에 그대로 저장하고 있었고, 토스 응답 안의 mid 필드를 통해 구상점 또는 신상점인지를 식별할 수 있었습니다.
저는 구상점/신상점 여부를 표현하는 TossPaymentMerchantType enum 속성을 토스페이먼츠 테이블에 추가하여 env에 넣어둔 신상점 mid를 기반으로 결제 시점에 판단 및 삽입하도록 했습니다. 그리고 이미 저장되어 있던 결제 건들은 정의상 모두 구상점이므로, 일괄적으로 구상점 값으로 마이그레이션 했습니다.
PG 어휘를 provider 경계 안에 가두기
이제 토스 결제 한 건이 어느 상점 소속인지를 알 수 있게 되었으니, 환불을 자동으로 처리 가능한지를 판단할 수 있습니다.
이때 만약에 토스에 종속된 정보인 토스 상점 타입 값을 환불 워크플로우가 직접 들여다보게 된다면 결제 도메인이 PG사에 묶이게 됩니다. 따라서 저는 토스에서 비롯된 사실은 토스 안에만 가둬두고, 환불 도메인은 그 사실의 결과만 받아 보는 것이 옳다고 판단했습니다.
이를 위해 PG사에 비종속적인 enum인 RefundExecutionCapability를 결제 엔티티의 칼럼으로 추가했습니다.
AUTO: PG 자동 환불 가능MANUAL: 자동 환불 불가, 사람이 처리해야 함UNKNOWN: 결제 승인 전 또는 PG가 정보를 주지 않은 경우의 기본값
또한 PG provider 인터페이스의 결제 승인 메소드의 반환 타입을 단순 boolean에서 환불 실행 능력을 포함한 응답 객체로 확장하고, 토스 구현체는 자기가 분류한 토스 상점 타입을 환불 실행 능력으로 번역해서 돌려주도록 했습니다. 그리고 PaymentService는 이 값을 받아 결제 엔티티에 반영한 뒤 결제 승인을 마무리합니다.
이 덕분에 다른 PG사를 추가하더라도 그 PG사의 사정만 자기 provider 안에서 번역하면 되고, 결제 도메인은 환불 실행 능력만 알면 되며, PG 종속적인 어휘는 provider 경계 바깥으로 새어 나오지 않습니다.
환불 처리 플로우 분기
이제 환불 플로우는 환불 실행 능력만을 보고 분기하면 됩니다. 기존에 ProcessRefundUseCase로 통합되어 있던 구조를 아래와 같이 분리했습니다.
ProcessAutoRefundUseCase: PG provider를 호출해 실제 환불을 친 뒤 DB 상태를 갱신ProcessManualRefundUseCase: PG 호출 없이 DB 상태만 갱신
이를 통해 신상점으로 예약한 결제 건들은 ProcessAutoRefundUseCase 유스케이스로 정책에 따라 기존처럼 자동환불이 이루어지도록 하였고, 구상점으로 예약한 결제건들은 관리자가 어드민 페이지에서 확인 이후에 수동으로 처리할 수 있도록 하였습니다.
수동 환불 워크플로우 자체에 필요한 부가 정보들도 환불 요청 엔티티에 함께 추가했습니다. 수동 환불은 결국 사람이 계좌로 송금한 뒤 결과를 기록하는 일이기에, 어떤 계좌로 환불했는지, 어느 어드민이 처리했는지, 잘못 만들어진 환불 요청을 비활성화할 수 있는 플래그가 모두 필요하다고 판단했습니다.
또한 구상점 결제 환불 시 프론트에서 계좌번호를 입력하는 UI를 추가하여, 운영팀이 더 이상 고객 한 분 한 분께 문자로 계좌를 받지 않고 어드민 한 페이지에서 모든 처리가 가능하도록 개편했습니다.
마무리
당시 토스페이먼츠 환불이 안되었던 상황을 복기하면 아직도 아찔합니다.
그러나 이때 환불 도메인을 PG사 사정으로부터 떼어내고 수동 환불 처리 플로우를 만든 결과, 지금은 신상점에서 월 정산금 부족 같은 사정으로 자동 환불이 일시적으로 실패하는 경우에도 같은 구조와 어드민 대시보드를 그대로 활용해 무리 없이 처리하고 있기에, 더욱 보람을 느끼고 저에게 큰 경험이 되었던 작업 중 하나입니다.