100년 가는 프론트엔드 코드, SDK
만약 여러분이 결제를 연동하는 개발자라면, 어떤 걸 해야한다고 생각하시나요?
실제로 결제창을 띄우고 PG사에 결제를 요청하려면 꽤 번거로운 작업이 필요합니다. UI 구현, 보안을 위한 인증 흐름, HTML Form과 결제 요청을 위한 비동기 처리, 그리고 다양한 예외 처리까지 고려해야 하죠. 이 과정에서 많은 개발자가 어려움을 겪습니다.
토스페이먼츠는 이러한 문제를 해결하기 위해 결제 SDK를 만들었습니다. 개발자가 보다 쉽게 결제를 연동할 수 있도록 번거로움을 줄이는 것이 목표였죠. 그렇다면 토스페이먼츠 결제 SDK를 사용하면 실제로 결제를 어떻게 구현할 수 있을까요?
// 1. SDK 로드 및 초기화
const tossPayments = loadTossPayment(clientKey);
const payment = tossPayment.payment({ customerKey });
// 2. 결제 정보와 함께 결제 요청
await payment.requestPayment('카드', {
amount: 15000,
orderId: 'sample-order-id',
orderName: '토스 티셔츠 외 2건',
customerName: '김토스',
successUrl: 'http://localhost:3000/success',
failUrl: 'http://localhost:3000/fail',
});단 몇 줄로 결제 화면을 띄우고 PG사로 결제를 요청하는 코드가 완성되었습니다. 간단하지 않나요? 하지만 이런 연동 경험을 제공하기까지 SDK의 운영과정은 쉽지 않았는데요. 몇가지 사례를 소개해 볼게요.
FE 개발과는 다른 SDK 개발의 특수성
1️⃣ 로그를 추가했는데 결제가 안된다?
운영 중 SDK 특정 메서드의 사용량을 분석하고 싶을 때가 있습니다. 저희도 getSelectedPaymentMethod라는 메서드의 사용량을 확인해 달라는 요청을 받았고 이를 위해 메서드 호출 시 로그를 보내도록 코드를 추가했습니다.
function getSelectedPaymentMethod() {
// 추가된 코드
log.info({ transaction: 'getSelectedPaymentMethod' });
return state.getSelectedPaymentMethod().id;
}그런데 가맹점에서 “결제가 안된다”는 문의가 들어왔습니다. 원인은 해당 가맹점이 getSelectedPaymentMethod를 매우 짧은 간격으로 여러 번 호출하고 있었기 때문이었죠. 로그 추가로 인해 네트워크 의존성이 생겼고, 요청이 브라우저에 빠르게 쌓이면서 결제 페이지 전체가 느려지고 다운되는 문제가 발생했습니다. 결과적으로 추가한 로그가 우리 로그 서버에 부하를 주는 상황이 된 셈입니다.
2️⃣ startsWith is not a function
특정 가맹점에서는 customerKey라는 고객 식별자 대신 secretKey 라는 클라이언트에 노출되면 안되는 보안키를 실수로 넣는 경우가 있었습니다. 가맹점의 보안 또한 토스페이먼츠의 중요한 가치이기 때문에, customerKey에 secretKey가 들어가면 에러를 발생시키도록 처리했습니다.
if (isSecretKey(customerKey)) {
throw new Error('customerKey에는 secretKey를 사용할 수 없습니다.');
}
function isSecretKey(value: string) {
return value.startsWith('test_sk_') || value.startsWith('live_sk');
}문제는 일부 가맹점에서 customerKey에 string 타입이 아닌 number를 보내면서 발생했습니다. number 타입에는 startsWith 없기 때문에 해당 메서드를 호출하는 부분에 문제가 발생했고 startsWith is not a function 에러가 발생한 것이죠.
이처럼 SDK 개발은 일반 프론트엔드 개발과 많이 달랐습니다.
- 가맹점 코드 속에 깊숙이 박혀, 가맹점 코드와 동일한 수명을 가졌고
- 가맹점의 연동 방식과 런타임 환경에 따라 호출되었습니다.
- 뿐만 아니라 결제 연동의 시작점인 만큼, 가맹점과 만나는 기술 커뮤니케이션의 시작점이었습니다.
SDK는 단순히 API를 소비하는 것이 아니라, 결제 서비스를 제공하는 API 제공자의 역할까지 수행해야 했습니다.
V1 SDK
이런 SDK의 특성 측면에서 기존 토스페이먼츠의 SDK는 많은 한계와 문제점들이 존재했습니다.
시스템의 미비로 버그는 잦은 빈도로 발생하고 가맹점의 요구사항으로 소프트웨어는 여러 맥락과 복잡도를 감당해야만 했어요. 또한 최적의 연동 경험이라고 하기에는 연동 개발자들의 고충은 여전히 존재했습니다. 저희는 이런 V1 SDK의 한계를 해결해야 하겠다는 문제의식을 갖기 시작했고 새로운 SDK, V2 SDK를 만들자라는 결론을 도출했어요.
V2 SDK의 시작
앞선 사례를 통해 SDK에 새로운 가치가 필요하다는 것을 느꼈습니다. 기존 목표인 “가맹점 연동을 쉽게”에서 나아가, 안정성, 확장성, 명확성을 목표로 하는 고도화된 SDK가 필요했죠.
이렇게 저희는 SDK가 지켜야 할 핵심가치를 세우고 이를 위한 장치들을 V2 SDK에 하나둘씩 마련해가기 시작했습니다.
안정성을 향해
앞서 말한 사례들처럼 SDK는 가맹점에 의존적인 라이프사이클을 가지기 때문에, 예측하기 어려운 문제들이 발생했었습니다. 일반적인 QA로는 알 수 없는 문제들이 존재했죠. 그래서 안정성을 중요한 목표로 세우고, 사이드 이펙트가 없도록 안전 장치를 하나씩 마련하기 시작했습니다.
우선 가장 빠른 피드백을 확보할 수 있는 테스트 코드에서부터 안정성을 챙겼습니다. 비즈니스 로직을 검증하는 단위 테스트를 300개 이상 작성했고, 여러 결제 제품의 유즈케이스를 기반으로 E2E 통합 테스트 500개 이상을 확보했습니다. 또, 배포 후에도 문제 상황을 빠르게 탐지하고 회복 탄력성을 확보하기 위해 촘촘하게 작성된 로그를 기반으로 ElasticSearch를 이용한 대시보드와 얼럿 시스템을 구축했습니다.
하지만 테스트코드만으로는 부족했다
그럼에도 불구하고 안정성을 확보하는 과정은 쉽지 않았습니다. 다양한 가맹점의 환경과 유스케이스를 파악하는 데 한계가 있었죠. 가맹점의 연동 방식은 항상 저희의 상상을 뛰어넘었어요. 그래서 저희는 ‘좀 더 빠르고 쉽게, 결제 이상 지점을 파악할 수 없을지’ 고민하기 시작했습니다.
운영을 하면서 느낀 건, 결제 장애는 일정한 패턴이 있다는 것이었습니다.
- 대부분 잘 작동하지만 특정 가맹점에서만 안된다.
- PC에서는 되는데 모바일에서 안된다. 웹에서는 잘 되는데 웹뷰에서는 안된다.
특정 연동 환경을 가진 가맹점이나 특정 런타임 환경에서 예측하지 못한 문제가 발생했고, 이는 개발자가 가장 예측하기 어려운 영역이었습니다. 매 개발마다 가맹점의 모든 연동 유즈케이스와 환경을 파악하는데에는 무리가 있었고 해당 지점에서 가장 많은 장애가 발생했죠. 그래서 우리는 이러한 예측 불가능 지점을 빠르게 파악하기 위한 토스페이먼츠 결제제품만의 특별한 모니터링 도구를 만들었습니다.
Global Trace ID와 모니터링 CLI
토스페이먼츠에는 global trace id라는 지표가 있습니다. FE, BE 관계없이 가맹점 결제 페이지에서 결제를 시작하고 성공하기까지 모든 과정을 global trace id와 함께 로깅하고 있습니다. 이 식별자만 검색하면 시스템 레이어 전체를 쉽게 추적할 수 있습니다. global trace id에 대한 소개는 SLASH 23 - 분산 추적 체계 & 로그 중심으로 Observability 확보하기에서 더 자세하게 확인해 볼 수 있어요.
우리는 이 global trace id를 이용해서 결제 요청은 했지만 실제로 결제가 성공하지 못한 건을 추릴 수 있었습니다. 시스템에 처음 들어온 결제 요청 로그와 시스템의 마지막 수행인 결제완료 로그를 매핑해서 그 추이를 한눈에 볼 수 있었습니다. 그리고 그 로그를 가맹점별, 런타임 환경별로 구분하여 결제 성공 평균치를 계산하고, 자체 해석을 더한 모니터링 CLI를 개발했습니다.
배포 이후에 모니터링 CLI를 실행한 결과를 함께 볼게요.

