Skip to Content
Software ArchitectureKakaoPay v2 신규 구축
🏗️ Software Architecture2023년 9월 19일

KakaoPay v2 신규 구축

#e-commerce#payment-expansion#software-architecture#kakaopay
Payment Expansion · Series 3 · Rebuild & Quality7 / 15Software Architecture
KakaoPay v2를 새로 만든 이유와 3개월 구축 흐름을 정리한다.

KakaoPay v2를 처음부터 새로 만든 과정을 남긴다. 2023년 하반기 초기 커밋부터 가을 프로덕션 배포까지, 왜 v1을 확장하지 않고 v2 계열로 다시 세웠는지, Spring WebFlux와 DynamoDB를 유지하면서 어떤 규칙과 구조를 새로 세웠는지, 그리고 제한된 시간 안에 어떻게 프로덕션 수준의 서비스를 만들었는지를 기록한다.

여기서의 v2는 KakaoPay 자체의 버전이 아니라, Nike Payment 시스템 안의 KakaoPay 연동 서비스를 재구축하며 붙인 내부 버전이다.

왜 새 서비스를 만들기로 했나

KakaoPay v1도 이미 비동기 WebFlux 기반으로 운영되고 있었고, 원래도 독립 배포되는 서비스였다. 문제는 배포 단위가 아니라 서비스의 책임과 규칙이었다. 2023년 상반기 정기결제, 저장결제, 환불 기능을 추가하면서 v1은 이미 많은 흐름을 품고 있었고, 여기에 새로운 저장 구조와 운영 기준, 품질 기준까지 계속 누적하는 방식은 점점 부담이 커지고 있었다.

더 중요한 배경은 KakaoPay 하나만의 문제가 아니었다. 이후 Apple Pay, PayPal 같은 다른 결제 앱들도 같은 방향으로 v2 계열로 정리해야 했고, 그때마다 코드가 파편화되고 같은 로직이 서비스마다 따로 구현되는 상태를 계속 둘 수는 없었다. 그래서 내가 제안한 방향은 특정 벤더 하나를 다시 만드는 것이 아니라, 여러 결제 앱이 같은 규칙과 같은 운영 기준 위에서 움직이도록 판 자체를 다시 짜는 쪽에 가까웠다.

물론 기존에도 공통화 작업은 하고 있었다. 공통 라이브러리와 공통 규칙을 조금씩 도입하며 중복을 줄이려는 시도는 이미 있었지만, 그 수준만으로는 서비스마다 벌어진 파편화와 누적된 예외 규칙을 충분히 정리하기 어려웠다. 결국 기존 공통화의 한계가 분명해졌고, 그래서 공통 라이브러리 자체도 다시 만들고, 그 위에 맞춰 v2 계열을 세우는 쪽으로 가게 됐다.

기존 구조에서 어디가 막혔나

가장 어려웠던 점은 기능 하나를 더 붙이는 일보다, 이미 운영 중인 흐름 위에 예외와 규칙이 계속 쌓이면서 구조가 점점 설명하기 어려워졌다는 데 있었다. 정기결제, 저장결제, 환불처럼 비슷해 보이는 흐름도 실제로는 벤더별 차이, 상태 처리, 저장 규칙, 로깅 방식이 조금씩 달랐고, 그 차이가 서비스 안에 누적될수록 다음 변경의 부담도 함께 커졌다.

특히 같은 문제를 서비스마다 비슷하지만 조금씩 다른 방식으로 풀고 있다는 점이 더 큰 신호였다. 이 상태로는 기능을 추가할 때마다 구현보다 정렬 비용이 더 커질 수밖에 없었다.

문제를 어떻게 정의하고 무엇을 제안했나

그래서 먼저 문제를 KakaoPay 기능이 부족하다가 아니라 규칙과 경계가 다시 필요하다로 정의했다. 몇 가지 방향을 놓고 고민해봤지만, 기존 구조 안에서 부분적으로만 손보는 방식으로는 한계가 분명했다. 결국 스키마, 모델링, 벨리데이션, 로깅, 저장 규칙을 한 번에 다시 맞출 수 있는 구조가 필요하다고 판단했고, 그래서 v2 계열과 새 공통 라이브러리 방향을 함께 제안하게 됐다.

당시에 실제로 놓고 본 선택지는 두 가지에 가까웠다.

  • 기존 v1 안에 확장을 계속 누적한다
    • 장점: 당장 시작은 빠르다.
    • 한계: 운영 중인 결제 흐름과 새 확장이 계속 얽히고, 서비스 내부 규칙이 더 파편화된다.
  • 새 서비스를 따로 만들고 기준을 다시 고정한다
    • 장점: 저장 구조, API 명세, 모델링 규칙, 벨리데이션 기준, 품질 기준을 처음부터 다시 맞출 수 있다.
    • 한계: 초기 설계와 공통화 기준을 다시 세우는 비용이 든다.

