가맹점은 변함없이, 결제창 시스템 전면 재작성하기
"가맹점의 변경 없이도 결제 시스템은 진화할 수 있을까?"
토스페이먼츠는 오래된 시스템을 인수한 이후 단계적으로 진정성 있게 시스템을 개선해왔습니다. 이번 글에서는 20년간 유지되어 온 PG의 레거시 결제창 시스템을, 변화에 유연하게 진화할 수 있는 새로운 결제 시스템으로 재탄생시킨 여정을 공유합니다.
결제창이라는 단어가 생소하실 수도 있지만, 온라인에서 결제를 한 번이라도 해보셨다면 분명 만나보셨을 겁니다. 상품을 구매하고 결제 버튼을 눌렀을 때 나타나는 바로 그 창이죠. 토스페이먼츠의 결제창은 국내 모든 카드사, 대부분의 해외 카드사, 그리고 토스페이와 같은 주요 간편결제사를 모두 지원합니다. 겉으로 보기에는 잘 작동하는 것처럼 보이지만, 새로운 결제 방법을 추가하거나 기능을 개선하려 할 때마다 우리는 큰 벽에 부딪혀 왔습니다.
20년 된 레거시 시스템의 한계
1️⃣ 절차지향적 코드의 늪
레거시 시스템의 가장 큰 문제는 절차지향적으로 작성된 코드 구조였습니다. 하나의 Java Class Method에서 모든 분기를 처리하고 있었는데, 단순히 결제수단에 따라 분기하는 코드만 2,500줄에 달했습니다.
// 실제로 이런 식의 코드가 2,500줄 이상 이어졌습니다
if (payMethod.equals("CARD")) {
// 카드 결제 로직
if (cardType.equals("SHINHAN")) {
// 신한카드 처리
} else if (cardType.equals("KB")) {
// KB카드 처리
}
// ... 수십 개의 카드사 분기
} else if (payMethod.equals("BANK")) {
// 계좌이체 로직
} else if (payMethod.equals("VIRTUAL_ACCOUNT")) {
// 가상계좌 로직
}
// ... 끝없는 분기문
Method 곳곳에 코드를 끼워넣는 행위들이 반복되어 작동되고 있었고, 공통적으로 필요한 특정 기능들이 컴포넌트로 구조화되어 있는 것이 아니라 각 결제방식마다 동일한 코드가 재작성되어 있었습니다.
2️⃣ 구시대의 기술 스택
출시한지 20년이 넘은 Struts와 WebLogic을 사용하고 있었습니다. 대부분의 개발자에게 익숙하지 않은 프레임워크였기에, 새로운 팀원이 합류할 때마다 레거시 프레임워크에 대한 학습비용이 매우 높았습니다.
3️⃣ BE와 FE의 강결합
결제창 화면을 담당하는 FE 로직과 수많은 원천사와의 통신을 담당하는 BE 로직이 강결합되어 있어 관심사 분리가 불가능한 상태였습니다.
예를 들어서, 아래 예시코드가 실제 JSP 파일 내에서 있던 레거시 로직 중 일부를 표현한 것인데요. JSP 파일에서 직접 JDBC 커넥션을 얻어와 쿼리를 실행하는 코드였습니다.
<%
<
이렇듯 화면을 담당하는 FE 로직과 비즈니스 로직이 뒤엉켜 있어, 하나의 기능을 추가하려면 JavaScript, JSP, Java 클래스 모두를 수정해야 했습니다.
개편의 시작: 파라미터 구조 재설계
1️⃣ 수백 개의 파라미터를 어떻게 정리할 것인가
토스페이먼츠 결제창 연동에는 두 가지 방식이 있습니다:
레거시 파라미터는 수백개의 Flat한 파라미터 형태로 존재했고, 심지어는 하나의 파라미터가 같은 값이더라도, 사용하는 맥락에 따라서 다른 방식으로 작동되기도 했습니다.
2️⃣ 추가적인 문제점 : 전역변수
이 모든 파라미터는 하나의 거대한 결제요청 DTO 클래스에서 관리되고 있었습니다. 이 결제요청 DTO 필드들에 대해서 시스템의 전반에서 get,set을 수행하고 있었죠. Mutable한 값이 시스템 전체에 지배적인 상황, 즉 전역변수에 가깝게 취급되고 있었습니다.
이러한 전역변수처럼 사용하는 결제요청 DTO 내의 값들을 정확하게 알기 위해서는 값이 변하는 모든 코드 내의 지점들을 쫓아다니면서, ‘어느 클래스의 어떤 코드가 먼저 실행이 되었으니, 여기서는 이 값일거야’라는 추측에 기반한 개발이 강제되었습니다.
3️⃣ SessionCreationRequest: 새로운 시작
우리는 파라미터를 두 가지로 분류했습니다:
이를 바탕으로 SessionCreationRequest
라는 새로운 DTO를 설계했어요.
data class SessionCreationRequest(
// 값 파라미터는 그대로 저장
val merchantId: String,
val orderId: String,
val amount: BigDecimal,
// 기능 파라미터는 Feature로 추상화
val cashReceiptFeature: FeatureCashReceipt?,
val cardDiscountFeature: FeatureCardDiscount?,
val cardInstallmentFeature: FeatureCardInstallment?,
)
3️⃣ Feature 추상화로 비즈니스 로직 보호하기
예를 들어, 현금영수증 발급 기능은 이렇게 추상화했습니다.
Before: 파편화된 파라미터 처리
// 시스템 곳곳에 산재한 중복 코드
String cashReceiptYn = request.getParameter("CASH_RECEIPT_YN");
String cashReceiptType = request.getParameter("CASH_RECEIPT_TP");
if ("Y".equals(cashReceiptYn) && "I".equals(cashReceiptType)) {
// 현금영수증 발급 로직
}
After: Feature로 추상화
data class FeatureCashReceipt(
val enabled: Boolean,
val type: CashReceiptType,
) : Feature {
companion object {
fun from(legacyCashReceiptYnParam: String?,
legacyCashReceiptTypeParam: String?
): FeatureCashReceipt = FeatureCashReceipt(
enabled = legacyCashReceiptYnParam == "Y",
type = CashReceiptType.of(legacyCashReceiptTypeParam)
)
}
}