진영몰의 결제 성공건이 41건에서 0건으로 중단되었습니다. 배포 전에는 평균 41건 성공했는데, 배포 후에는 어떤 요청도 성공하지 못한 것이죠.

그리고 해당 가맹점의 런타임 환경별로도 추이를 확인할 수 있습니다. 배포 전에는 PC Chrome, Android WebView, iOS Webview 에서 결제가 잘 발생했지만 배포 후에는 환경 관계없이 모두 0건이 된걸 확인할 수 있습니다.
확장성을 향해
SDK가 가맹점과 소통하는 창구인만큼 가맹점들마다 존재하는 다양한 요구사항을 수용해야 했었습니다. 특정 가맹점에게만 유효성을 검증 로직을 추가해줘야 한다던가, 특정 가맹점의 경우 카드 할부 개월수를 고정한다던가, 특정 가맹점의 프로모션 정보를 특정 값으로 고정해 줘야할 때가 존재했고 저희는 이런 요구사항을 어떻게 개발해야 할지 고민해야 했습니다.
if문은 싸다
처음에는 간단한 if 문으로 처리하려 했습니다. 실제로 초기 코드는 아래처럼 구성되어 있었습니다.
async function requestPayment(params) {
if (isJinyoungMall(clientKey)) {
// 진영몰을 위한 파라미터 검증 로직 추가
const isValid = await validateParamsForJinyoungMall(params);
if(!isValid) {
throw new Error("유효하지 않은 결제 파라미터입니다.");
}
}
if(isSungHyunMall(clientKey)) {
//성현몰은 카드 할부개월수 1개월로 고정
cardInstallmentPlan = 1;
} else {
cardInstallmentPlan = widget.getCardInstallmentPlan();
}
if(isTossMall(clientKey)) {
//토스몰은 토스몰만을 위한 프로모션 정보 전달
promotions = params.promotionsForTossMall;
} else {
promotions = widget.getPromotions();
}
// ... 끝없는 if문들
}하지만 요구사항이 늘어나니 상황이 달라졌습니다. 요구사항이 늘어나면서 중요한 비즈니스 로직을 파악하기 어려워지고, 특정 가맹점의 요구사항을 추적하는 것도 비용이 늘어나기 시작했어요. 중요 비즈니스 로직을 읽어야 할 때는 특정 가맹점의 요구사항을 함께 읽어야 하고 반대로 특정 가맹점의 요구사항을 읽어야 할 때는 isTossMall 와 같은 분기문을 code search를 해서 잡히는 파일들을 전부 열어서 파악해야 했습니다.
조립 가능한 SDK
저희는 이 문제를 해결하기 위해 소프트웨어가 구성하는 메시지에 집중했습니다. "결제를 요청한다"는 메시지만 남기고 핵심 로직과 특정 가맹점의 로직을 분리하기 시작했어요. 모든 가맹점은 동일한 메시지를 따르지만, 필요하다면 자신만의 구현을 가질 수 있도록 만들었습니다.