결국 v2 신규 구축은 배포 단위를 나누기 위한 선택이 아니라, 구조와 규칙을 다시 세우기 위한 선택에 가까웠다. 그리고 그 제안은 KakaoPay 한 서비스만 정리하는 데서 끝나지 않고, 이후 다른 결제 앱에도 같은 기준을 적용할 수 있는 출발점이 됐다.

구축은 다섯 단계로 끊어 갔다

  • 초기 구축 단계: 프로젝트 구조를 만들고 API 문서 구조와 OpenAPI 스펙을 먼저 고정했다. 구현 전에 명세와 규칙을 확정하는 API-first 접근이었다.
  • 핵심 구현 단계: Controller, Service, Communicator를 집중적으로 구현하며 핵심 결제 경로를 세웠다.
  • 공통화 단계: 여러 결제 앱이 함께 따를 수 있도록 공통 규칙, 공통 라이브러리 방향, 공통 로깅 기준을 함께 정리했다.
  • 품질 보강 단계: 에러 핸들링, DynamoDB 저장, 엔티티 규칙 정리, 스키마 기반 벨리데이션, 테스트 보강, KMS 설정을 함께 정리했다.
  • 전환 준비 단계: 문서 빌드 자동화와 에러 핸들링 보완으로 프로덕션 전환 준비를 마무리했다.

3개월 구축을 어떻게 끊어 진행했나

Spring WebFlux는 새로 도입한 선택이라기보다, 이미 운영 경험이 있던 비동기 처리 모델을 새 서비스 경계 안에서도 유지한 쪽에 가까웠다. 핵심은 WebFlux로 바꿨다가 아니라, 새 서비스에서 API 명세를 OpenAPI로 연결하고, 그 스키마 파일을 기준으로 모델링과 벨리데이션이 가능하게 만들고, 처리 계층과 저장 구조, 품질 기준을 처음부터 다시 세웠다는 점이었다.

동시에 이 작업은 KakaoPay 한 서비스 안의 리팩토링으로 끝나지 않았다. 이후 다른 결제 앱들도 v2 계열로 정리할 수 있도록 공통 라이브러리와 공통 규칙을 다시 손봤고, 서비스별로 흩어져 있던 로직과 로깅 구조도 가능한 한 같은 기준으로 맞추려 했다. 그래야 나중에 로그를 검색할 때도 서비스마다 다른 키와 다른 포맷을 해석하지 않아도 되고, 디버깅이나 트러블슈팅 때도 같은 관점으로 추적할 수 있었다. 시간이 지나면서 이 구조는 KakaoPay만의 예외적인 선택이 아니라, 글로벌 결제 시스템 쪽이 점차 따라오게 되는 기준점이 되었다.

DynamoDB를 계속 가져간 이유: 결제수단 등록 데이터는 키-값 기반 조회가 대부분이고, 자동 스케일링과 운영 일관성이 중요했다. 여기서 핵심은 저장소를 새로 바꾸는 것이 아니라, 이미 쓰고 있던 DynamoDB 기준을 v2 계열에서도 더 명확한 convention과 공통 규칙으로 정리하는 일이었다.

Redocly를 선택한 이유: 기존 APIB 문서 기준을 OpenAPI 스펙으로 옮겨 코드와 함께 관리하고, 빌드 시 자동으로 API 문서를 생성하기 위해. 문서를 수동으로 관리하면 코드와 문서가 불일치하는 문제가 반복된다. 더 중요한 점은 이 스펙 파일이 단순 문서가 아니라 모델과 검증 규칙을 정렬하는 기준점 역할도 했다는 것이다. 이 문서 구조 역시 내가 먼저 제안하고 정리한 방향이었고, 이후 다른 팀이 Confluence에 정리된 설계를 보고 문의해오며 비슷한 기준을 함께 맞춰가기도 했다.

왜 OpenAPI 3.1이 잘 맞았나

우리가 OpenAPI 3.1을 쓴 이유는 oneOf 하나 때문만은 아니었다. oneOf 자체는 3.0에도 있었지만, 3.1은 OpenAPI가 JSON Schema 2020-12와 정렬되면서 스키마 표현력이 훨씬 좋아졌다. 공식 업그레이드 가이드도 이 점을 가장 크게 설명한다.

