레거시 결제 원장을 확장 가능한 시스템으로
안녕하세요, 토스페이먼츠 Server Developer 박순현, 양권성입니다.
토스페이먼츠는 온라인 거래의 중심에 있습니다. 사용자가 쇼핑몰에서 ‘결제하기’를 누르는 순간, 보이지 않는 곳에서 수많은 결제가 안정적으로 처리됩니다. 하지만 우리의 시작은 완전히 새로웠던 것이 아닙니다. 2020년 8월, 기존 PG 사업을 인수하면서 토스페이먼츠가 출범했습니다. 이전 시스템은 20년 이상 운영된 레거시 구조였고, 우리가 개선해야 할 기술적 과제는 만만치 않았습니다.
이 과정에서 가장 복잡하면서도 핵심이었던 해결 과제는 결제 원장(Ledger) — 결제의 모든 내역이 저장되는 중심 시스템이었습니다.
결제 원장의 역할과 한계

‘원장’은 회계 용어에서 유래한 개념으로, 모든 거래 내역을 기록하는 장부를 의미합니다. 결제 원장은 고객의 결제, 취소, 환불 내역이 기록되는 핵심 테이블로, 정산과 회계의 기초 데이터로 사용됩니다. 하지만 이 원장이 오랜 기간 운영되며 여러 문제가 누적되었습니다.
문제 1. 일관성 없는 데이터 구조
레거시 원장은 결제수단별로 테이블 구조가 모두 달랐습니다. 카드 결제의 경우 전체 취소와 부분 취소를 서로 다른 테이블에 저장했고, 계좌이체 결제는 하나의 테이블에 모든 취소 내역을 기록했습니다.
예를 들어 REFUND라는 테이블 이름은 모든 결제수단의 취소 내역이 들어갈 것처럼 보였지만, 실제로는 계좌이체 취소만 저장하고 있었습니다. 이런 불일치 구조는 신규 입사자의 온보딩을 어렵게 만들었고, 비즈니스 로직 변경 시 유지보수 난이도와 장애 위험을 높였습니다.
문제 2. 도메인 간 강한 결합
결제, 정산, 취소, 회계 등 여러 도메인이 동일한 원장 테이블을 공유하고 있었습니다. 또한 같은 컬럼이 도메인별로 서로 다른 의미로 사용되기도 했습니다.

이 구조에서는 컬럼 하나를 수정할 때마다 여러 팀이 영향도를 분석해야 했고, 결국 서비스 확장 속도와 유연성이 심각하게 저하되었습니다.
문제 3. 확장성을 가로막는 구조적 한계
기존 시스템은 결제와 결제수단이 1:1 관계로 묶여 있었습니다. 즉, “한 결제에 여러 결제수단을 사용하는 구조”를 지원할 수 없었습니다.

예를 들어 하나의 주문을 카드 + 계좌이체로 나누어 결제하거나, 여러 사용자가 금액을 나누어 내는 더치페이(Dutch pay) 모델을 구현할 수 없었습니다. 이 구조를 유지한 채로는 비즈니스 확장이 불가능했습니다.
새로운 시작: MySQL 기반 원장 시스템 구축
우리는 단순히 기존 Oracle DB에 테이블을 추가하는 대신, MySQL 기반의 신규 원장을 독립적으로 구축하기로 결정했습니다.
MySQL을 선택한 이유
신규 원장 설계: 세 가지 핵심 전략
1️⃣ 데이터 구조의 일관성 확보
모든 결제 승인 내역을 approve 공통 테이블에 저장하고, 결제수단별 추가 정보는 개별 테이블에 나누어 저장했습니다.

또한 기존에는 취소 시 데이터를 UPDATE 하던 구조를 INSERT-only 원칙으로 변경했습니다. 이로써 데이터의 불변성을 보장하고, 데드락을 방지하며, 과거 히스토리 추적이 용이해졌습니다.
2️⃣ 도메인 간 결합도 낮추기
신규 원장은 각 도메인이 직접 DB를 조회하지 않도록 설계했습니다.

