KakaoPay 확장: 정기결제·저장결제·환불
Payment Expansion 프로젝트의 첫 번째 구현 작업을 기록한다. 기존 KakaoPay 서비스에 정기결제, 저장결제, 환불 흐름을 붙이며 저장결제 확장의 출발점을 만들었던 2023년 상반기 작업이다. 이 문서는 KakaoPay 확장 범위(정기결제/저장결제/환불)만 다룬다.
이 글에서 말하는 KakaoPay는 KakaoPay 자체 서비스가 아니라, Nike Payment 시스템 안에서 운영하던 KakaoPay 연동 서비스를 뜻한다.
왜 기존 KakaoPay 확장부터 시작했나
KakaoPay는 이미 운영 중인 서비스였다. 일회성 결제는 처리하고 있었고, 여기에 저장결제 확장에 필요한 세 가지를 우선 붙여야 했다. 정기결제 등록, 저장된 결제수단 정보 관리, 그리고 저장된 결제수단으로 결제한 건의 환불 처리다.
이미 운영 중인 KakaoPay 흐름을 먼저 활용했다
새 결제수단을 처음부터 만드는 대신, 이미 붙어 있던 KakaoPay 연동을 기반으로 확장 범위를 여는 쪽이 가장 빨랐다.
저장결제에 필요한 최소 기능부터 묶었다
등록만 가능한 구조로는 실제 서비스 가치가 나오지 않기 때문에 정기결제, 저장결제 데이터, 환불까지 한 축으로 봤다.
워커 서비스까지 같이 움직여야 했다
등록 계층만 바꾸는 것으로 끝나지 않았고, 승인과 후처리를 담당하는 비동기 워커도 같은 식별자와 데이터를 이해해야 했다.
왜 정기결제라는 표현이 더 맞았나
지금 다시 보면 구독보다 정기결제라는 표현이 더 맞았다. 카카오페이 공식 문서와 개발자 포럼에서도 주로 정기 결제, SID, ready, approve, subscription 같은 용어로 설명한다.
내 추정으로는 이 기능이 처음부터 정기구독이나 정기결제 용도로 설계됐기 때문에 그런 이름이 붙었을 가능성이 크다. 다만 실제 현업에서는 꼭 월 단위 자동 청구만 필요한 것이 아니라, 저장된 결제수단처럼 다시 쓸 수 있는 결제 구조를 원하는 경우도 있었다. 우리도 바로 그 필요 때문에 이 기능을 저장결제 축으로 해석해 붙였다.
이 표현이 중요했던 이유는 정기결제라고 하면 매월 1회 같은 고정 주기 청구를 먼저 떠올리기 쉽기 때문이다. 그래서 당시에도 자연스럽게 이런 의문이 따라왔다. 정말 월 단위 자동청구만 가능한 것인가, 아니면 사용자가 다시 쓰고 싶을 때마다 호출하는 구조로도 볼 수 있는가.
우리 쪽에서는 이 해석을 임의로 밀어붙인 것이 아니었다. 법무팀 검토와 벤더 확인을 거친 뒤, 저장결제처럼 사용할 수 있다는 전제 아래 진행했다. 그래서 서비스 문맥에서는 저장결제로 이해하고 있었지만, 연동 용어와 API 명칭은 처음 붙은 정기결제 계열 이름을 계속 따라가게 된 것으로 보는 편이 자연스러웠다.
공식 문서와 포럼 설명을 기준으로 보면 핵심은 주기 자체보다 SID 기반으로 후속 결제를 호출할 수 있는가에 더 가까웠다. 즉 연동 관점에서는 “정기 결제용 결제수단을 등록하고, 이후 SID 기반으로 필요 시 결제를 호출하는 흐름”으로 이해하는 편이 더 정확했다.
공식 문서 기준으로 보면 이 흐름은 대략 이렇게 읽힌다.
type KakaoSubscriptionFlow = {
ready: {
cid: string
partner_order_id: string
partner_user_id: string
approval_url: string
}
approve: {
tid: string
pg_token: string
}
subscription: {
sid: string
item_name: string
total_amount: number
}
}이 구조를 기준으로 보면, 우리 쪽 구현도 단순히 API 하나를 더 호출하는 문제가 아니었다. 최초 등록 단계에서 어떤 식별자를 저장할지, 승인 이후 어떤 데이터를 워커까지 넘길지, 이후 사용자가 다시 결제를 원할 때와 환불 처리에서 같은 식별자를 어떻게 재사용할지가 함께 정리되어야 했다.
기존 흐름 위에 무엇을 더 붙여야 했나
동시에 결제 워커 서비스에도 같은 변경을 반영해야 했다. 결제 승인 처리를 담당하는 워커에서 KakaoPay 저장결제 데이터를 읽고 처리할 수 있어야 했기 때문이다. 즉 하나의 기능 추가가 최소 2개 서비스를 동시에 수정하는 작업이었다.
핵심 체크포인트
- 초기: KakaoPay 정기결제 등록 경로를 먼저 열었다.
- 다음 단계: 저장결제 데이터 모델과 승인 처리 계층의 식별자를 맞췄다.
- 그 직후: 저장결제 환불 경로와 해지 처리까지 같은 축으로 정리했다.
초기 확장을 어떤 순서로 넣었나
2023년 상반기 초반 약 3주 동안 집중적으로 진행했다.
흐름은 비교적 명확했다. 먼저 정기결제 등록 경로를 열고, 그다음 저장결제 식별자와 데이터 모델을 맞추고, 마지막으로 환불과 해지까지 연결했다. 등록만 가능한 상태로 두지 않고 실제 운영에 필요한 경로를 한 묶음으로 열어두는 것이 중요했다.
다만 실제 개발 자체가 아주 오래 걸린 것은 아니었다. 시간을 더 크게 잡아먹은 쪽은 구현 이후였다. nike.com의 웹, 앱, PC, 모바일과 SNKRS의 웹, 앱, PC, 모바일까지 전 채널에서 테스트가 필요했고, 프론트엔드 반영과 UAT까지 함께 돌아가야 했다. 특히 앱 쪽은 Android와 iOS를 모두 확인해야 했고, 모바일 웹까지 포함해 일부 OS/환경에서만 동작하지 않는 케이스도 있었다.
여기에 조직 구조도 영향을 크게 줬다. 프론트엔드도 웹, 모바일웹, Android, iOS 팀이 각각 따로 있었고, 이 팀들이 모두 미국에 있었다. 즉 기능 하나를 열어도 화면 반영, 딥링크 복귀, QA 포인트를 각 팀과 계속 맞춰야 했다. 단순 구현보다 커뮤니케이션 비용이 훨씬 컸다.
미국에서 개발하고 테스트하는 환경 자체도 허들이 있었다. KakaoPay 같은 국내 간편결제는 실제로 한국 계정과 한국 사용 환경이 있어야 검증이 가능한 경우가 많았기 때문이다. 그래서 미국 팀이 바로 재현하거나 끝까지 테스트하기 어려운 구간이 있었고, 이 제약이 출시 준비를 더 느리게 만들었다. 결국 백엔드 구현보다 실제 런칭 준비와 검증 단계가 훨씬 길게 느껴졌다.
그래서 초반 프론트 개발은 실제 결제를 끝까지 붙여보며 진행하기보다, 먼저 API 명세를 기준으로 화면과 흐름을 맞춰가는 비중이 높았다. 미국 팀이 바로 실사용 조건을 재현하기 어려웠기 때문에, 백엔드와 프론트가 같은 명세를 보고 움직이는 시간이 길 수밖에 없었다.
왜 런칭이 더 오래 걸렸나
- 백엔드 구현: 정기결제 등록, 저장결제 데이터, 환불/해지 흐름 추가
- 프론트 반영: 웹과 앱에서 등록/승인/재사용 UX 연결
- 채널 검증: nike.com과 SNKRS의 웹, 앱, PC, 모바일 전 채널 테스트
- 플랫폼 검증: Android, iOS, 모바일 웹까지 각각 다른 동작 여부 확인
- 협업 비용: 웹, 모바일웹, Android, iOS 팀이 분리되어 있고 모두 미국 조직에 있어 조율 비용이 큼
- 테스트 제약: 미국 개발 환경에서는 한국 계정 기반 간편결제를 끝까지 재현하기 어려움
- 명세 의존: 초반 프론트 개발은 실제 결제 검증보다 API 명세 기준 정렬 비중이 높았음
- 운영 검증: UAT와 실제 런칭 직전 시나리오 점검
카카오페이 공식 문서와 포럼 답변을 기준으로 봐도, 이 단계에서 중요한 식별자는 tid, pg_token, sid였다. ready 이후 redirect와 approve를 거쳐야 하고, 이후 반복 결제에서는 SID를 다시 사용한다는 점이 핵심이었다. 우리 구현도 이 공식 흐름을 그대로 서비스 구조에 맞게 옮기되, 내부 데이터 모델과 워커 연계까지 같이 고려해야 했다.
public final class KakaoPayStoredPaymentFlow {
public ReadyResult readyForSubscription(ReadyCommand cmd) { return ReadyResult.accepted(); }
public ApproveResult approveRegistration(String tid, String pgToken) {
return ApproveResult.withSid("stored-sid");
}
public ChargeResult chargeWithSid(String sid, Money amount) {
return ChargeResult.approved();
}
public RefundResult refundStoredPayment(String sid, Money amount) {
return RefundResult.queued();
}
}위 코드는 실제 카카오페이 SDK나 내부 코드를 옮긴 것이 아니라, 이 글에서 다루는 흐름을 설명하기 위한 단순화된 예시다. 핵심은 등록용 승인과 이후 SID 기반 결제, 환불이 서로 분리되어 있으면서도 같은 저장결제 축으로 묶여 있었다는 점이다.
정기결제 등록 경로를 먼저 열었다
기존 승인 흐름을 유지한 채 정기 결제용 등록 경로부터 열었다.
저장결제 식별자를 정렬했다
등록 데이터와 승인 처리 계층이 같은 식별자를 보도록 맞췄다.
환불과 해지까지 같은 축으로 확장했다
등록만 되는 구조가 아니라 refund, unauthorize까지 이어지는 경로를 함께 정리했다.
초기 확장이 다음 계약 정리로 이어진 이유
이 단계에서 중요한 것은 기능 수를 늘린 것이 아니라, 저장결제와 환불이 기존 결제 흐름에 어떤 방식으로 붙어야 하는지 기준을 만든 점이었다. 한 번의 구현으로 끝나는 작업이 아니라 워커와 연동 계층이 함께 움직여야 했기 때문에, 데이터 모델과 상태 전이를 먼저 정리하는 쪽이 더 중요했다.
다음 글에서는 이 확장을 더 안정적으로 운영하기 위해 저장결제 관리 계층의 API 명세를 어떻게 다시 정의했는지 이어서 본다.