저희는 구현을 작은 레고 블록 단위로 구성해서, 가맹점의 요구사항을 레고 블록을 바꾸는 것만으로 대응할 수 있도록 설계했습니다. 블록 간의 중복은 줄이고, 가맹점의 커스텀 요구사항은 한 블록으로 격리했습니다. 이렇게 하면 모든 커스텀 요구사항을 더 적은 비용으로 빠르게, 기존 동작에 영향을 미치지 않고 구현할 수 있다고 생각했어요.
조립의 단위
그런데 레고 블록이라면 서로 맞물리는 부분이 있고, 그 부분끼리 조립을 해야 하는데요. 그러다보니 자연스럽게 어떤 기준으로 맞물리는 부분을 만들어야 할지 고민하게 되었어요. 이를 위해 소프트웨어 설계 관점으로 문제를 고민하다가 업계에 유명한 격언에서 인사이트를 얻었습니다. "변경의 원인이 되는 곳을 따라서 경계를 그어라". 저희는 이 격언을 따라서 두 가지 경계를 긋고 세 가지의 계층을 도출 할 수 있었습니다.
- Public Interface Layer: 가맹점이 사용하는 메서드의 형태에 따라 바뀌는 곳. 이 레이어의 코드는 가맹점과의 메시지에 따라 변경됨을 의미.
- Domain Layer: 도메인 정책, 비즈니스 로직에 따라 바뀌는 곳. 이 레이어의 코드는 우리의 도메인, 비즈니스로직에 따라 변경됨을 의미.
- External Service Layer: 서버 API, Web API 등 외부 의존성에 따라 바뀌는 곳. 도메인이나 가맹점과의 메시지가 아닌 외부 의존성의 변경이나 기술적 변경에 따라 변경됨을 의미.
‘결제를 요청한다’는 메시지의 구현
‘결제를 요청한다’는 메시지가 각 레이어마다 어떤 구현을 가지는지 한번 살펴볼까요?
// Public Interface Layer
interface WidgetPublicInterface {
requestPayment(params: PaymentParams): Promise<PaymentResult>;
}
class WidgetSDK implements WidgetPublicInterface {
constructor(private readonly dependencyConfigurations: DependencyConfigurations) {}
requestPayment(paymentRequest: PaymentParams) {
// 가맹점과 약속한 인터페이스에 대한 검증 로직
if ( paymentRequest.failUrl != null && paymentRequest.successUrl == null) {
throw new Error('successUrl이 누락되었습니다.');
}
// 도메인 계층으로 메시지를 전달하기 위한 번역
const amount = new DomainModel.Amount(paymentRequest.amount);
// 도메인 계층으로 메시지 전달
const result = await this.dependencyConfigurations.PaymentWidgetRequestPaymentService.execute(...);
// 도메인 계층의 응답을 가맹점과 약속한 인터페이스로 번역
return {
...result,
amount: result.amount.getAmount();
currency: result.amount.getCurrency();
};
}
첫 번째로 Public Interface Layer 계층입니다. 가맹점과 약속한 인터페이스를 WidgetPublicInterface 라는 인터페이스로 명시하고 WidgetSDK 라는 구현체는 해당 인터페이스를 구현하도록 작성했습니다. Public Interface Layer 계층은 가맹점과의 인터페이스를 관심사로 삼고 이를 변경의 원인으로 삼기 때문에 인터페이스의 약속을 검증하거나 도메인 계층으로 메시지를 보내기 전에 도메인 계층에 맞게 번역하는 역할을 수행합니다. 반대로 도메인 레이어에서 나온 응답값을 가맹점과 약속한 인터페이스에 맞게 번역하는 역할 또한 수행하죠.
// domain/port-in
interface WidgetRequestPaymentUsecase {
execute(params: RequestPaymentParams): Promise<RequestPaymentResult);
}
// Domain Layer
class StandardWidgetRequestPaymentUsecase implements WidgetRequestPaymentUsecase {
constructor(private readonly externalPaymentWidgetService: ExternalPaymentWidgetService,
private readonly customerKeyRepository: CustomerKeyRepository) {}
async execute(request: RequestPaymentParams): Promise<RequestPaymentResult> {
// 약관 위젯을 사용하는 경우 필수 약관이 동의되었는지 검증
if(widgets.agreementWidget != null && !widgets.agreementWidget.isRequiredTermsAgreed()) {
throw new NeedAgreementWithRequiredTermsError();
}
// 카드사를 선택했는지, 할부를 선택했는지 등 결제수단 위젯의 필수요건이 충족되었는지 검증
try {
await widget.paymentMethodWidget.validatePaymentRequest();
} catch (error) {
throw error;
}
// 외부 의존성(서버)를 통해 선택한 결제수단의 mid라는 결제정보 불러옴
const selectedPaymentMethod = widget.paymentMethodWidget.getSelectedPaymentMethod();
const mid = this.externalPaymentWidgetService.getMid(selectedPaymentMethod);
// 외부 의존성(메모리스토리지)를 통해 해당유저의 customer key 불러옴
const customerKey = this.customerKeyRepository.getCustomerKey().value;
...
}
}두번째는 Domain Layer 계층입니다. 여기에서는 가맹점과의 인터페이스나 특정 기술에 대한 구현의 맥락 없이 “결제를 요청한다” 라는 유즈케이스의 일련의 비즈니스 로직이 기술되어있습니다. Domain Layer 계층은 도메인을 변경의 원인으로 삼기 때문에 도메인이나 비즈니스 요구사항이 변경될때 해당 변경사항을 반영하는 역할을 수행합니다. 반대로 스스로 수행하기 어려운 로직은 External Service Layer의 외부의존성에 요청하는 역할을 수행합니다.
// domain/port-out
interface ExternalPaymentWidgetService {
getMid:(paymentMethod: PaymentMethod) => Mid;
}
interface CustomerKeyRepository {
getCustomerKey: () => CustomerKey;
}
// External Service Layer
class PaymentWidgetServer implements ExternalPaymentWidgetService {
constructor() {
this.httpClient = new XHRHttpClient();
}
async getMid(paymentMethod: PaymentMethod) {
// xhr을 이용해 서버로 부터 http 요청
return this.httpClient.get(...);
}
...
}
class SessionCustomerRepository extends CustomerKeyRepository {
const key = '@TOSSPAYMENTS_CUSTOMER_KEY';
constructor() {
this.sessionStorage = new SessionStorage();
}
async getCustomerKey() {
// 웹 브라우저의 세션 스토리지를 이용하여 mid 조회
return this.sessionStorage.get(key);
}
...
}마지막으로 External Service Layer 계층입니다. 여기에서는 서버, 웹 api 등등 기술에 대한 의존성을 가진 계층이고 SDK의 도메인이 아닌 로직들을 이 계층에서 담당하고 있습니다. 도메인이나 가맹점과의 계약이 아닌 이외의 의존성들의 기능을 사상하고 변경을 반영하는 역할을 수행합니다.
여기에서 중요한점은, 경계를 넘어선 구현을 참조할 때는 모두 인터페이스를 기준으로 의존성을 주입받는 형태로 구성되어 있다는 것입니다. 예를 들어서 Domain Layer에서 External Service Layer로 저장된 customer key를 요청한다고 할 때, 세션 스토리지에서 가져올지 로컬 스토리지에서 가져와 같은 구현사항은 런타임 시점에 의존성을 조립할 때 결정되고 Domain Layer 에서는 오직 customer key를 불러온다는 인터페이스만 참조하는 형태라는 것이죠. 레이어 간의 경계에서는 의존성을 역전시키고 각 레이어의 구현에 대한 의존성을 끊어냄으로써 좀 더 유연하고 확장성있는 구조를 확보했습니다.
표준 SDK + 커스텀 SDK
특정 가맹점에게 유효성 검사를 추가해 달라는 예시로 돌아와 보겠습니다. 유효성 검사는 비즈니스 로직이기 때문에, 진영몰을 위한 새로운 도메인 코드 블록만 만들어서 유효성 검사 로직을 추가하면 됩니다.
// Domain Layer
class JinyoungMallWidgetRequestPaymentUsecase implements WidgetRequestPaymentUsecase {
async execute(request: RequestPaymentParams): Promise<RequestPaymentResult> {
try {
await this.externalPaymentWidgetService.validateRequestPaymemtParams(request);
} catch (error) {
throw error;
}
...
}
}그리고 기존에 StandardWidgetRequestPaymentUsecase라는 레고 블럭을 JinyoungMallWidgetRequestPaymentUsecase 레고 블럭으로만 변경하고, 해당 가맹점에게 커스텀 SDK를 제공하면 표준 SDK를 오염시키지 않고 새로운 요구사항을 깔끔하게 대응할 수 있게 되죠.
명확성을 향해
다음은 가맹점이 결제 요청을 보내는 파라미터의 일부를 가져와 봤습니다. 실제로 아래와 같은 많은 결제 파라미터들을 requestPayment 메서드를 통해 보내고 있었고 이때 각 파라미터 각각마다 많은 제약사항과 의미가 담겨있었습니다. 가맹점 개발자들을 이 내용을 모두 학습하여 페이먼츠와 약속한 형태로 해당 값들을 전달하고 있었습니다.

