저장결제 관리 계층 KakaoPay/Fiserv API 명세
저장결제 관리 계층 서비스에 KakaoPay와 Fiserv Billkey 등록을 통합하면서 겪은 API 명세 문제를 기록한다. 벤더마다 ‘등록 성공’, ‘등록 실패’, ‘등록 취소’의 의미가 다르다는 것을 알게 된 경험이다. 결제 시스템에서 다중 벤더를 통합할 때 공통 API 명세를 어떻게 설계해야 하는지에 대한 교훈을 남긴다.
한국어로는 보통 API 명세라고 부르지만, 영어권 개발 문맥에서는 같은 뜻으로 API contract라는 표현을 많이 쓴다. 나도 처음에는 contract를 보고 법무나 상업 계약을 떠올렸는데, 실제로는 요청/응답 형식, 상태값, 필수 필드, 에러 처리 규칙까지 포함한 API 명세를 뜻하는 경우가 많았다.
왜 명세를 먼저 맞춰야 했나
저장결제 관리 계층 서비스는 원래 신용카드의 저장된 결제수단만 관리하고 있었다. 여기에 KakaoPay와 Fiserv의 Billkey 등록을 통합해야 했다. 문제는 각 벤더의 등록 API가 다른 방식으로 설계되어 있다는 것이었다.
여기에 협업 구조도 크게 작용했다. 웹, 모바일웹, Android, iOS 프론트 팀이 모두 미국에 있었는데, KakaoPay 같은 국내 간편결제는 한국 계정과 한국 사용 환경이 있어야 끝까지 테스트할 수 있는 경우가 많았다. 즉 미국 팀 입장에서는 초반에 실제 간편결제를 바로 재현하기 어렵고, 그래서 프론트 개발도 우선 API 명세에 더 강하게 의존할 수밖에 없었다.
벤더마다 등록의 의미가 왜 달랐나
신용카드 저장은 상대적으로 단순하다. 카드 정보를 받아서 토큰화하고 저장하면 된다. 하지만 KakaoPay는 사용자가 KakaoPay 앱에서 인증을 완료해야 등록이 완료되고, Fiserv는 billkey 발급 요청 후 별도의 확인 과정이 필요했다. 즉 ‘등록’이라는 같은 단어가 벤더마다 다른 프로세스를 의미했다.
핵심 체크포인트
- 초기: KakaoPay 인증 완료 후 저장결제 관리 계층에 등록 정보를 반영하는 흐름을 만들었다.
- 다음 단계: 등록 취소와 등록 실패를 서로 다른 상태로 다뤄야 한다는 점을 반영해 후속 호출 구조를 수정했다.
- 그 이후: KakaoPay와 Fiserv가 같은 등록 API 명세 아래 동작하도록 요청/응답 구조를 다시 정리했다.
- 확장 단계: Fiserv Billkey 등록 지원을 붙이며 저장결제 관리 계층과 결제 연동 계층을 함께 조정했다.
공통 명세를 어떤 기준으로 세웠나
이 작업은 2023년 상반기부터 여름까지 약 4개월에 걸쳐 진행되었다. 저장결제 관리 계층 서비스에만 18건의 커밋이 발생했다.
이 작업에서 가장 어려웠던 부분은 벤더별로 등록 상태 전이(state transition)가 다르다는 것이었다.
KakaoPay: 등록 요청 → 사용자 앱 인증 → 성공 콜백 또는 취소 콜백. 이론상으로는 사용자가 취소하면 cancel callback이 와야 했지만, 실제로는 콜백이 오지 않는 경우(타임아웃)도 적지 않았다. 사용자가 KakaoPay 앱을 열고 인증을 완료하지 않고 닫아버리는 경우도 있었고, 앱 안에 등록된 결제정보가 없어 중간에 이탈하는 경우도 있었다. 또 단순한 고객 변심으로 취소하는 경우도 많았는데, 이런 경우에도 기대한 cancel callback이 항상 도착하는 것은 아니었다. 그래서 서버가 결과를 마냥 기다릴 수는 없었다.
Fiserv: 등록 요청 → billkey 발급 → 확인. Fiserv는 동기적으로 결과를 반환하지만, billkey가 실제로 사용 가능한 상태가 되려면 별도 확인이 필요했다.
성공과 실패를 먼저 나눴다
등록 성공, 실패, 취소를 같은 결과로 처리하지 않도록 상태를 분리했다.
벤더별 상태를 공통 상태로 매핑했다
상위 계층은 하나의 명세만 보도록 상태 전이를 다시 정리했다.
후속 계층까지 같은 명세를 보게 했다
저장결제 관리 계층과 결제 연동 계층이 같은 등록 의미를 공유하도록 맞췄다.
콜백이 없는 경우도 상태로 다뤘다
앱 이탈, 결제정보 미보유, 고객 취소처럼 콜백이 끝내 오지 않는 경우를 고려해, 대기 상태를 무한정 유지하지 않도록 timeout 기준과 후속 상태 처리를 함께 정리했다.
상태 매핑 예시 코드
벤더별 응답 코드를 공통 상태로 매핑하는 계층을 두면, 상위 API 명세를 안정적으로 유지할 수 있다.
public enum RegistrationStatus { INITIATED, COMPLETED, FAILED, CANCELLED }
public RegistrationStatus map(String vendor, String vendorCode) {
if ("KAKAO".equals(vendor) && "READY".equals(vendorCode)) return RegistrationStatus.INITIATED;
if ("KAKAO".equals(vendor) && "DONE".equals(vendorCode)) return RegistrationStatus.COMPLETED;
if ("FISERV".equals(vendor) && "BILLKEY_OK".equals(vendorCode)) return RegistrationStatus.COMPLETED;
return RegistrationStatus.FAILED;
}명세를 먼저 고정해야 확장이 흔들리지 않았다
이 작업이 남긴 가장 큰 교훈은 벤더별 구현보다 공통 명세를 먼저 세워야 한다는 점이었다. 등록 성공, 실패, 취소를 같은 이름으로 부르더라도 실제 의미가 다르면 이후 워커와 후속 처리 계층에서 계속 균열이 생긴다. 특히 초반 프론트 작업이 실제 결제 테스트보다 명세에 더 의존할 수밖에 없었던 환경에서는, 명세가 흔들리면 구현과 QA가 함께 흔들린다.
다음 글에서는 명세를 정리한 뒤 실제 등록 완료 흐름에서 마주친 WebView redirect 문제를 이어서 다룬다.