또 결제가 발생하면 Kafka 이벤트를 발행하고, 각 도메인은 해당 이벤트를 구독하여 자신의 로직을 수행합니다.

이벤트 스트림은 DB의 최신 상태와 동일한 불변 데이터를 전달하며, 조회 없이도 필요한 처리가 가능했습니다. 덕분에 도메인 간 직접적인 의존성이 제거되고 팀별 병렬 개발이 가능해졌습니다.
3️⃣ 결제와 승인(Approval)의 분리
기존에는 결제와 승인 개념이 혼재되어 있었습니다. 신규 시스템에서는 ‘결제’는 수단이 확정되지 않은 상태, ‘승인’은 실제 수단이 결정된 상태로 분리했습니다.

이제 하나의 결제에 여러 결제수단이 연결될 수 있으며, 복합결제나 더치페이 같은 시나리오도 유연하게 지원할 수 있게 되었습니다.
안전한 마이그레이션 전략
1️⃣ 비동기 방식의 점진적 적재
신규 원장 전환의 첫 단계는 서비스 무중단이었습니다. 기존 원장에 먼저 데이터를 저장한 뒤, 신규 원장에는 비동기 방식으로 적재했습니다.

실패하더라도 서비스 장애로 이어지지 않도록 예외를 처리하고, 모든 실패 케이스를 모니터링 및 알림 시스템으로 관리했습니다.
2️⃣ 리소스 관리와 튜닝
비동기 처리는 실서비스 서버에서 수행되므로, CPU·메모리 부담을 최소화해야 했습니다.

초기에는 ThreadPool을 작게 설정하고, 트래픽을 점진적으로 투입하면서 최적 값을 찾았습니다. ThreadPool이 포화될 경우 해당 작업을 과감히 버리고, 별도 검증 배치를 통해 데이터 누락을 보완하는 구조로 설계했습니다.
3️⃣ 데이터 정합성 검증
비동기 적재 과정에서 누락될 수 있는 데이터를 보정하기 위해 RO(Read Only) DB 기반의 검증 배치를 구성했습니다. 매시 5분 간격으로 실행되어 복제 지연을 고려하면서 누락된 데이터를 재적재했습니다.
대규모 마이그레이션의 도전
실시간 신규 적재가 안정화된 뒤에는 기존 데이터를 옮겨야 했습니다. 이 과정은 단순한 데이터 복사가 아니라, 수억 건 단위의 INSERT가 동시에 발생하는 고부하 작업이었습니다.
전용 서버 구성: 작업을 라이브 서버에서 함께 실행하게 되면, 아무리 비동기 처리라 하더라도 같은 컴퓨팅 리소스를 공유하게 되기 때문에, 예상하지 못한 영향을 줄 수 있어서 마이그레이션 서버를 별도로 배포해 실서비스 리소스와 분리했습니다.
또 RW DB는 AWS에서 하나의 AZ(Availability Zone)에 위치해 있고, 네트워크 지연을 최소화하기 위해 마이그레이션 서버도 같은 AZ에 배포했습니다.
Bulk Insert 도입: 기존 단건 저장 방식을 개선해 대량 삽입이 가능하도록 저장 인터페이스를 재설계했습니다.
로컬 캐시 적용: 반복 조회가 필요한 데이터는 서버 메모리에 캐시하여 네트워크 I/O를 줄였습니다.
네트워크 대역폭 관리: IDC(기존 DB)와 AWS(신규 DB) 간 트래픽을 모니터링하며, 다른 서비스에 영향을 주지 않도록 점진적으로 조정했습니다.