실무에서 특히 좋았던 건 세 가지였다.

  • 모델 표현력: oneOf, anyOf 같은 조합을 쓰되, 필요한 경우 if/then/else 같은 조건부 스키마까지 활용할 수 있었다.
  • 타입 제어: 3.0의 nullable 대신 JSON Schema 방식의 type: ["string", "null"] 같은 표현을 그대로 쓸 수 있었다.
  • 파라미터 제어: query/path 파라미터를 스키마와 함께 두고 style, explode로 직렬화 방식을 명확히 적을 수 있어, 프론트와 백엔드가 같은 해석을 공유하기 좋았다.

예를 들어 저장결제 등록 결과처럼 상태에 따라 응답 모양이 달라지는 경우에는 oneOf가 유용했다.

yaml
sample/openapi-3.1-oneof-response.yaml
openapi: 3.1.0 components: schemas: RegistrationResult: oneOf: - $ref: '#/components/schemas/CompletedRegistration' - $ref: '#/components/schemas/CancelledRegistration' - $ref: '#/components/schemas/PendingRegistration' discriminator: propertyName: status CompletedRegistration: type: object properties: status: { type: string, const: completed } sid: { type: string } required: [status, sid] CancelledRegistration: type: object properties: status: { type: string, const: cancelled } reason: type: ["string", "null"] required: [status] PendingRegistration: type: object properties: status: { type: string, const: pending } expiresAt: { type: string, format: date-time } required: [status, expiresAt]

물론 oneOf 같은 큰 기능만 중요한 건 아니었다. 오히려 실무에서는 아주 평범한 입력 제약을 스키마에 같이 적어둘 수 있다는 점도 꽤 컸다. 타입 제한, 길이 제한, 정규식, format 같은 규칙을 명세와 검증에서 함께 쓸 수 있으니, 요청 검증 로직이 문서 밖으로 흩어지지 않았다.

yaml
sample/openapi-3.1-basic-validation.yaml
openapi: 3.1.0 components: schemas: CustomerProfile: type: object additionalProperties: false required: [customerId, email, locale] properties: customerId: type: string minLength: 8 maxLength: 32 pattern: '^[A-Z0-9_-]+$' email: type: string format: email maxLength: 120 locale: type: string enum: [ko-KR, en-US, ja-JP] phoneNumber: type: ["string", "null"] pattern: '^[0-9+ -]{8,20}$'

한 단계 더 가면 스키마를 거의 클래스처럼 조합해서 다룰 수도 있었다. 공통 속성은 $refallOf로 묶고, 실제 타입 분기는 oneOfdiscriminator로 나누면, 결제수단이나 응답 모델을 객체 계층처럼 설계할 수 있다.

yaml
sample/openapi-3.1-composed-payment-method.yaml
openapi: 3.1.0 components: schemas: PaymentMethodBase: type: object additionalProperties: false required: [id, type, displayName] properties: id: type: string pattern: '^[a-z0-9-]{10,40}$' type: type: string enum: [card, simple_pay] displayName: type: string minLength: 1 maxLength: 50 CardPaymentMethod: allOf: - $ref: '#/components/schemas/PaymentMethodBase' - type: object properties: type: type: string const: card last4: type: string pattern: '^[0-9]{4}$' issuerName: type: string minLength: 2 maxLength: 30 required: [type, last4] SimplePayPaymentMethod: allOf: - $ref: '#/components/schemas/PaymentMethodBase' - type: object properties: type: type: string const: simple_pay provider: type: string enum: [kakaopay, naverpay, applepay, paypal] sid: type: string pattern: '^[A-Za-z0-9_-]{12,80}$' required: [type, provider, sid] PaymentMethod: oneOf: - $ref: '#/components/schemas/CardPaymentMethod' - $ref: '#/components/schemas/SimplePayPaymentMethod' discriminator: propertyName: type

이런 식으로 해두면 구현 코드에서도 타입이 card인지 simple_pay인지, 어떤 필드가 반드시 있어야 하는지, 어떤 문자열 형식을 허용하는지를 해석하기가 쉬웠다. 결국 스키마가 단순 문서가 아니라, 객체 구조와 경계를 설명하는 공용 언어가 되는 셈이었다.