기존 레거시 시스템의 코드에 중복으로 존재하던 결제의 핵심 코어 로직들을 응집성 있는 단위로 뽑아내어 리팩토링했습니다. 이 코어 로직을 처리하는 Component에서는 가맹점의 파라미터를 직접 체크하고, Validation까지 수행하던 기존 구조와 다르게, 위에서 정의한 FeatureCashReceipt
를 인자로 받게끔 되어있어요. FeatureCashReceipt은 “현금영수증 발급요청”에 대한 세부기능이 추상화된 단위이기 때문에, 파편화된 외부 인터페이스인 파라미터들의 오염으로부터 코어한 비즈니스 로직을 지킬수 있습니다.
만약, 이런 구조에서 현금영수증과 관련된 추가적인 기능을 개발해야 한다면 어떻게 해야할까요?
필요하다면 추가된 결제요청 파라미터를 바탕으로, 추상화된 요청 객체를 FeatureAnother로 선언하고, 그 후에는 FeatureAnother 기능과 FeatureCashReceipt 두가지를 인자로 받는, core 로직 component 부분만 추가 기능을 개발하면 될 것입니다.

OpenAPI Adapter 벗어나기
1️⃣ OpenAPI Adapter 문제점
OpenAPI 파라미터 연동방식은, 법인설립 초기에 일관된 결제경험을 위해 제공한 연동방식이기에 사실은 레거시 파라미터를 감싼 형태였습니다. 스트랭글러 패턴처럼 OpenAPI 파라미터는 레거시 파라미터 앞에 있는 일종의 Proxy 형태로, 변환해주는 Adapter 레이어를 거쳐 최소 수십 개의 레거시 파라미터들로 변환됩니다.
OpenAPI 파라미터 → Adapter Layer 변환 → 레거시 파라미터 → 결제 요청
이라는 변환과정을 항상 거쳐야만 했는데요.
OpenAPI에 새로운 기능을 제공하기 위해서는 이에 대응되는 레거시 파라미터도 추가해주고, 변환 Adapter에서의 로직도 추가가 필요했습니다. 시간이 지날수록 비즈니스 로직이 녹아들어 단순 변환계층보다는 로직이 복잡해지고 비대해지기 시작했습니다.
2️⃣ 신규 시스템의 해결책
신규 시스템에서는 각 연동 방식별로 독립적인 Converter를 만들었습니다. SessionCreationRequest를 만드는 부분을 두 가지로 구분했어요.
OpenAPIConverter
class OpenAPIConverter {
fun convert(request: OpenAPIRequest): SessionCreationRequest =
SessionCreationRequest(
merchantId = request.merchantId,
orderId = request.orderId,
amount = request.amount,
cashReceiptFeature = request.cashReceipt,
cardDiscountFeature = request.cardDiscount,
...
cardInstallmentFeature = request.cardInstallment,
...
...
)
}
LegacyConverter
class LegacyConverter {
fun convert(params: Map<String, String>): SessionCreationRequest =
SessionCreationRequest(
merchantId = params["MID"],
orderId = params["OID"],
amount = params["AMOUNT"],
cashReceiptFeature =
FeatureCashReceipt.from(
params["CASH_RECEIPT_YN"],
params["CASH_RECEIPT_TP"],
),
cardDiscountFeature =
FeatureCardDiscount.from(
params["DC_NO"],
params["DC_AUTO"],
),
cardInstallmentFeature =
FeatureCardInstallment.from(
parseInstallmentRangeExceptOneMonthWithDefault(
params["INSTALL_RANGE"],
),
parseNointInfParameter(
params["NO_INT_INF_PARAM"],
params["MERT_NO_INT"] == "Y"
)
)
...
...
)
}
이렇게 구조를 개선한 결과, 아래와 같은 이점을 얻을수 있게 되었습니다.
외부와의 인터페이스 추가로 코어 비즈니스 로직의 브레이킹 체인지를 요구하지 않는 것이죠.
FE와 BE의 관심사 분리
아까 보여드린것처럼, 이 JSP 내에서 DB 커넥션을 얻어서, DB를 읽는 코드까지 있었습니다. 심지어, JSP가 참조하는 Javascript 내에서도 비즈니스 로직을 가지고 있었고요. 따라서, 하나의 기능을 추가하려 할때, JavaScript, JSP, Java class 모두를 확인하여 영향도를 체크하고, 세 곳 모두 수정해야하기에 독립적이고 병렬적인 기능의 발전이 불가능했습니다. 신규 시스템에서는 이러한 강결합된 로직들을 제거후, 서버와 프론트가 서로의 관심사에 맞는 일을 할수있게끔 해야했습니다.
저희는 신규 시스템을 설계하는 과정에서 웹에서의 인증방식을 거쳐 결제를 일으키는 많은 서비스들의 통신과정과 패턴을 많이 관찰했습니다. 서비스의 패턴마다 조금씩 차이는 있었지만, 공통적으로 드러나는 3가지 Step을 정의할 수 있었습니다. 신규 결제창 시스템에서도 동일한 개념을 차용했습니다.
3-Step 결제
우리는 결제 과정을 명확한 3단계로 정의했습니다.