이 경험을 통해 단순 데이터 이관을 넘어, 이질적인 인프라 환경 간 데이터 흐름과 병목을 세밀하게 제어하는 노하우를 쌓을 수 있었습니다.
운영의 현실 - 장애와 대응
시스템의 설계와 구현이 아무리 완벽해 보여도, 실제 운영 환경에서의 안정성은 전혀 다른 이야기입니다. 서비스가 실시간으로 수만 건의 거래를 처리하는 순간, 예측하지 못한 병목이나 실패는 언제든 발생할 수 있습니다.
장애 발생: DB 부하 급증 어느 날, 관제센터로부터 “결제 서버의 응답이 지연되고 있으며, DB 부하가 비정상적으로 높습니다.” 이라는 알림을 받았습니다. 대시보드를 확인하니 MySQL에서 wait 세션 수가 급증하고 있었습니다.

우선 MySQL 상위 쿼리 로그를 확인했습니다. 특정 SELECT 쿼리가 지속적으로 높은 CPU와 I/O 부하를 유발하고 있었고, 해당 쿼리는 바로 전날 신규 배포된 기능에서 추가된 것이었습니다.
빠른 롤백과 즉시 대응 문제가 신규 배포된 코드에서 기인함을 확인하자마자 우리는 즉시 이전 버전으로 롤백을 결정했습니다. 토스페이먼츠는 항상 최근 5개 배포 이미지(Docker Build)를 보관하기 때문에 별도의 빌드 과정 없이 즉시 재배포가 가능했습니다. 롤백 이후 DB 부하는 빠르게 정상화 되었습니다.
원인 분석: 옵티마이저의 잘못된 판단

해당 쿼리는 단순한 SELECT 문이었지만, 데이터가 쌓이기 시작하면서 옵티마이저가 의도하지 않은 실행 계획을 선택했습니다. 결국 인덱스를 사용하지 않고 테이블 전체를 스캔하는 풀스캔(Full Scan) 이 발생해 결제 승인 서버에 과도한 부하를 일으켰습니다.
이 경험을 통해 신규 테이블을 설계하거나 기능을 추가할 때는 데이터 양이 증가한 상황에서의 쿼리 동작까지 반드시 고려해야 한다는 교훈을 얻었습니다. 이후 쿼리에 힌트를 추가해 의도한 인덱스를 사용하도록 보완했습니다.
장애를 통해 발견된 문제들
이번 장애를 계기로 여러 연쇄적인 문제들이 함께 드러났는데, 대표적으로 다음 네 가지였습니다.
문제 1. 두 원장의 데이터 불일치
DB 부하가 발생하면서 신규 원장에 일부 데이터가 정상적으로 적재되지 않았습니다.
이로 인해 기존 원장과 신규 원장의 데이터가 불일치하게 되었습니다. 이 문제는 멀티 데이터 소스를 운영하면서 언제든 두 저장소 간의 데이터는 틀어질 수 있음을 가정하고 있었어서, 승인 서버는 신규 원장이 메인 원장의 지위를 얻기 전까지 정답지인 구원장을 기준으로 자동으로 보정을 하는 배치가 매 5분마다 수행되고 있었고, 해당 배치에서 불일치는 해소 되었습니다.
문제 2. 원천사와의 불일치 — 망취소(Network Cancellation)
내부 데이터 불일치 외에도, 외부 결제 원천사와의 상태 불일치도 발생했습니다.

예를 들어 결제 서버가 카드사로 승인 요청을 보냈지만 네트워크 타임아웃으로 실패한 경우, 카드사에서는 결제가 완료되었지만 토스페이먼츠에서는 실패로 남는 상황이 생길 수 있습니다.

이런 상황을 대비해, 결제 서버는 타임아웃된 거래건을 별도로 저장하고 원천사로 취소 요청을 보내어 상태를 일치시키는 망취소(Network Cancellation) 로직을 운영하고 있었습니다.
이 과정을 통해 카드사, 은행 등 외부 시스템과의 데이터 정합성을 유지했습니다.
문제 3. 가맹점과의 응답 불일치
가맹점 측에서 수신한 결제 응답과 토스페이먼츠의 실제 처리 결과가 일치하지 않는 사례도 발생했습니다. MSA 기반 구조에서 각 서버의 타임아웃 설정이 불일치했던 것이 원인이었습니다.