여기서 한 걸음 더 나가면, 코드 레벨에서도 컨트롤러 경계에 어떤 스키마를 붙일지 지정해둘 수 있었다. 이 부분은 외부 공개 라이브러리를 그대로 가져다 쓴 것이라기보다, 당시 공통 라이브러리 안에 우리가 직접 만든 annotation과 유틸리티에 더 가까웠다. 아주 거대한 프레임워크는 아니었지만, 실무에서는 꽤 유용한 작은 라이브러리였다. 파라미터 쪽에서는 @RequestJsonBody로 원본 JSON과 genericType을 함께 지정해 쓰고, @SchemaValidate(file = "...json")는 응답 모델 쪽에 붙여두는 방식이었다. 구현은 HandlerMethodArgumentResolver 쪽에 더 가까웠다. resolver가 annotation을 읽고, 연결된 JSON Schema 파일을 가져와 검증한 뒤, 성공하면 지정된 genericType으로 변환해서 컨트롤러에 넘기는 식이었다. 지금 다시 떠올려보면 인터페이스나 abstract 계층으로 요청과 응답의 기본 골격을 먼저 잡아두고, 실제 구현체에서 벤더별 키와 값을 추가하는 구조에 더 가까웠던 것 같다. 그렇게 해야 공통 요청/응답 형식, 공통 에러 메시지, 공통 검증 규칙을 먼저 맞춘 뒤, 벤더 차이는 구현체에서만 흡수할 수 있었기 때문이다. 왜냐하면 JSON Schema가 요청과 응답을 다루기에 훨씬 편해 보였고, 그 기준을 공통 라이브러리로 묶어두는 편이 서비스마다 같은 규칙을 반복 적용하기 좋았기 때문이다. 이렇게 해두면 필드 누락, 타입 불일치, 정규식 위반 같은 문제를 컨트롤러 바깥으로 넘기지 않고 초기에 걸러낼 수 있었고, 에러 메시지 형식도 미리 정해둔 규칙으로 맞출 수 있었다.

아래 코드는 실제 구현을 그대로 옮긴 것이 아니라, 당시 구조를 설명하기 위한 개념 예시다.

java
sample/schema-validated-controller.java
@RestController @RequestMapping("/stored-payments") public class StoredPaymentController { @PostMapping("/registrations") public RegistrationResponse register( @RequestJsonBody( json = "$.paymentMethod", genericType = StoredPaymentRegistrationRequest.class ) StoredPaymentRegistrationRequest request ) { return registrationService.register(request); } } public abstract class RegistrationResponse { private String registrationId; private String status; private String errorCode; private String errorMessage; } @SchemaValidate(file = "schema/stored-payment-registration-response.json") public class KakaoPayRegistrationResponse extends RegistrationResponse { private String sid; private String partnerOrderId; } public class NaverPayRegistrationResponse extends RegistrationResponse { private String reserveId; private String merchantUserKey; }

실제 핵심은 컨트롤러 메서드 자체보다, 그 앞단에서 annotation을 읽고 스키마 검증과 타입 변환을 묶어주는 resolver에 있었다.

java
sample/request-json-body-resolver.java
public class RequestJsonBodyArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(RequestJsonBody.class); } @Override public Object resolveArgument( MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory ) throws Exception { RequestJsonBody annotation = parameter.getParameterAnnotation(RequestJsonBody.class); String requestBody = readBody(webRequest); JsonNode selectedNode = objectMapper.readTree(requestBody).at(annotation.json()); JsonSchema schema = schemaRegistry.load(annotation.schema()); schema.validate(selectedNode); return objectMapper.treeToValue(selectedNode, annotation.genericType()); } }
json
sample/stored-payment-registration-response.json
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": false, "required": ["registrationId", "status"], "properties": { "registrationId": { "type": "string", "pattern": "^[A-Z0-9-]{12,40}$" }, "status": { "type": "string", "enum": ["completed", "pending", "cancelled"] }, "sid": { "type": ["string", "null"], "pattern": "^[A-Za-z0-9_-]{12,80}$" } }, "allOf": [ { "if": { "properties": { "status": { "const": "completed" } } }, "then": { "required": ["sid"] } } ] }
yaml
sample/validation-messages.yaml
messages: customerId.pattern: "customerId must contain only uppercase letters, numbers, underscore, or hyphen." email.format: "email must be a valid email address." sid.required: "sid is required when provider is simple_pay." provider.enum: "provider must be one of kakaopay, naverpay, applepay, or paypal."

이런 방식이 좋았던 이유는 분명했다. 명세와 구현이 멀어지지 않았고, 요청 검증과 응답 검증, 메시지 포맷까지 같은 기준에서 움직였다. 특히 HandlerMethodArgumentResolver@RequestJsonBody를 읽어 JSON path를 추출하고, 연결된 스키마로 검증한 뒤, genericType으로 변환해 넘겨주면 컨트롤러 진입 시점부터 어떤 payload가 유효한지 분명해졌다. 응답을 공통 추상 골격 위에 올려두면 공통 필드와 공통 에러 체계를 먼저 맞춘 뒤 구현체에서만 벤더별 값을 추가할 수 있었고, 응답 모델에 붙은 @SchemaValidate는 밖으로 나가는 데이터 형식도 같은 기준으로 맞춰줬다. 위 예시처럼 모델과 JSON Schema를 한 쌍으로 두면, 타입 제약, enum, 정규식, 조건부 필수값까지 코드와 스키마가 서로 어긋나지 않게 가져갈 수 있었다. 작고 단순한 공통 라이브러리였지만, 실제로는 서비스 경계에서 같은 규칙을 반복 적용하게 해주는 데 큰 도움이 됐다.

