How We Rebuilt a 20-Year-Old Payment System from the Ground Up
20년 묵은 결제 시스템, 어떻게 갈아엎었나? (feat. 토스페이먼츠)
💡 요약 오라클(Oracle) 기반의 20년 된 레거시 결제 시스템을 MySQL + JPA 기반의 현대적인 아키텍처로 전환한 대규모 마이그레이션기입니다. MSA 전환, 비동기 처리, 데이터 일관성 보장 전략, 그리고 오픈 후 발생한 리얼한 장애 상황과 해결책을 상세히 다룹니다.
🏗️ 1. 레거시 시스템의 한계와 문제점
20년간 운영된 기존 결제 시스템(일명 '원장')은 비즈니스 성장에 따라 명확한 한계에 봉착했습니다. 단순히 "오래되어서"가 아니라, 구조적인 문제로 인해 확장이 불가능했기 때문입니다.
1-1. 🍝 일관성 없는 구조 (제각각인 테이블 구조, 데이터 파편화)
결제 수단마다 테이블 설계가 달랐습니다.
카드 결제: 전체 취소와 부분 취소가 각각 다른 테이블에 저장됨.
계좌 이체: 하나의 테이블에 섞여서 저장됨.
용어의 모호함:
REFUND라는 테이블이 있었으나, 이름과 달리 '계좌 이체의 취소'만 저장하고 있었습니다.
이러한 데이터 구조의 비일관성은 신규 입사자의 온보딩 비용을 높이고, 운영 난이도를 급상승시키는 주범이었습니다.
1-2. 🔗 다양한 도메인 사이의 강한 의존성 (도메인 간의 강한 결합, Coupling)
결제(Payment), 정산(Settlement) 등 서로 다른 도메인이 하나의 거대한 원장 테이블을 공유하고 있었습니다.
하나의 컬럼을 팀마다 다른 의미로 재해석해서 사용.
컬럼 하나를 수정하려면 전사적인 영향도 파악(Impact Analysis)이 필요.
결과적으로 개발 속도가 저하되고 서비스 유연성이 떨어짐.
1-3. 🧱 비즈니스 확장을 막는 구조적 한계 (1:1 매핑)
가장 치명적인 문제는 [결제 : 결제 수단]이 1:1로 묶여 있는 구조였습니다.
"복합 결제(포인트+카드)"나 "더치페이" 같은 요구사항이 들어왔을 때, 기존 테이블 구조에서는 이를 담아낼 방법이 없었습니다.
구조를 뜯어고치지 않는 한 비즈니스 요구사항을 수용할 수 없는 '데드락' 상태였습니다.
🛠️ 2. 새로운 아키텍처 설계: 확장성을 향하여 (왜 MySQL로 전환했는가?)
토스페이먼츠 팀은 기존 오라클 DB에 테이블을 추가하는 땜질식 처방 대신, MySQL 기반의 신규 원장 시스템을 구축하기로 결정합니다.
2-1. 🐬 Oracle에서 MySQL + JPA로의 전환
격리(Isolation): 기존 오라클 DB는 여러 팀이 공유하고 있어, 타 팀의 작업이 결제 서비스 장애로 전파될 위험이 있었습니다. 결제팀 전용의 독립적인 DB가 필요했습니다.
생산성(Productivity): 기존
MyBatis+XML쿼리 방식은 런타임 오류(컬럼 누락 등)를 잡기 어렵고, 동적 쿼리 해석이 난해했습니다.JPA(Java Persistence API)를 도입해 타입 안정성을 확보하고, 엔티티(Entity) 중심으로 비즈니스 로직을 직관적으로 관리하고자 했습니다.
📝 Junior Dev Note: JPA vs MyBatis
MyBatis: SQL 매퍼(Mapper)입니다. 개발자가 직접 SQL을 작성해야 하므로 세밀한 튜닝이 가능하지만, CRUD 쿼리 반복 작업이 많고 스키마 변경 시 쿼리를 일일이 수정해야 합니다.
JPA: ORM(Object-Relational Mapping) 기술입니다. 자바 객체와 DB 테이블을 매핑해줍니다. 기본적인 SQL을 자동으로 생성해주므로 생산성이 높고, 객체 지향적인 개발이 가능합니다.
2.2 🧬 일관성 있는 구조 (데이터 모델링, 공통과 개별의 테이블 분리)
일관성을 위해 공통 데이터와 개별 데이터를 분리했습니다.
Approve(공통): 모든 결제 수단의 승인 데이터를 저장.개별 테이블: 카드, 계좌 등 각 수단별 고유 정보만 별도 저장.
2-3. 🔒 불변성(Immutability)을 위한 Insert-Only 전략
기존에는 결제 취소 시 데이터를 Update 했습니다. 하지만 신규 원장에서는 오직 Insert만 수행합니다.
장점:
데이터 히스토리가 완벽하게 보존됨.
Update로 인한
Row Lock경쟁(데드락)을 방지.쓰기 성능 향상.
2-4. 📡 도메인 간 결합도 낮추기, 이벤트 기반 아키텍처 (Kafka)
도메인 간 결합도를 끊기 위해 Kafka를 도입했습니다.
결제가 발생하면
Event를 발행합니다.정산 등 타 도메인은 이 이벤트를 구독(Subscribe)하여 각자의 로직을 처리합니다.
조회 API를 쓰지 않은 이유: 대량의 배치 처리가 필요한 경우, API 호출은 결제 시스템에 과도한 부하(DDoS 공격과 유사한 효과)를 줄 수 있기 때문입니다.
🚀 3. 무중단 마이그레이션 전략 (The Migration)
새로운 시스템을 만들었지만, 살아있는 거대한 시스템을 멈출 수는 없습니다. '달리는 기차의 바퀴를 갈아 끼우는' 전략이 필요했습니다.
3-1. 🔄 듀얼 라이트 (Dual Write) 패턴
안정성을 위해 과도기에는 두 시스템을 병행 운영합니다.
Main: 레거시(Oracle)에 동기(Sync)로 저장. (실패 시 트랜잭션 롤백)
Sub: 신규(MySQL)에는 비동기(Async)로 저장.
Fail-over: 신규 저장에 실패하더라도 전체 결제 서비스는 정상 응답을 줍니다. (장애 격리)
3-2. 🧵 비동기 처리와 자원 격리
신규 시스템 저장은 별도의 스레드 풀(Thread Pool)을 통해 비동기로 처리했습니다.
초기에는 스레드 풀 사이즈를 작게 설정하고, 트래픽을 보면서 점진적으로 늘렸습니다.
Discard Policy: 스레드 풀이 꽉 차면 과감하게 작업을 버립니다. 라이브 서비스의 CPU/Memory를 보호하기 위함입니다. 누락된 데이터는 후속 배치 작업이 책임집니다.
Graceful Shutdown: 배포 시 서버가 내려갈 때, 큐에 남아있는 작업이 처리될 시간을 벌어줍니다.
3-3. 🕵️♂️ 검증 및 보정 배치 (Reconciliation)
비동기 저장이나 스레드 풀 full로 인해 누락된 데이터는 어떻게 할까요?
5분 지연 배치: 매시 정각이 아닌 5분의 여유를 두고, 레거시 DB와 신규 DB를 대조하여 누락된 건을 적재(Backfill)합니다.
실시간 DB(RW) 부하를 피하기 위해 읽기 전용 복제본(RO, Read-Only Replica)을 사용했습니다.
3-4. 🚚 대용량 과거 데이터 이관 (Massive Migration)
실시간 트래픽 적재가 안정화된 후, 과거 데이터를 옮겨야 했습니다.
별도 서버 구성: 라이브 서비스에 영향을 주지 않기 위해 마이그레이션 전용 서버를 띄웠습니다.
같은 AZ(Availability Zone) 배치: AWS 내에서 DB와 같은 존에 서버를 배치해 네트워크 지연(Latency)을 최소화했습니다.
Bulk Insert: 한 건씩 넣으면 Network I/O 오버헤드가 큽니다. JDBC의
Batch Update기능을 활용해 대량으로 밀어 넣었습니다.Local Cache: 반복 조회되는 데이터는 로컬 메모리에 캐싱해 DB 부하를 줄였습니다.
네트워크 대역폭 조절: IDC(레거시)와 AWS(신규) 간의 전용선 대역폭이 포화되지 않도록 모니터링하며 속도를 조절했습니다.
💥 4. 운영의 시작과 장애 일지 (Post-Mortem)
"설계가 완벽하니 잘 돌아가겠지?"라는 생각은 오산이었습니다. 오픈 후 발생한 실제 장애 사례와 해결 과정입니다.
😱 장애 1: 인덱스를 무시한 옵티마이저 (DB CPU 100%)
상황: 신규 기능 배포 후 DB CPU가 치솟았습니다.
원인: 테스트 환경과 달리, 운영 환경의 데이터 분포도가 달라지자 MySQL 옵티마이저가 인덱스를 타지 않고 Full Table Scan을 선택했습니다. 데이터가 급격히 쌓이는 시점과 맞물려 발생했습니다.
해결:
빠른 롤백 (이미지 보관 정책 덕분에 즉시 가능).
Index Hint를 쿼리에 추가하여 강제로 특정 인덱스를 사용하도록 고정.
📝 Junior Dev Note: DB Optimizer & Index Hint DB는 쿼리를 실행할 때 가장 효율적인 경로(실행 계획)를 계산하는 '옵티마이저'가 있습니다. 하지만 데이터 양이나 분포에 따라 옵티마이저가 잘못된 판단을 할 수 있습니다. Index Hint는 "이 인덱스를 꼭 써!"라고 개발자가 DB에 명령하는 것입니다.
🤯 장애 2: 커넥션 풀 고갈과 듀얼 원장 불일치
상황: DB 부하로 쿼리 응답이 느려지자, 애플리케이션 서버의 DB Connection Pool이 모두 소진(Active connection max)되었습니다.
현상: 신규 결제 요청들이 DB 연결을 못 해 대기하다가
Connection Timeout에러 발생. 신규 원장 비동기 적재도 실패.해결:
장애 상황 해소 후, 기 구축해둔
보정 배치(Reconciliation Batch)가 자동으로 돌며 누락된 데이터를 채워 넣었습니다. (설계의 승리)
📉 장애 3: 타임아웃 도미노 (Cascading Failure)
상황: 결제 승인 서버 앞단의 서버들이 승인 서버의 응답을 기다리지 않고 먼저 타임아웃을 내버렸습니다.
결과: 고객에게는 "결제 실패"라고 떴지만, 내부적으로는 승인이 완료되는 데이터 불일치 발생.
원인: 타임아웃 설정의 부조화.
앞단 서버 Timeout: 8초
뒷단(승인) 서버 Timeout: 10초
승인 처리가 9초 걸리면? -> 앞단은 실패 처리, 뒷단은 성공 처리.
해결: 호출 체인(Call Chain)상의 모든 타임아웃을 전수 조사하여 재설정했습니다.
원칙: Upstream(호출하는 쪽) Timeout > Downstream(호출받는 쪽) Timeout + Network Latency
📮 장애 4: 이벤트 누락과 중복 발행
상황: Kafka로 이벤트를 발행하는 과정(Outbox Pattern)에서 DB 저장 실패 등으로 이벤트가 누락됨.
해결:
로그(ES, Local Disk Fallback)를 분석해 대상 건 식별 후 재발행.
문제: 재발행 과정에서 이벤트가 중복 전송됨.
보완: 이벤트 헤더에
멱등키(Idempotency Key)를 심어서, 소비(Consumer) 쪽에서 중복을 걸러낼 수 있도록 개선.
📝 Junior Dev Note: 멱등성 (Idempotency) 연산을 여러 번 적용하더라도 결과가 달라지지 않는 성질입니다. 결제 시스템에서 매우 중요합니다. "1000원 결제" 요청을 실수로 두 번 보내도, 실제로는 한 번만 결제되어야 합니다. 이를 위해 고유한 ID(Key)를 사용하여 중복 처리를 막습니다.
🛡️ 5. 데이터 일관성을 지키기 위한 최후의 보루들
금융 시스템에서 데이터 불일치는 곧 돈 문제로 직결됩니다. 토스페이먼츠가 구축한 안전장치들입니다.
5-1. 망취소 (Net Cancel)
문제: 토스페이먼츠 <-> 카드사/은행 간 통신 타임아웃 발생 시, 은행은 출금했는데 토스는 모르는 상황 발생 가능.
해결: 응답을 확실히 받지 못한 애매한 거래는, 시스템이 자동으로 **취소 요청(망취소)**을 날려 거래를 무효화시킵니다. "확실하지 않으면 승인하지 않는다"는 원칙입니다.
5-2. 아웃박스 패턴 (Transactional Outbox)
비즈니스 로직(DB 저장)과 이벤트 발행(Kafka)은 원자적(Atomic)으로 묶여야 합니다.
DB 트랜잭션 안에
Event테이블에 데이터를 같이Insert하고, 별도의 프로세스가 이 테이블을 읽어 Kafka로 메시지를 쏘는 방식을 사용해 메시지 발행을 보장했습니다.
🎯 6. 결론 및 인사이트
이번 마이그레이션 프로젝트는 단순한 코드 수정이 아니라, "지속 가능한 성장"을 위한 기반 공사였습니다.
초기 설계만큼 운영 대응이 중요하다: 아무리 완벽한 설계도 실제 트래픽과 데이터 분포 앞에서는 무너질 수 있습니다. 모니터링, 롤백 전략, 보정 배치가 필수입니다.
시스템은 스스로 회복해야 한다: 장애는 언제든 발생합니다. 중요한 건 사람이 개입하지 않아도 시스템이 스스로 데이터를 맞추고 복구하는
회복 탄력성(Resilience)입니다.마이그레이션은 종합 예술이다: DB, 네트워크, 애플리케이션 로직, 배포 전략까지 인프라 전반을 입체적으로 볼 수 있는 시야가 필요합니다.
✍️ 마무리하며 20년 된 레거시를 걷어내는 것은 '달리는 자동차의 엔진을 교체하는 것'과 같습니다. 토스페이먼츠의 사례는 철저한 이중화(Dual Write), 비동기 처리, 그리고 실패를 가정한 보정 메커니즘이 대규모 시스템 전환의 핵심 성공 요인임을 보여줍니다.
백엔드 개발자라면, 여러분의 시스템은 "오늘 밤 장애가 나도 내일 아침에 데이터가 맞아 있을지" 한 번쯤 고민해보는 계기가 되었으면 좋겠습니다.
느낀점
토스페이먼츠의 결제 원장 개편 사례는 단순한 DB 이전이 아니라, 레거시 구조를 유지하면서도 신규 시스템을 점진적으로 확장하는 과정에서 발생하는 실제 운영상의 난제를 잘 보여준다. 구원장과 신원장을 동시에 저장하는 방식, 비동기 저장으로 인한 이벤트 누락과 중복, 아웃박스 패턴의 한계, 타임아웃 불일치로 인한 결제 상태 불일치 등은 설계만으로 예측할 수 없는 실전 문제들이었다. 또한 JPA가 대량 마이그레이션에 적합하지 않기 때문에 벌크 인서트를 위한 별도 서버를 두는 전략, 그리고 장애 상황에서도 결제 가용성을 최우선으로 둬야 한다는 판단 등은 실제 도메인 특성에 기반한 선택들이었다.
이 과정을 보면서 시스템 설계의 “정답”은 결국 운영 속에서 만들어진다는 점을 크게 느꼈다. 모든 장애를 미리 예방하는 구조를 만드는 것이 아니라, 장애가 발생해도 데이터가 다시 복구되고 트래픽이 회복되는 회복탄력성(resilience) 자체가 설계의 목표가 되어야 한다는 것. 특히 이벤트 기반 시스템은 중복·누락을 완전히 제거하려고 하기보다, 멱등성을 통해 이를 흡수하도록 설계하는 것이 현실적이라는 점이 가장 인상적이었다. 결국 시스템은 완벽해야 하는 것이 아니라, 잘 무너지고 잘 복구될 수 있어야 한다는 사실을 다시 한 번 깨달았다.
Last updated