Bridge 인터페이스
이렇듯 결제창에서의 유저 액션을 바탕으로 FE-BE간의 API FLOW를 정했습니다. 이 틀에 맞추고나니 FE<->BE간의 협업에서 가장 정하기 어려운 API 설계방식에 대한 고민은 해결되었는데요. 다만 이러한 설계방식을 새롭게 도입하는 것만으로는 기존의 강결합된 로직들을 완전히 분리하기가 어려웠습니다. 한가지 더 남은 문제점이 있었습니다. 앞서 결제창은 수많은 카드사, 간편결제사, 심지어는 PayPal과 같은 해외간편결제 서비스와 연동되어 있다고 말씀드렸는데요. 이렇게 수 많은 이해관계자들과 웹브라우저에서 서로 데이터를 전달할수 있는 표준화된 방법이 무엇일까요?
결제창은 수많은 카드사, 간편결제사와 웹 브라우저에서 데이터를 주고받아야 합니다. 이때 사용할 수 있는 표준화된 방법은 Redirect와 Form POST 뿐입니다. 문제는 ‘어디로’, ‘어떻게’ 보낼지는 비즈니스 로직인데, 만약 API 요청에 대해 서버가 단순히 필요한 데이터만 JSON으로 내려준다고 해볼까요? 그럼 프론트엔드에선 이런 고민을 하게 될텐데요.
“내가 받은 데이터를 어디로 전달해야하지?” 와 “Redirect를 사용해야 하나? Form POST를 사용해야 하나?”
이를 FE가 판단하게 되면 FE에 비즈니스 로직이 스며들게 됩니다.
FE-BE간의 API 통신응답에서 결제에 필요한 데이터만을 담지 않는 새로운 형태의 인터페이스를 도입했습니다. ‘Bridge 인터페이스’는 <도착지, 전송할 방식, 전송할 데이터>로 구성되어 있습니다. 우리의 해결책은 Bridge 인터페이스였습니다. 아마 많이들 익숙하실 HTTP의 Header, Body의 개념을 떠올리시면 이해하기가 편하실 것 같습니다.

