Search

PG사 추가연동 작업

1. "지금은 토스밖에 안 쓰는데 굳이?" — 결제 모듈을 Strategy로 추상화한 이유

배경: API 없는 CMS와 매월 3번의 수기 작업

뽀득 결제는 처음에 외부 CMS를 통해 돌아가고 있었다. 문제는 이 CMS가 API를 제공하지 않는다는 거였다. 그래서 매월 3회, 사람이 직접 결제 데이터를 갱신해야 했다.
이건 두 가지 리스크가 있었다.
담당자가 자리를 비우면 결제가 누락될 수 있다.
"매월 반복되는 수기 작업"은 언젠가 반드시 실수가 난다.
그래서 토스페이먼츠 빌링키 기반 정기결제를 도입해서, 결제 등록 → 청구 → 재시도까지의 비즈니스 로직을 시스템 내부로 흡수하기로 했다.

고민: 토스 하나 쓰는데 인터페이스까지 만들 필요가 있나?

여기서 첫 번째 고민이 생겼다.
지금 당장은 토스페이먼츠 하나만 쓴다. 그런데 결제 로직을 토스 SDK에 직접 박아넣으면, 나중에 다른 PG사를 붙일 때 결제 서비스 코드 전체를 뜯어고쳐야 한다.
반대로 처음부터 추상화를 하면 지금 당장은 코드가 한 겹 더 늘어난다. 소위 말하는 YAGNI("You Aren't Gonna Need It") — 필요하지도 않은 걸 미리 만들지 말라는 이야기와 정면으로 부딪힌다.
내 판단 기준은 이거였다.
결제 PG는 비즈니스가 커지면 거의 확정적으로 늘어나는 축이다. (수수료 협상, 장애 대비 이중화, 특정 결제수단 대응 등)
"혹시 필요할지도 모르는 기능"이 아니라 "시간문제인 요구사항"이라면, 그건 YAGNI의 예외다.
그래서 선제적으로 추상화하기로 했다.

해결: PG에 종속되지 않는 공통 결제 인터페이스

핵심은 결제 서비스는 "결제한다"는 행위만 알고, "어떤 PG로 결제하는지"는 몰라야 한다는 것이다. Strategy 패턴을 썼다.
// payment-strategy.interface.ts export interface PaymentStrategy { // 빌링키 발급 issueBillingKey(input: IssueBillingKeyInput): Promise<BillingKey>; // 정기결제 청구 charge(input: ChargeInput): Promise<PaymentResult>; // 결제 취소 cancel(input: CancelInput): Promise<CancelResult>; }
TypeScript
복사
토스 구현체는 이 인터페이스를 구현할 뿐이다.
// toss-payment.strategy.ts @Injectable() export class TossPaymentStrategy implements PaymentStrategy { async charge(input: ChargeInput): Promise<PaymentResult> { const res = await this.tossClient.billingCharge({ billingKey: input.billingKey, amount: input.amount, orderId: input.orderId, }); // 👇 토스의 응답을 '우리 도메인 언어'로 번역 return this.toDomainResult(res); } }
TypeScript
복사
그리고 결제 서비스는 구체적인 PG를 모른다. 인터페이스만 주입받는다.
@Injectable() export class PaymentService { constructor( @Inject('PAYMENT_STRATEGY') private readonly payment: PaymentStrategy, ) {} async billMonthly(subscription: Subscription) { // 여기엔 '토스'라는 단어가 단 한 번도 등장하지 않는다. return this.payment.charge({ billingKey: subscription.billingKey, amount: subscription.amount, orderId: generateOrderId(), }); } }
TypeScript
복사

설계 판단의 유효성이 실제로 검증된 순간

몇 달 뒤, 이니시스를 추가해달라는 요구가 실제로 들어왔다.
(서비스의 요구사항이라기보단… PG사의 투자를 이끌어내기 위한 조건이였다…)
이때가 이 설계의 가치가 증명되는 순간이었다. 내가 한 일은 이거였다.
// inicis-payment.strategy.ts @Injectable() export class InicisPaymentStrategy implements PaymentStrategy { async charge(input: ChargeInput): Promise<PaymentResult> { const res = await this.inicisClient.pay({ /* 이니시스 규격 */ }); return this.toDomainResult(res); // 이니시스 응답 → 도메인 언어 } // ... }
TypeScript
복사
Adapter 하나(정확히는 Strategy 구현체 하나)를 추가했을 뿐, PaymentService의 비즈니스 로직은 단 한 줄도 바뀌지 않았다. 청구 스케줄러, 재시도 로직, 구독 처리 전부 그대로였다.
"확장에는 열려 있고 수정에는 닫혀 있다"는 OCP를 말로만 알다가, 요구사항이 실제로 그 모양대로 들어오는 걸 눈으로 본 경험이었다.

트러블슈팅: 진짜 어려운 건 인터페이스가 아니라 "번역"이었다

추상화에서 제일 손이 많이 간 건 인터페이스 설계가 아니라 PG마다 다른 응답/에러를 하나의 도메인 언어로 정규화하는 부분이었다.
토스와 이니시스는 성공/실패를 판단하는 필드가 다르다.
에러 코드 체계가 완전히 다르다. (어떤 건 HTTP 상태로, 어떤 건 body의 resultCode로)
취소 가능 조건, 부분 취소 지원 여부도 다르다.
그래서 각 Strategy 구현체 안에 toDomainResult() 같은 번역 레이어를 두고, "PG의 방언"을 여기서 전부 흡수하도록 했다. 이 경계를 확실히 그어둔 덕분에 PaymentService는 PG의 존재 자체를 모를 수 있었다.
여기서 배운 것: 추상화의 진짜 난이도는 "공통점을 뽑는 것"이 아니라 "차이점을 어디서 흡수할지 정하는 것"이다.

덤: 운영 가시성도 같이 챙겼다

외부 PG API 통신을 공통 인터셉터로 전부 로깅해서 DB에 적재했다. 덕분에 CS팀이 PG 콘솔에 직접 접속하지 않고도 결제 내역을 확인할 수 있게 됐다. 추상화 레이어를 하나 두니, 이렇게 "모든 PG 호출이 지나가는 길목"이 생겨서 로깅 같은 횡단 관심사를 한 곳에서 처리하기 좋았다.

회고

월 3회 수기 갱신 작업 → 완전 제거
담당자 부재 시 결제 누락 리스크 → 해소
신규 PG 연동 → 구현체 1개로 확장 가능
가장 크게 남은 건 "추상화를 언제 하느냐"에 대한 감이다. 무조건 추상화가 옳은 게 아니라, 그 축이 늘어날 게 확실한가를 근거로 판단해야 한다는 것. 결제 PG는 그 근거가 충분했고, 실제로 이니시스가 들어오면서 판단이 맞았음을 확인했다.