레거시 PG 플랫폼을 Spring Boot로 전환하게 된 이유
결제·가맹점·정산·관리자 기능이 얽힌 ASP 기반 PG를 Spring Boot 모놀리스로 옮기기로 결정한 배경과 당시 세운 전환 목표를 적었다.
PG 플랫폼을 맡았을 때 결제, 가맹점, 정산, 관리자 기능이 ASP 기반 시스템 안에 함께 들어 있었다. 기능은 오랫동안 운영됐지만 한 영역의 변경이 어디까지 영향을 주는지 예측하기 어려웠고, 작은 수정도 전체 흐름을 다시 확인해야 했다.
처음부터 새 기술로 다시 만들고 싶었던 것은 아니었다. 운영 중인 결제 시스템은 멈춰 세울 수 없었다. 기존 기능을 유지하면서 변경 범위를 설명할 수 있고, 장애가 생겼을 때 되돌릴 수 있는 구조가 필요했다. 그 기준에서 Spring Boot 전환을 시작했다.
기존 시스템에서 가장 힘들었던 점
가장 큰 문제는 오래된 언어 자체보다 경계가 흐린 구조였다. 결제 승인 로직을 고치면 가맹점 설정과 정산 계산까지 확인해야 했고, 관리자 화면에서 사용하는 규칙이 운영 로직과 중복되기도 했다. 파일 하나에 운영 코드와 작업 중인 코드가 섞여 있으니, 수정한 코드보다 함께 나가는 코드가 더 많았다.
flowchart TD
subgraph host["같은 Windows 서버 한 대"]
A["ASP PG"] --> B["결제"]
A --> C["가맹점"]
A --> D["정산"]
A --> E["관리자"]
A --> F[("DB")]
end
애플리케이션만 그 서버에 있던 것도 아니었다. 데이터베이스가 같은 ASP 서버 안에서 함께 돌았다. 서버 한 대에 문제가 생기면 결제 처리와 데이터가 동시에 멈췄고, 애플리케이션을 손보는 작업과 데이터베이스를 손보는 작업을 따로 떼어 낼 수 없었다.
기록도 남아 있지 않았다. 설계 문서나 운영 문서가 없어서 어떤 규칙이 왜 그렇게 동작하는지 확인하려면 코드를 직접 읽는 수밖에 없었다. 코드에 남은 동작이 의도한 설계인지, 그때그때의 대응이 굳어 버린 것인지 구분할 근거가 없었다.
문제가 생겼을 때 원인을 좁히는 데도 시간이 걸렸다. 요청과 처리 결과를 연결할 기준이 부족했고, 운영자가 보는 상태와 개발자가 로그에서 보는 상태가 다를 때가 있었다. 기능 추가보다 먼저 변경과 장애를 추적할 수 있는 기반을 만들어야 했다.
서버가 꺼지면 스케줄러가 다시 켰다
운영하면서 가장 불안했던 것은 서버가 한 번씩 꺼지는 일이었다. 예고 없이 멈췄고, 멈춰 있는 동안에는 결제와 관리자 화면이 함께 내려갔다. 애플리케이션과 데이터베이스가 같은 서버에 있었으니 한 번 꺼질 때마다 둘 다 사라졌다.
왜 꺼지는지를 알아내기가 어려웠다. 원인을 특정하지 못하는 동안에도 서비스는 계속 떠 있어야 했다. 그래서 윈도우 서버의 스케줄러로 서비스가 살아 있는지 주기적으로 확인하고, 꺼져 있으면 다시 켜도록 걸어 뒀다. 사람이 알아채고 손으로 올리는 것보다는 빨랐다.
flowchart TD
A["스케줄러 주기 실행"] --> B{"서비스가 살아 있나?"}
B -- "예" --> C["그대로 둔다"]
B -- "아니오" --> D["서비스 다시 시작"]
C --> A
D --> A
이 방식은 해결이 아니라 임시 방편이었다. 원인은 그대로 남았고, 꺼져 있다가 다시 켜지는 사이의 요청은 그대로 실패했다. 문제는 서버가 꺼진다는 사실보다 왜 꺼지는지 알 수 없다는 데 있었다. 원인을 확인할 수 없는 구조를 재시작으로 덮는 대신 애플리케이션과 데이터베이스를 분리하고, 문서 없이도 동작과 상태를 설명할 수 있는 구조로 옮기는 쪽을 택했다. 전환을 결정한 이유 중 하나가 이것이었다.
왜 Spring Boot를 골랐나
팀이 Java 생태계에서 유지보수 경험을 쌓고 있었고, 트랜잭션과 데이터 접근, 테스트, 모니터링 도구를 한 흐름으로 묶기 쉬웠다. 프레임워크를 바꾸는 것보다 중요한 것은 결제 규칙을 코드의 경계로 다시 표현하는 일이었지만, Spring Boot가 그 작업을 시작하기에 현실적인 선택이었다.
새 시스템의 목표를 화려한 아키텍처로 잡지 않았다. 동일한 입력에 동일한 결제 결과를 내고, 실패 원인을 남기고, 변경한 기능을 테스트하고, 문제가 생기면 기존 경로로 돌아갈 수 있어야 했다.
왜 처음부터 MSA로 가지 않았나
도메인 경계가 정리되지 않은 상태에서 서비스를 먼저 나누면 기존 결합이 네트워크 호출로 옮겨갈 뿐이라고 판단했다. 결제와 정산이 어떤 데이터를 소유해야 하는지, 실패했을 때 누가 복구해야 하는지부터 확인해야 했다.
그래서 첫 구조는 모놀리스로 정했다. 하나의 애플리케이션 안에서 Payment, Merchant, Settlement, Admin 모듈을 나누고 트랜잭션 경계를 확인했다. 배포와 관측, 테스트 기준을 먼저 세운 뒤 실제 운영에서 독립성이 필요한 영역을 나누기로 했다.
flowchart TD A["Spring Boot Monolith"] --> B["Payment"] A --> C["Merchant"] A --> D["Settlement"] A --> E["Admin"]
전환 목표를 네 가지로 줄였다
- 기존 결제 결과와 새 시스템의 결과가 같아야 했다.
- 기능을 한 번에 바꾸지 않고 경로별로 전환할 수 있어야 했다.
- 로그, 메트릭, 배포 이력으로 운영 상태를 설명할 수 있어야 했다.
- 테스트가 없는 규칙을 새 시스템에 그대로 옮기지 않기로 했다.
기능 목록보다 돈의 흐름을 먼저 그렸다
전환 범위를 정할 때 기존 메뉴를 그대로 새 메뉴로 옮기면 빠르게 시작할 수는 있었다. 하지만 화면 단위로 보면 승인과 취소, 정산이 서로 독립적인 기능처럼 보였고, 실제 데이터는 한 거래의 상태를 함께 바꾸고 있었다. 그래서 메뉴 목록 대신 결제 한 건이 들어와 정산 대상이 되기까지의 흐름을 먼저 그렸다.
flowchart TD A["승인 요청"] --> B["거래 식별"] B --> C["가맹점 설정 확인"] C --> D["외부 결제사 승인"] D --> E["결제 상태 저장"] E --> F["정산 대상 생성"] F --> G["관리자 조회"]
각 단계마다 입력, 출력, 데이터 소유자, 실패했을 때 남는 상태를 적었다. 이 표를 만들자 같은 테이블을 여러 기능이 직접 수정하는 지점과, 한 화면의 편의를 위해 도메인 규칙이 복제된 지점이 보였다. 새 코드의 패키지는 화면이 아니라 이 흐름의 책임을 기준으로 나눴다.
| 질문 | 확인한 내용 |
|---|---|
| 무엇이 거래를 식별하는가 | 재요청과 중복 요청에서도 유지되는 키 |
| 언제 성공으로 보는가 | 외부 응답과 내부 저장 상태의 조합 |
| 누가 상태를 바꾸는가 | 결제·취소·정산별 변경 책임 |
| 실패하면 무엇이 남는가 | 재시도·조회·수동 복구에 필요한 기록 |
레거시 동작을 요구사항으로 착각하지 않았다
오래된 시스템에는 문서보다 코드가 더 많은 사실을 담고 있었다. 그렇다고 현재 동작을 모두 요구사항으로 인정하면 우연히 남은 버그와 중복까지 그대로 옮기게 된다. 기존 코드, 운영자의 사용 방식, 데이터에 남은 결과를 함께 비교했다.
판단이 어려운 규칙은 세 가지로 분류했다. 반드시 같아야 하는 결제 결과, 운영 중 임시로 생긴 예외 처리, 더는 사용하지 않지만 코드에 남은 경로였다. 출처를 확인하지 못한 분기는 지우거나 유지한다고 바로 결정하지 않고 전환 목록에 따로 남겼다.
flowchart TD A["규칙 조사표"] --> B["입력 조건"] A --> C["기존 결과"] A --> D["데이터 변경"] A --> E["호출하는 화면·배치"] A --> F["유지 근거"] A --> G["새 시스템 처리 여부"]
이 과정은 느렸지만 새 코드에 설명할 수 없는 조건문이 쌓이는 것을 막았다. 특히 금액과 상태를 바꾸는 규칙은 담당자의 기억만으로 옮기지 않고 결과 비교 케이스를 먼저 만들었다.
새 시스템을 옆에 세우고 경로를 조금씩 옮겼다
기존 시스템을 중단하고 한 번에 교체하는 방식은 선택하지 않았다. 새 Spring Boot 애플리케이션을 기존 경로 옆에 두고, 전환 준비가 끝난 기능부터 요청을 넘겼다. 아직 검증하지 않은 기능은 ASP가 계속 처리했다.
flowchart TD
C["Client"] --> R{"Routing rule"}
R --> L["Legacy ASP"]
R --> S["Spring Boot"]
S --> P["Payment"]
S --> M["Merchant"]
S --> T["Settlement"]
S --> A["Admin"]
기존 시스템을 감싸 두고 준비된 기능부터 새 쪽으로 넘기는 이 방식은 흔히 목 졸라 죽이는 무화과에 비유된다. 한 번에 갈아엎지 않고 바깥부터 조금씩 대체해 결국 안쪽이 비게 만드는 형태다. 이름보다 중요한 것은 전환 기간 내내 두 시스템이 동시에 살아 있어야 한다는 제약이었다.
라우팅과 코드 배포를 분리한 이유는 되돌리기 위해서였다. 새 코드를 배포한 직후 모든 요청을 넘기지 않고, 경로가 닫힌 상태에서 기동과 연결을 확인했다. 전환 뒤 문제가 보이면 애플리케이션을 다시 배포하지 않고 해당 기능의 경로만 기존 시스템으로 돌릴 수 있게 했다.
두 시스템이 함께 있는 동안에는 데이터 변경의 주체를 하나로 제한했다. 같은 요청을 양쪽에서 실제 처리해 비교하면 외부 승인이나 저장이 두 번 일어날 수 있다. 부작용이 있는 경로는 한쪽만 쓰게 하고, 다른 쪽은 입력을 재현한 검증 환경이나 읽기 전용 비교로 확인했다.
가장 느린 조회부터 옮겼다
전환 순서를 정할 때 기능의 중요도만 보지 않았다. 가장 아픈 곳이 어디인지도 함께 봤다. 대표 조회 하나가 최대 30초까지 걸리고 있었고, 결제 흐름에서 그 시간은 타임아웃 위험으로 이어졌다.
원인은 쿼리 하나가 아니라 구조였다. 서브쿼리가 여러 겹으로 중첩돼 있었고, 집계와 형 변환까지 SQL 안에서 하고 있었다. 조건이 늘어날수록 읽는 행이 곱으로 늘었다.
새 시스템으로 옮기면서 중첩을 JOIN 중심으로 다시 짜고, 집계와 변환은 애플리케이션으로 뺐다. 데이터베이스는 필요한 행을 좁히는 일만 하게 했다. 이관 뒤 그 조회는 0.1초 수준으로 떨어졌다.
이 결과가 전환의 명분이 됐다. 구조를 바꾸면 무엇이 좋아지는지 숫자로 보여 줄 수 있으면 다음 전환 대상을 정하는 논의가 빨라진다.
이 숫자를 뒤에 나오는 성능 개선과 헷갈리지 않게 적어 둔다. 여기서 말한 30초와 0.1초는 조회 한 건의 절대 시간이고, 원인은 쿼리가 아니라 SQL 안에 집계까지 밀어 넣은 구조였다. 전환을 끝낸 뒤에는 남은 느린 쿼리를 실행 계획부터 다시 본 작업이 따로 있었고, 그쪽은 API 응답 분포를 기준으로 잰다. 서브쿼리를 JOIN으로 바꾼 것도 이유가 달랐다 — 여기서는 구조를 옮기느라 바꿨고, 그쪽에서는 두 형태의 실행 계획을 비교한 끝에 바꿨다. 그 과정은 결제 API 응답속도를 30% 개선하면서 배운 SQL 튜닝에 적었다.
동일한 결과를 무엇으로 증명할지 정했다
HTTP 상태가 같다는 것만으로 전환 성공을 판단하지 않았다. 승인 결과 코드, 거래 상태, 금액, 취소 가능 금액, 정산 대상 여부처럼 다음 단계가 사용하는 결과를 비교했다. 화면에 보이지 않지만 복구에 필요한 이력도 확인 대상에 넣었다.
| 비교 층위 | 검증 기준 |
|---|---|
| API | 성공·실패 응답과 오류 분류 |
| 도메인 | 허용된 상태 전이와 금액 불변식 |
| 데이터 | 거래·이력·정산 대상의 최종 상태 |
| 운영 | 조회 가능 여부와 실패 원인 추적 |
비교 결과가 다르면 새 코드가 틀렸다고 단정하지 않았다. 기존 동작이 의도였는지부터 다시 확인했다. 차이를 숨기지 않고 결정 기록에 남기니 나중에 같은 질문이 나왔을 때 왜 결과를 맞췄거나 바꿨는지 설명할 수 있었다.
첫 구조에도 일부러 남긴 한계가 있었다
모놀리스 안에서 모듈을 나눴지만 데이터베이스까지 바로 분리하지는 않았다. 기존 데이터와 대조해야 했고, 도메인 경계가 실제 운영에서도 맞는지 더 확인해야 했다. 물리적 분리보다 직접 참조를 줄이고 변경 책임을 한곳에 모으는 데 집중했다.
완벽한 자동 테스트나 관측 체계가 준비될 때까지 전환을 미룰 수도 없었다. 대신 전환할 기능마다 최소 검증 목록을 두고, 부족한 부분을 다음 기능에 반영했다. 첫 단계에서 얻고 싶었던 것은 완성된 최종 구조가 아니라 안전하게 다음 결정을 내릴 수 있는 구조였다.
이 결정으로 모든 문제가 바로 해결되지는 않았다. 대신 무엇을 먼저 고쳐야 하는지가 보이기 시작했다. 2024년의 작업은 ASP를 Java로 번역하는 일이 아니라, 결제 시스템의 경계를 다시 찾는 일에서 출발했다.