// BE가 내려주는 Bridge 응답
{
"destination": "https://shinhancard.com/auth",
"method": "POST",
"payload": {
"merchantId": "toss_123",
"amount": 10000,
"encryptedData": "..."
}
}
사용자가 하나의 카드를 선택하여 결제한다면?
사용자가 OO카드 결제수단을 선택했다고 해봅시다.
그렇기에 BE에서 <전송할 데이터>
를 만드는 로직이 아무리 복잡하더라도, FE가 그 비즈니스 로직에 대해서는 알 필요가 없습니다. FE의 관심사는 오직 <도착지,전송할 방식>
에 맞게 데이터들을 Relay해주면 되는 것이죠.
이렇게 FE-BE간 3-Step API 흐름과, Data Relay를 필요로 하는 부분에서 통일된 인터페이스를 사용함으로써, BE와 FE 모두 서로의 관심사에 맞는 일을 하도록 발전할 수 있습니다.
안전한 전환: Canary 배포와 자동 롤백
수만 개 가맹점을 어떻게 안전하게 이전할 것인가
토스페이먼츠에서 생각하는 결제 시스템의 핵심은 서비스 연속성입니다. 신규 시스템을 다 만들었으니 이제 결제요청을 신규시스템으로 100% 전환했다고 가정해 봅시다. 만약에라도 신규 시스템에 문제가 생기면, 전체 결제요청 시스템으로 문제가 확장됩니다. ’서비스 연속성’을 지킬수 없게 됩니다.
우리는 가맹점별, 기능별로 세밀하게 제어 가능한 Canary 시스템을 구축했습니다.
자동 모니터링과 롤백
3-Step 플로우를 활용해 정상 작동 여부를 실시간으로 판단했습니다.
- PREPARE 대비 CONFIRM 비율이 정상 범위를 벗어나면 → 사용자가 결제를 완료하지 못하고 있음
- 이상 징후 감지 시 자동으로 트래픽을 레거시로 롤백
실제 예시