더 좋았던 점은 이 스키마 파일들을 OpenAPI 3.1 스펙에서도 그대로 참조할 수 있었다는 점이다. 한 번 잘 정리해둔 JSON Schema를 컨트롤러 검증에만 쓰고 버리는 것이 아니라, OpenAPI 문서에서도 $ref로 연결하면 Redocly가 그 구조를 그대로 문서화해줬다. 결국 같은 스키마 파일이 런타임 검증, API 명세, 문서 빌드를 동시에 떠받치는 구조가 되었고, 이게 실무에서는 정말 편했다.

yaml
sample/openapi-3.1-schema-reference.yaml
openapi: 3.1.0 info: title: Stored Payment Registration API version: 1.0.0 paths: /stored-payments/registrations: post: summary: Register stored payment requestBody: required: true content: application/json: schema: $ref: './schema/stored-payment-registration-request.json' responses: '200': description: ok content: application/json: schema: $ref: './schema/stored-payment-registration-response.json'

프론트나 다른 팀 입장에서도 어떤 요청이 유효한지, 어떤 에러를 받게 되는지를 문서와 실제 동작에서 거의 같은 모습으로 볼 수 있었다. 결과적으로 이 공통 라이브러리는 OpenAPI 스키마와 서비스 구현 사이를 붙여주는 접착제 역할을 했다.

파라미터 제어에서도 이점이 있었다. 예를 들어 채널, 디바이스, 환경 같은 조합 필터를 query parameter로 받을 때 styleexplode를 스펙에 명시해두면, 어떤 식으로 직렬화되어야 하는지 문서와 구현이 쉽게 어긋나지 않았다.

yaml
sample/openapi-3.1-parameter-control.yaml
openapi: 3.1.0 paths: /payment/logs: get: parameters: - in: query name: filters style: form explode: true schema: type: object properties: channel: type: string enum: [nike_web, nike_app, snkrs_app] device: type: string enum: [desktop, mobile_web, android, ios] environment: type: string enum: [debug, staging, production] responses: '200': description: ok

이런 식으로 스펙을 잡아두면, 단순히 문서를 예쁘게 만드는 수준이 아니라 모델링, 벨리데이션, 파라미터 해석, 프론트 연동 방식까지 하나의 기준으로 묶어둘 수 있었다. v2에서 OpenAPI 3.1이 좋았던 이유도 거기에 있었다.

GitHub 커밋 로그를 기반으로 주요 마일스톤을 재구성하면:

API 명세와 규칙부터 먼저 고정했다

구현보다 문서와 인터페이스를 먼저 정리해 병렬 작업 기반을 만들었다.

핵심 처리 경로를 빠르게 세웠다

Controller, 처리 계층, 저장 계층을 먼저 세워 승인 흐름이 끝까지 이어지게 했다.

공통화와 로깅 기준을 함께 맞췄다

다른 결제 앱도 같은 v2 규칙을 따를 수 있게 공통 라이브러리와 로그 구조를 같이 정리했다.

저장과 품질 기준을 뒤따라 붙였다

데이터 저장, 암호화, 테스트, 문서 빌드, 벨리데이션 규칙을 이어서 붙이며 프로덕션 기준을 맞췄다.

재구축이 필요했던 순간을 남기다

이 글의 핵심은 새 서비스를 만들었다는 사실보다, 기존 확장을 계속 누적하는 방식으로는 규칙과 품질 기준을 더 이상 깔끔하게 유지하기 어려웠다는 판단에 있다. v2는 기능 추가를 위한 우회로가 아니라, OpenAPI 연결, 스키마 기반 모델링과 벨리데이션, 저장 규칙, 공통 로깅 구조, 품질 기준을 한 번에 다시 세우기 위한 새로운 기준점이었다. 그리고 그 기준점은 이후 다른 결제 서비스와 다른 팀이 더 다루기 쉬운 구조를 찾을 때 자연스럽게 참조하게 되는 방향으로 이어졌다.

다음 글에서는 그 기준점 위에서 저장 구조와 암호화 정책을 어떻게 함께 고정했는지 이어서 본다.

Last updated on