이런점들에서 파라미터, 그리고 더 나아가 SDK 인터페이스는 단순한 함수가 아니였습니다. 어떤 값을 어떻게 보내야 하는지 가맹점과 SDK가 약속한 하나의 계약이자 명세였죠. SDK와 가맹점은 오래 함께 하는 사이인 만큼 그 접점에 있는 인터페이스는 단순한 인터페이스가 아니라 가맹점과 우리 사이의 '공식 언어'이자 공동으로 학습해야 하는 지식이었습니다.
인터페이스를 계약으로 바라보다
그래서 저희는 인터페이스를 함수를 넘어서 계약으로 바라보기 시작했습니다. 약속을 더 명확하게 하기 위해 계약으로 분리함으로써 구현에 오염되지 않고 오직 인터페이스만 존재하는 구조로 경계 설정했어요. 인터페이스가 계약이 됨으로써 코드 그 자체이면서도 문서이고 스펙이고 약속이 되게 만들었습니다. 단순히 함수의 시그니처를 인터페이스로 분리한 게 아니라. jsdoc을 이용해서 함수가 반환하는 에러와 응답, 타입으로 나타내기 어려운 명세를 담은 주석까지 가맹점과의 모든 "약속"을 명시했어요.
/**
* 결제를 요청합니다
* @docsDefaultSignature RequestPaymentWithRedirection
*
* @param {WidgetPaymentRequest} paymentRequest - 결제 요청 정보입니다.
*
* @returns 결제 결과
*
*
* @throws {@link PublicError.Widgets.UserCancelError} 사용자가 결제를 취소한 경우
* @throws {@link PublicError.Widgets.ProviderStatusUnhealthyError} 결제 기관의 시스템에 문제가 있을 때
* ...
*/
export type RequestPayment = RequestPaymentWithPromise & RequestPaymentWithRedirection;
/**
* @docsAlias Promise 방식
* @returns `WidgetPaymentResult` 객체가 응답됩니다. 객체 필드를 확인하고 [결제 승인 API](/reference#결제-승인)를 호출해야 결제가 최종적으로 완료돼요.
* @param {WidgetPaymentRequest} paymentRequest 결제 요청 정보입니다.
*
* @example
* ```js
* widgets.requestPayment({
orderId: generateRandomString(),
orderName: "토스 티셔츠 외 2건",
customerEmail: "customer123@gmail.com",
customerName: "김토스",
});
* ```
*/
type RequestPaymentWithPromise = (
paymentRequest: WidgetPaymentRequest
) => Promise<WidgetPaymentResult>;
export interface WidgetPaymentRequest {
/**
* 주문번호입니다. 각 주문을 구분하는 무작위한 고유값을 생성하세요. 영문 대소문자, 숫자, 특수문자 `-`, `_`, `=`로 이루어진 6자 이상 64자 이하의 문자열이어야 합니다.
*/
orderId: string;
/**
* 구매상품입니다. 예를 들면 `생수 외 1건` 같은 형식입니다. 최대 길이는 100자입니다.
*/
orderName: string;
/**
* 구매자 이메일입니다. 결제 상태가 바뀌면 이메일 주소로 결제내역이 전송됩니다. 최대 길이는 100자입니다.
*/
customerEmail?: string | null;
/**
* 구매자명입니다. 최대 길이는 100자입니다.
*/
customerName?: string | null;
...
}또, 이런 계약을 npm 패키지로 분리하여 단일 계약 패키지의 형태로 제공했습니다. 구현과의 물리적 경계를 형성해서 구현과의 변경의 시점을 분리했습니다.
// @tosspayments/standard-public-interface 패키지
export interface PaymentPublicInterface {
requestPayment(params: PaymentRequestParams): Promise<PaymentResult>;
// ...
}
// 실제 SDK에서 계약 구현
import { PaymentPublicInterface } from '@tosspayments/standard-public-interface';
class PaymentSDK implements PaymentPublicInterface {
async requestPayment(params: PaymentRequestParams): Promise<PaymentResult> {
...
}
}이렇게 SDK 인터페이스를 '계약'으로 분리하면서 이 계약을 다양하게 활용할 수 있었는데요. 그 중 몇 가지 활용을 이야기해 보겠습니다.
Git이 계약서이자 히스토리북이 되다
기존 v1 SDK의 문제들 중 가장 큰 문제는 히스토리 파악이 어렵다는 것이었습니다. 이 파라미터가 왜 추가되었는지 어떤 논의를 거쳤는지 왜 이렇게 변경되었는지를 알 수 없었죠. 슬랙이나 노션을 찾아가며 이전에 개발한 개발자가 구두로 논의한게 아닌 어딘가 기록을 해두었기를 바랄 수 밖에 없었습니다.
이런 문제를 해결하기 위해 인터페이스를 관리하는 git을 계약 명세서이자 히스토리북의 역할이 되도록 만들었습니다. 계약을 관리하는 git의 커밋 컨벤션을 만들어서 git diff만 봤을 때 누구에 의해 어떤 계약이 언제 변경됐는지 알 수 있게 했죠. 아래는 이런 git을 관리하기 위한 커밋 컨벤션의 일부를 가져왔습니다.
그리고 해당 컨벤션을 기반으로 작성된 실제 git diff 중 일부입니다. 실제로 통화를 나타내는 currency라는 파라미터의 diff를 추적해보면 USD 통화가 언제 누가 왜 추가되었는지를 알 수 있었죠. 이를 통해서 문서나 사내 메신저 등 중복된 출처가 아니라 Git이 히스토리의 단일 신뢰 지점이 되게 할 수 있었고 예전엔 특정 사람만 알고 있던 히스토리를 이제는 시스템 차원에서 추적할 수 있었습니다.

