← 모든 글
PG 플랫폼

연 3,000억 결제 시스템을 Spring Boot로 전환하며 세운 원칙

연 3,000억 규모의 결제 흐름을 한 번에 갈아엎지 않고 단계적으로 Spring Boot로 옮기며 지킨 아키텍처 원칙을 정리했다.

연 3,000억 규모의 결제 흐름을 새 시스템으로 옮기면서 가장 먼저 버린 생각은 한 번에 교체하겠다는 계획이었다. 결제는 새 코드가 완성됐다는 이유만으로 전환할 수 없었다. 기존 결과와 같다는 증거, 문제가 생겼을 때 돌아갈 경로, 운영자가 상태를 확인할 방법이 함께 있어야 했다.

전환의 목표는 최신 기술을 쓰는 것이 아니었다. 돈의 상태를 틀리지 않게 옮기고, 변경 범위를 작게 유지하고, 실패를 설명할 수 있게 만드는 것이었다.

첫 번째 원칙: 정확성을 속도보다 앞에 뒀다

결제 승인, 취소, 정산 대상 생성은 서로 다른 화면과 배치에서 사용됐지만 같은 금액 상태를 공유했다. 새 시스템에서는 각 기능이 어떤 상태를 만들고 어떤 상태만 받아들일 수 있는지 먼저 적었다.

API 응답이 성공했는지보다 데이터베이스에 어떤 상태가 남았는지를 기준으로 비교했다. 기존 시스템과 새 시스템에 같은 입력을 넣고 결과를 대조할 수 있는 케이스부터 전환했다. 결과를 설명하지 못하는 기능은 일정이 늦어져도 넘기지 않았다.

두 번째 원칙: 기능 단위로 길을 나눴다

기존 ASP 경로 ───────────────┐
                            ├─ 결과 비교
Spring Boot 신규 경로 ──────┘

전체 PG를 한 번에 바꾸지 않고 결제, 가맹점, 정산, 관리자 기능을 나눠 옮겼다. 전환하지 않은 기능은 기존 시스템을 계속 사용했다. 새 경로에 문제가 생기면 해당 기능만 기존 경로로 돌릴 수 있게 했다.

이 방식은 두 시스템을 함께 관리해야 해서 번거로웠다. 그래도 장애 범위를 한 기능으로 제한할 수 있었고, 실제 트래픽에서 확인한 결과를 다음 전환에 반영할 수 있었다.

세 번째 원칙: 모듈 경계를 먼저 만들었다

새 코드를 하나의 Spring Boot 애플리케이션에 넣되 Payment, Merchant, Settlement, Admin 모듈이 서로의 테이블과 내부 구현을 직접 사용하지 않게 했다. 처음부터 완벽히 지키지는 못했지만 새로운 결합이 생길 때 이유를 남겼다.

모듈맡긴 책임
Payment승인, 취소, 결제 상태
Merchant가맹점 계약과 결제 설정
Settlement정산 대상과 금액 계산
Admin운영 조회와 통제

네 번째 원칙: 되돌릴 수 없는 배포를 만들지 않았다

코드 배포와 기능 전환을 분리했다. 새 코드를 먼저 배포하고 확인한 뒤 기능별로 경로를 열었다. 데이터 변경이 필요한 경우에도 이전 버전이 읽을 수 있는 상태를 유지했다.

전환 여부, 오류율, 처리 시간, 결과 차이를 같은 화면에서 확인할 수 있게 했다. 문제가 생긴 뒤 로그를 찾는 대신, 전환한 기능이 지금 어떤 상태인지 먼저 볼 수 있어야 했다.

불변식을 문장과 코드로 함께 남겼다

정확성을 강조하는 말만으로는 리뷰 기준이 되지 않았다. 결제 한 건이 지켜야 할 조건을 짧은 문장으로 적고, 그 문장이 어느 계층에서 보장되는지 연결했다. 애플리케이션 검증만으로 지키는 규칙과 데이터베이스 제약이 필요한 규칙을 구분했다.

결제 불변식
1. 같은 거래 키로 유효한 승인은 하나만 존재한다.
2. 취소 누적 금액은 승인 금액을 넘지 않는다.
3. 실패한 요청은 성공 상태를 만들지 않는다.
4. 상태가 바뀌면 그 이유를 추적할 이력이 남는다.

예를 들어 중복 승인은 서비스 메서드의 사전 조회만으로 막지 않았다. 동시에 들어온 요청은 둘 다 조회를 통과할 수 있기 때문이다. 비즈니스 키의 유일성을 저장소에도 표현하고, 충돌했을 때 이미 처리된 거래인지 확인하는 흐름을 뒀다.