위 사진처럼 빨간색 라인이 가리키는 시점에서 CONFIRM 단계의 수치가 급감했습니다.
이는 우리가 정의한 결제시스템의 "서비스 연속성"을 해치는 행위이므로 신규 시스템 투입이 중지되어야 합니다. 이때 급감하는 수치를 detect를 한 뒤에 문제가 생긴 결제수단은 신규 시스템 트래픽 투입이 거의 바로 중지되고 레거시 시스템으로 원복되었습니다.
성능 최적화: 작은 디테일부터
Preflight 요청 제거로 첫 화면 로딩 속도 개선
추가로 아까 말씀드렸던 3Step 중에서 ENTRY는 첫 화면을 그리는 단계이기에 반응성이 사용경험에 아주 중요한데요. ENTRY 단계에서 프론트엔드는 즉 첫화면을 그리는 데이터를 서버에 요청합니다. 그럼 서버에서는 API 응답으로 필요한 데이터를 내려줍니다.
끝일까요? 그러면 좋겠지만 다들 잘 아시는것처럼 CORS와 연관된 Preflight 요청이 먼저 나가고, 실제 데이터가 오고가는 본 요청이 그제서야 날아갑니다. 물론 많은 환경에서 프리플라이트는 30~50ms 내외의 소요시간을 가지고 있습니다. 하지만 일반적으로 본요청의 시간이 200~300ms임을 고려할때, 비율로 보면 Preflight에 소요되는 시간이 꽤 큰 비중을 차지하고 있는데요. 또 모바일에서 결제를 많이 하는 특성상, 불안정할수 있는 모바일 네트워크에서 RTT 2번이 생각보다 유저 경험상 좋지 않다고 판단했습니다.
Simple Request로 Preflight 우회
이 문제를 해결하기 위해 반응성이 매우 중요한 ENTRY API 요청에는 Simple Request를 사용했습니다. 이를 통해 “첫화면”을 렌더링하는 시간을 조금 단축할수 있었어요. 결제경험의 첫 번째 단계이기에, 이런 사소한 부분까지 최적화하려 노력했습니다.
Multi-Layer Cache로 대규모 트래픽 대응
또한, 시스템에 대한 전체 장악이 쉽지않아 성능개선은 쉽사리 시도하기 어려웠던 레거시 시스템에 반해, 신규 시스템에서는 병목이 걸린만한 부분들을 Detect하고 개선해 대규모 트래픽에 대응하기가 훨씬 수월해졌습니다. 이를테면, 설계단계에서부터 Multi-layer Cache를 꼼꼼히 설계했는데요.

위의 그림에서 파란색 라인이 결제창 API의 호출건수, 주황색 라인이 평균 응답속도입니다. 보시는 것처럼, 평소에 들어오던 트래픽의 20배가 넘는 정도의 Peak 트래픽이 순식간에 들어오더라도, Multi-Layer Cache의 hit률이 더욱 좋아져서 오히려 평균 응답속도는 급격하게 개선되었습니다.
가시적인 성과: 1달 만에 10년 묵은 기능 재개발
토스페이먼츠가 레거시 결제창을 개편하려 한 가장 큰 이유를 생각해보면 비즈니스적 요구사항을 빠르게 반영하지 못한다
였습니다. 레거시 시스템에서는 코드 이해조차 어려웠던 ‘즉시할인’ 기능을 신규 시스템에서는 완전히 재개발했습니다.
특정 기능과 관련된 모든것들을 1달만에 개발했다는 사실보다 더 중요한 것이 있습니다. 기존에 코드 이해가 불가능에 가까운 시스템을 신규시스템에서는 도메인을 표현하는 코드로 재탄생시켰고, 필요하다면 언제든 빠르게 기능을 추가가능한 구조로 바꾸었다는 것입니다.
마치며
우리가 만든 것은 단순히 새로운 결제창이 아닙니다. 미래에 어떤 요구사항이 와도 유연하게 대응할 수 있는 시스템이죠. 이번 개편은 결제창 하나의 변화가 아니라, 링크페이, 결제 위젯, 그리고 앞으로 출시될 수많은 토스페이먼츠 결제 제품 전체를 지탱하는 기반의 개편입니다. 20년 된 레거시를 안고 가면서도, 수만 개 가맹점의 연동은 그대로 유지하며, 동시에 미래를 위한 기반을 다진 것. 그것이 우리가 이룬 진짜 성과입니다.
✅ 이번 아티클은 아래 Toss Makers Conference 25의 세션을 바탕으로 재구성되었습니다.
