레거시 PG 플랫폼을 Spring Boot로 전환하게 된 이유
결제·가맹점·정산·관리자 기능이 얽힌 ASP 기반 PG를 Spring Boot 모놀리스로 옮기기로 결정한 배경과 당시 세운 전환 목표를 적었다.
PG 플랫폼을 맡았을 때 결제, 가맹점, 정산, 관리자 기능이 ASP 기반 시스템 안에 함께 들어 있었다. 기능은 오랫동안 운영됐지만 한 영역의 변경이 어디까지 영향을 주는지 예측하기 어려웠고, 작은 수정도 전체 흐름을 다시 확인해야 했다.
처음부터 새 기술로 다시 만들고 싶었던 것은 아니었다. 운영 중인 결제 시스템은 멈춰 세울 수 없었다. 기존 기능을 유지하면서 변경 범위를 설명할 수 있고, 장애가 생겼을 때 되돌릴 수 있는 구조가 필요했다. 그 기준에서 Spring Boot 전환을 시작했다.
기존 시스템에서 가장 힘들었던 점
가장 큰 문제는 오래된 언어 자체보다 경계가 흐린 구조였다. 결제 승인 로직을 고치면 가맹점 설정과 정산 계산까지 확인해야 했고, 관리자 화면에서 사용하는 규칙이 운영 로직과 중복되기도 했다. 배포 단위가 크니 수정한 코드보다 함께 배포되는 코드가 더 많았다.
ASP PG
├─ 결제
├─ 가맹점
├─ 정산
└─ 관리자
문제가 생겼을 때 원인을 좁히는 데도 시간이 걸렸다. 요청과 처리 결과를 연결할 기준이 부족했고, 운영자가 보는 상태와 개발자가 로그에서 보는 상태가 다를 때가 있었다. 기능 추가보다 먼저 변경과 장애를 추적할 수 있는 기반을 만들어야 했다.
왜 Spring Boot를 골랐나
팀이 Java 생태계에서 유지보수 경험을 쌓고 있었고, 트랜잭션과 데이터 접근, 테스트, 모니터링 도구를 한 흐름으로 묶기 쉬웠다. 프레임워크를 바꾸는 것보다 중요한 것은 결제 규칙을 코드의 경계로 다시 표현하는 일이었지만, Spring Boot가 그 작업을 시작하기에 현실적인 선택이었다.
새 시스템의 목표를 화려한 아키텍처로 잡지 않았다. 동일한 입력에 동일한 결제 결과를 내고, 실패 원인을 남기고, 변경한 기능을 테스트하고, 문제가 생기면 기존 경로로 돌아갈 수 있어야 했다.
왜 처음부터 MSA로 가지 않았나
도메인 경계가 정리되지 않은 상태에서 서비스를 먼저 나누면 기존 결합이 네트워크 호출로 옮겨갈 뿐이라고 판단했다. 결제와 정산이 어떤 데이터를 소유해야 하는지, 실패했을 때 누가 복구해야 하는지부터 확인해야 했다.
그래서 첫 구조는 모놀리스로 정했다. 하나의 애플리케이션 안에서 Payment, Merchant, Settlement, Admin 모듈을 나누고 트랜잭션 경계를 확인했다. 배포와 관측, 테스트 기준을 먼저 세운 뒤 실제 운영에서 독립성이 필요한 영역을 나누기로 했다.
Spring Boot Monolith
├─ Payment
├─ Merchant
├─ Settlement
└─ Admin
전환 목표를 네 가지로 줄였다
- 기존 결제 결과와 새 시스템의 결과가 같아야 했다.
- 기능을 한 번에 바꾸지 않고 경로별로 전환할 수 있어야 했다.
- 로그, 메트릭, 배포 이력으로 운영 상태를 설명할 수 있어야 했다.
- 테스트가 없는 규칙을 새 시스템에 그대로 옮기지 않기로 했다.
기능 목록보다 돈의 흐름을 먼저 그렸다
전환 범위를 정할 때 기존 메뉴를 그대로 새 메뉴로 옮기면 빠르게 시작할 수는 있었다. 하지만 화면 단위로 보면 승인과 취소, 정산이 서로 독립적인 기능처럼 보였고, 실제 데이터는 한 거래의 상태를 함께 바꾸고 있었다. 그래서 메뉴 목록 대신 결제 한 건이 들어와 정산 대상이 되기까지의 흐름을 먼저 그렸다.
승인 요청
→ 거래 식별
→ 가맹점 설정 확인
→ 외부 결제사 승인
→ 결제 상태 저장
→ 정산 대상 생성
→ 관리자 조회
각 단계마다 입력, 출력, 데이터 소유자, 실패했을 때 남는 상태를 적었다. 이 표를 만들자 같은 테이블을 여러 기능이 직접 수정하는 지점과, 한 화면의 편의를 위해 도메인 규칙이 복제된 지점이 보였다. 새 코드의 패키지는 화면이 아니라 이 흐름의 책임을 기준으로 나눴다.
| 질문 | 확인한 내용 |
|---|---|
| 무엇이 거래를 식별하는가 | 재요청과 중복 요청에서도 유지되는 키 |
| 언제 성공으로 보는가 | 외부 응답과 내부 저장 상태의 조합 |
| 누가 상태를 바꾸는가 | 결제·취소·정산별 변경 책임 |
| 실패하면 무엇이 남는가 | 재시도·조회·수동 복구에 필요한 기록 |
레거시 동작을 요구사항으로 착각하지 않았다
오래된 시스템에는 문서보다 코드가 더 많은 사실을 담고 있었다. 그렇다고 현재 동작을 모두 요구사항으로 인정하면 우연히 남은 버그와 중복까지 그대로 옮기게 된다. 기존 코드, 운영자의 사용 방식, 데이터에 남은 결과를 함께 비교했다.
판단이 어려운 규칙은 세 가지로 분류했다. 반드시 같아야 하는 결제 결과, 운영 중 임시로 생긴 예외 처리, 더는 사용하지 않지만 코드에 남은 경로였다. 출처를 확인하지 못한 분기는 지우거나 유지한다고 바로 결정하지 않고 전환 목록에 따로 남겼다.
규칙 조사표
├─ 입력 조건
├─ 기존 결과
├─ 데이터 변경
├─ 호출하는 화면·배치
├─ 유지 근거
└─ 새 시스템 처리 여부
이 과정은 느렸지만 새 코드에 설명할 수 없는 조건문이 쌓이는 것을 막았다. 특히 금액과 상태를 바꾸는 규칙은 담당자의 기억만으로 옮기지 않고 결과 비교 케이스를 먼저 만들었다.
새 시스템을 옆에 세우고 경로를 조금씩 옮겼다
기존 시스템을 중단하고 한 번에 교체하는 방식은 선택하지 않았다. 새 Spring Boot 애플리케이션을 기존 경로 옆에 두고, 전환 준비가 끝난 기능부터 요청을 넘겼다. 아직 검증하지 않은 기능은 ASP가 계속 처리했다.
Client
→ Routing rule
├─ Legacy ASP
└─ Spring Boot
├─ Payment
├─ Merchant
├─ Settlement
└─ Admin
라우팅과 코드 배포를 분리한 이유는 되돌리기 위해서였다. 새 코드를 배포한 직후 모든 요청을 넘기지 않고, 경로가 닫힌 상태에서 기동과 연결을 확인했다. 전환 뒤 문제가 보이면 애플리케이션을 다시 배포하지 않고 해당 기능의 경로만 기존 시스템으로 돌릴 수 있게 했다.
두 시스템이 함께 있는 동안에는 데이터 변경의 주체를 하나로 제한했다. 같은 요청을 양쪽에서 실제 처리해 비교하면 외부 승인이나 저장이 두 번 일어날 수 있다. 부작용이 있는 경로는 한쪽만 쓰게 하고, 다른 쪽은 입력을 재현한 검증 환경이나 읽기 전용 비교로 확인했다.
동일한 결과를 무엇으로 증명할지 정했다
HTTP 상태가 같다는 것만으로 전환 성공을 판단하지 않았다. 승인 결과 코드, 거래 상태, 금액, 취소 가능 금액, 정산 대상 여부처럼 다음 단계가 사용하는 결과를 비교했다. 화면에 보이지 않지만 복구에 필요한 이력도 확인 대상에 넣었다.
| 비교 층위 | 검증 기준 |
|---|---|
| API | 성공·실패 응답과 오류 분류 |
| 도메인 | 허용된 상태 전이와 금액 불변식 |
| 데이터 | 거래·이력·정산 대상의 최종 상태 |
| 운영 | 조회 가능 여부와 실패 원인 추적 |
비교 결과가 다르면 새 코드가 틀렸다고 단정하지 않았다. 기존 동작이 의도였는지부터 다시 확인했다. 차이를 숨기지 않고 결정 기록에 남기니 나중에 같은 질문이 나왔을 때 왜 결과를 맞췄거나 바꿨는지 설명할 수 있었다.
첫 구조에도 일부러 남긴 한계가 있었다
모놀리스 안에서 모듈을 나눴지만 데이터베이스까지 바로 분리하지는 않았다. 기존 데이터와 대조해야 했고, 도메인 경계가 실제 운영에서도 맞는지 더 확인해야 했다. 물리적 분리보다 직접 참조를 줄이고 변경 책임을 한곳에 모으는 데 집중했다.
완벽한 자동 테스트나 관측 체계가 준비될 때까지 전환을 미룰 수도 없었다. 대신 전환할 기능마다 최소 검증 목록을 두고, 부족한 부분을 다음 기능에 반영했다. 첫 단계에서 얻고 싶었던 것은 완성된 최종 구조가 아니라 안전하게 다음 결정을 내릴 수 있는 구조였다.
이 결정으로 모든 문제가 바로 해결되지는 않았다. 대신 무엇을 먼저 고쳐야 하는지가 보이기 시작했다. 2024년의 작업은 ASP를 Java로 번역하는 일이 아니라, 결제 시스템의 경계를 다시 찾는 일에서 출발했다.