취소 금액도 요청값만 검사하지 않았다. 현재까지 반영된 취소 금액과 새 요청을 같은 트랜잭션 안에서 확인해야 했다. 규칙을 문장으로 써두니 테스트 이름과 운영 점검 항목도 같은 언어를 사용할 수 있었다.

이중 운영 기간의 데이터 소유자를 정했다

레거시와 신규 시스템이 동시에 떠 있는 기간에는 어느 쪽이 원본인지 모호해지기 쉽다. 기능별로 쓰기 주체를 하나만 정하고, 다른 시스템은 조회하거나 결과를 전달받게 했다. 양쪽에서 같은 데이터를 고칠 수 있게 두지 않았다.

상태쓰기 주체다른 시스템의 역할
전환 전Legacy신규 경로 검증
부분 전환기능별 지정 시스템조회·결과 비교
전환 완료Spring Boot롤백 기간 동안 읽기

동기화가 필요한 데이터는 방향을 명시했다. 신규 시스템이 레거시 테이블을 마음대로 고치거나 그 반대가 되지 않게 했다. 데이터 이동이 실패하면 재실행할 수 있도록 처리 상태와 기준 키를 남겼다.

호환되는 데이터 변경만 먼저 배포했다

애플리케이션을 되돌릴 수 있어도 데이터베이스가 이전 버전과 호환되지 않으면 실제 롤백은 불가능했다. 컬럼을 바로 바꾸거나 지우지 않고 추가, 병행 사용, 전환, 정리 순서로 나눴다.

1. 새 컬럼 추가 — 기존 코드는 영향 없음
2. 새 코드가 구·신 컬럼을 함께 읽음
3. 쓰기 경로를 새 컬럼으로 전환
4. 데이터 검증과 보정
5. 이전 컬럼 의존 제거

정리 단계는 전환과 같은 배포에 넣지 않았다. 새 경로가 안정적으로 동작하는 것을 확인하기 전에는 이전 코드가 읽는 구조를 유지했다. 느리지만 이 순서 덕분에 애플리케이션과 스키마를 따로 되돌릴 수 있었다.

기능 플래그는 운영 통제 수단으로 사용했다

기능 플래그를 새 기능을 감추는 용도로만 보지 않았다. 어떤 요청이 어느 시스템으로 가는지 제어하고, 전환 범위를 관찰하고, 문제가 생기면 즉시 닫는 운영 장치로 사용했다.

if (migrationRule.useNewPayment(request)) {
    return springBootPayment.approve(request);
}
return legacyPayment.approve(request);

플래그 판단에는 거래 결과를 바꿀 수 있는 임의 조건을 넣지 않았다. 같은 요청이 재시도될 때 경로가 달라지지 않게 안정적인 기준을 사용했다. 누가 언제 전환 범위를 바꿨는지도 배포 이력과 함께 남겼다.

전환 체크리스트를 배포 조건으로 만들었다

‘테스트 완료’라는 한 줄은 사람마다 의미가 달랐다. 기능마다 정상·실패·중복·취소·롤백 시나리오를 확인하고, 모니터링과 운영 조회가 준비됐는지까지 체크했다.

  1. 기존 결과와 신규 결과의 차이를 설명할 수 있는가
  2. 중복 요청과 재시도에서 금액이 한 번만 반영되는가
  3. 외부 연동이 실패했을 때 상태를 확정하거나 조회할 수 있는가
  4. 신규 경로를 닫아도 이전 버전이 데이터를 읽을 수 있는가
  5. 운영자가 거래 상태와 실패 이유를 확인할 수 있는가

체크리스트는 서류가 아니라 전환 여부를 결정하는 기준이었다. 통과하지 못한 항목은 위험을 기록하고 범위를 줄이거나 전환을 미뤘다. 일정에 맞추기 위해 검증 항목의 의미를 바꾸지는 않았다.

전환이 남긴 것

이 작업에서 가장 어려웠던 부분은 Spring Boot 코드가 아니었다. 오래 운영된 규칙 중 무엇이 의도이고 무엇이 우연히 남은 동작인지 구분하는 일이었다. 단계적 전환은 느려 보였지만 그 질문을 건너뛰지 않게 했다.

연 3,000억이라는 규모는 새로운 구조를 과시할 이유가 아니라, 작은 불확실성도 그대로 넘기지 말아야 할 이유였다. 이후 성능, 배포, 모니터링, 테스트를 고친 기준도 이때 세운 네 가지 원칙에서 이어졌다.