살아있는 연동 문서
기존 V1 SDK의 문제들 또 하나의 문제는 문서와 실제 동작이 다른 경우가 발생한다는 것이었습니다. 매번 사람이 수동으로 작성하는 SDK 문서는 개발 사이클과 멀어지고 관리되지 못하는 경우가 빈번히 발생했고 누군가 제보를 주거나 개발자가 발견하지 않으면 docs에 작성된 내용은 수정되기가 어려웠습니다.
이를 위해 Typescript 기반으로 작성된 계약을 활용하여 문서를 자동으로 생성될 수 있도록 만들었습니다. Typescript의 컴파일러를 이용해서 Typescript Interface와 Jsdoc을 읽고 이를 Mdx 로 추출해서 정적 서비스를 서빙하는 docs 서비스에 업로드 하는 방식으로 변경했습니다. 그리고 해당 로직을 계약에 변경사항이 생길 때마다 CI를 통해 자동으로 돌아갈 수 있도록 설정했습니다.
// release.yaml
- name: Trigger SDK Docs Auto Generation
if: steps.changesets.outputs.published == 'true'
uses: actions/github-script@v6
with:
github-token: ${{ secrets.FRONTEND_GITHUB_TOKEN }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: 'docs',
workflow_id: 'SDK-public-interface-update.yml',
ref: 'main',
})
실제로 docs에서 제공하고 있는 v2 SDK docs는 이 계약 인터페이스를 기반으로 자동으로 생성된 문서입니다. 실제로 계약이 변경되면 아래의 docs에도 해당 변경사항이 자동으로 반영되게 됩니다.