예를 들어 PX 서버와 승인 서버 간 타임아웃이 다를 경우, 승인 서버가 정상적으로 결제를 완료하더라도 앞단의 서버는 이미 타임아웃 실패를 반환할 수 있습니다.

이 문제를 해결하기 위해 승인 서버의 최대 처리 시간을 계산하고, 모든 상위 서버의 타임아웃을 이에 맞추어 재설정하였고, 결제 요청 체인 전체의 타임아웃을 일관되게 맞추면서 가맹점과의 불일치를 해소 하였습니다.
문제 4. 결제 이벤트 누락과 중복
결제 승인이나 취소 시, 시스템은 각 트랜잭션에 대한 이벤트를 발행합니다. 이벤트 발행은 흔히들 알고 계시는 Outbox 패턴으로 구현되어 있습니다.
아웃박스 패턴을 썼는데 왜 이벤트 누락이 있지? 라는 의문이 있을 수 있는데, 토스페이먼츠는 가맹점의 결제 서비스 가용성에 좀 더 집중하고 있고, 또 결제 비즈니스의 특성상 외부 원천사 연동에서 롤백을 지원하지 않는 부분도 있어서, Outbox에 저장을 실패한다고 비즈니스 로직 전체를 실패 처리하기보다 별도의 처리를 진행하고 있습니다.
다행히 Outbox에 저장이 된 이벤트들에 대해서는 성공 여부를 통해 이벤트를 재발송하는 배치가 수행되고 있어서 누락 없이 발송되었습니다.
로그 기반 복구

누락된 이벤트는 Outbox에 적재되지 않은 거래건들인데요. 결제 서버는 영향을 받은 거래건들은 ES에 로그를 적재하고 있고, 로깅 시스템에 문제가 생겼을 때를 대비하여 fallback appender를 통하여 서버 팟의 로컬 디스크에도 저장해두었다가 로깅 시스템 복구 시 로깅을 재처리하고 있기에, 영향 거래들을 빠르게 식별해 장애 시간대의 전체 거래의 이벤트를 재발행할 수 있었습니다.
이벤트 중복 발행
거기서 추가 문제는 누락된 이벤트를 재발행하면서 이벤트가 중복으로 발행된 것인데, 이벤트를 적재하는 서비스에서 승인 또는 취소가 중복해서 보이는 문제가 있었습니다.

이 문제는 사용하는 곳에서 동일 이벤트를 중복 처리하지 않을 수 있게 동일 건을 식별할 수 있도록 이벤트의 헤더에 멱등키를 실어 제공하는 방법으로 보완하였습니다.
Lesson & Learn
처음 이 시스템을 설계할 때의 기대와 달리 운영에 들어가 보니 예기치 못한 이슈들이 정말 많았습니다.
이슈들을 대응하면서 느낀 건 초기 설계만큼이나 중요한 건 그 이후의 운영 대응이라는 것입니다. 장애가 발생했을 때도 시스템이 스스로 회복할 수 있어야 하고, 잘못된 데이터를 바로잡을 수 있는 구조가 갖춰져 있어야 한다는 것을 배웠습니다.
신규 결제 원장 전환은 단순한 마이그레이션이 아니었습니다.
운영 중 발생한 장애와 그 복구 과정을 통해, 토스페이먼츠는 시스템이 스스로 회복할 수 있는 구조로 한 단계 성장했습니다. 이번 경험은 안정성과 확장성을 모두 갖춘 결제 인프라를 지속적으로 발전시키기 위한 중요한 전환점이 되었습니다.
✅ 이번 아티클은 Toss Makers Conference 25의 세션을 바탕으로 재구성되었습니다.