이를 통해 구현된 코드의 시그니처가 문서와 동일하게 됨으로써 문서와 코드 간의 불일치를 해결할 수 있었습니다. 또한 문서를 자동 생성되게 만듦으로써 별도의 연동문서를 관리하는 관리 비용도 감소 할 수 있었습니다. 무엇보다 이제 계약이 가맹점 개발자에게 공개된다는 의미가 생겼고 이를 통해 좀 더 가맹점 개발자에게 친화적인 형태로 계약이 발전할 수 있었습니다.
유효성 검증 계층
v1 SDK를 운영하면서 느낀건 연동하는 개발자가 무엇이 잘못된건지 알기 어렵다는것이었습니다. 필수값을 보내지 않았거나 약속된 타입이 아닌 타입을 보내는 등등 여러 연동실수를 했지만 쉽게 이해할 수 없는 에러메시지로 인해 문제지점을 찾기 어려웠습니다. docs에 작성된 내용이 었지만 여러 내용들에 가려져 놓치기 쉽다보니 이런 연동 실수는 자주 발생하곤 했었어요.
마찬가지로 해당문서를 해결하기 위해 Typescript 기반으로 작성된 계약을 활용하기로 했습니다. Typescript 컴파일러를 이용해 Typescript Interface를 zod schema로 변환하고 이를 기반으로 런타임 시점에 Validation 할 수 있도록 작성했습니다. 또 이때 발생하는 에러의 원인을 명시적으로 나타낼 수 있도록 에러메시지를 변환하여 어떤 필드가 어떻게 잘못되었는지를 알 수 있게 만들었습니다.
export function ValidateParameters(
criterion: schema.SchemaType,
errorConstructor: new (message: string) => Error
) {
return function (_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
try {
criterion.parse(args[0]);
} catch (e) {
if (schema.isSchemaError(e)) {
const errorMessage = translateSchemaError(e);
throw new errorConstructor(errorMessage);
}
}
return originalMethod.apply(this, args);
};
return descriptor;
};
}
function translateSchemaError(e: schema.SchemaError) {
...
const wrongParameterNames = issuesToThrow.map(issue => errorParameterName(issue)).join(', ');
switch (issueCodeToThrow) {
case ExtendedSchemaIssueCode.invalid_type: {
return `${wrongParameterNames} 파라미터의 타입이 올바르지 않습니다.`;
}
case ExtendedSchemaIssueCode.unrecognized_keys: {
return `${wrongParameterNames}는 정의되지 않은 파라미터입니다.`;
}
case ExtendedSchemaIssueCode.invalid_union_discriminator: {
return `${wrongParameterNames} 파라미터에 사용할 수 없는 enum 값입니다.`;
}
case ExtendedSchemaIssueCode.invalid_enum_value:
case ExtendedSchemaIssueCode.invalid_literal: {
return `${wrongParameterNames} 파라미터의 값이 올바르지 않습니다.`;
}
case ExtendedSchemaIssueCode.missing_required_parameters: {
return `${wrongParameterNames} 필수 파라미터가 누락되었습니다.`;
}
case ExtendedSchemaIssueCode.unexpected_parameter_in_method: {
return `파라미터가 없는 메서드입니다.`;
}
default: {
return '파라미터가 올바르지 않습니다.';
}
}
}이를 통해서 잘못된 연동에 대해서 개발자에게 빠르게 피드백 할 수 있었습니다. 어떤 부분이 문제인지를 에러를 이용해서 피드백하고 이를 통해서 SDK의 연동 경험을 향상 시킬 수 있었어요. 뿐만 아니라 잘못 연동한 값이 SDK의 도메인 계층까지 흘러들어가지 않도록 하는 오류방지계층의 역할하는 이점 또한 챙길 수 있었습니다.
V2 SDK를 만들며
이렇게 V2 SDK를 만들면서 다양한 임팩트들을 얻을 수 있었습니다.
마치며
결제 SDK를 만든다는 건 단순히 결제 서비스를 제공하는 일이 아니었습니다. 가맹점의 비즈니스가 안정적으로 돌아가도록 돕고, 개발자가 결제를 손쉽게 연동할 수 있게 만드는 것.
결국 개발 경험을 설계하면서, 안정적인 결제를 보장하고, 새로운 기능과 커스터마이징 요구도 부담 없이 수용할 수 있는 확장 가능한 플랫폼을 만드는 일이었습니다. 여러 원칙을 기반으로 V2 SDK를 다시 쌓아 올리며, 우리는 기술적 완성도 뿐 아니라 가맹점에게 신뢰받는 SDK가 되기 위한 기준을 세울 수 있었습니다.
✅ 이번 아티클은 아래 Toss Makers Conference 25의 세션을 바탕으로 재구성되었습니다.
