Safe Currency Exchange in Distributed Environments with Compensating Transactions

보상 트랜잭션으로 분산 환경에서도 안전하게 환전하기

[https://www.youtube.com/watch?v=xpwRTu47fqY&ab_channel=토스](https://www.youtube.com/watch?v=xpwRTu47fqY&ab_channel=%ED%86%A0%EC%8A%A4)

💱 분산 환경에서의 안전한 환전: 토스뱅크 FX 스쿼드의 사가 패턴 적용기


🏦 서비스 출시 배경: 토스뱅크 외화 통장

2024년 1월, 토스뱅크는 외화 통장 서비스를 출시했습니다.

사용자는 17개 외화를 자유롭게 보유하고, 해외 결제에 사용할 수 있으며, 환전 수수료 무료라는 파격적인 조건을 내걸었습니다.

이는 업계에 큰 변화를 가져왔고, 국내 은행들이 앞다투어 환전 수수료 무료화를 선언하는 계기가 되었습니다.

하지만 이 혁신적인 서비스 뒤에는 분산 환경에서 환전을 안전하게 처리해야 하는 난제가 존재했습니다.

하나의 데이터베이스에서 처리하는 단순 트랜잭션이 아니라, 원화 계좌 서버와 외화 계좌 서버가 분리된 환경에서의 트랜잭션 일관성을 보장해야 했기 때문입니다.


⚙️ 모놀리식에서 MSA로: 아키텍처 전환의 배경

초기 토스뱅크의 코어뱅킹 시스템은 모놀리식(Monolithic) 구조였습니다.

즉, 하나의 거대한 서버와 단일 데이터베이스가 모든 계좌와 거래 로직을 관리했죠.

📌 모놀리식 시스템

모든 기능이 하나의 코드베이스와 DB에서 돌아가는 구조. 작은 규모에서는 단순하지만, 규모가 커질수록 배포, 장애 복구, 확장성에서 문제가 커집니다.

하지만 서비스가 커지면서 확장성 부족, 배포 어려움, 단일 장애점(SPOF, Single Point of Failure) 문제가 심각해졌습니다.

이에 따라 MSA(Microservice Architecture) 전환이 추진되었습니다.

  • 기존 코어뱅킹 로직은 도메인 단위로 점진적으로 분리

  • 원화 계좌는 여전히 코어뱅킹 DB를 공유

  • 외화 계좌 같은 신규 상품은 완전히 분리된 DB에서 운영

결국 환전 로직은 원화 서버 + 외화 서버 + 환전 서버라는 분산 환경 위에서 구현되어야 했습니다.


💥 분산 환경에서의 트랜잭션 문제

환전은 본질적으로 원화 출금 + 외화 입금이라는 두 개의 작업으로 구성됩니다.

단일 DB에서는 하나의 트랜잭션으로 묶어 원자성을 보장할 수 있습니다. (둘 중 하나 실패하면 전체 롤백)

하지만 서버와 DB가 분리되면 문제가 생깁니다:

  • 원화 출금은 성공했는데 외화 입금이 실패 → 고객 돈이 증발

  • 외화 입금은 성공했는데 원화 출금이 실패 → 은행 손실 발생

따라서 분산 트랜잭션을 통해 원자성을 보장해야 했습니다.


🔑 분산 트랜잭션의 두 가지 접근법

토스뱅크 팀은 대표적인 두 가지 방식을 비교했습니다.

1️⃣ Two-Phase Commit (2PC, 투피씨)

  • 코디네이터가 각 서비스에 "커밋 가능 여부"를 질의

  • 모두 OK → 커밋 진행

  • 하나라도 실패 → 전체 롤백

장점: 원자성이 강력하게 보장됨

단점: 느린 참여자 때문에 전체가 대기 → 성능·가용성 저하

2️⃣ Saga Pattern (사가 패턴)

  • 각 서비스가 로컬 트랜잭션을 실행

  • 실패 시, 이미 실행된 작업에 대해 보상 트랜잭션(Compensation Transaction) 수행

장점: 높은 가용성과 확장성

단점: 중간 상태 노출 가능, 보상 로직을 직접 구현해야 함


🎯 왜 사가 패턴을 선택했나?

토스뱅크 FX 팀은 사가 패턴을 선택했습니다. 이유는:

  • 환전 서비스는 고객 트래픽이 많고, 향후 카드/회계 등 다양한 시스템과 연결될 가능성이 큼

  • 2PC는 참여자가 늘어날수록 병목이 발생 → 적합하지 않음

  • 사가는 확장성회복 가능성이 뛰어남


🎭 사가 패턴의 두 가지 방식

사가에는 두 가지 운영 방식이 있습니다:

  1. Choreography Saga (코레오그래피)

    • 메시지 브로커 기반, 이벤트 주도

    • 단일 장애점 없음, 서비스 결합도 낮음

    • 단점: 상태 추적이 어렵다

  2. Orchestration Saga (오케스트레이션)

    • 오케스트레이터가 각 서비스에 명령

    • 상태 추적이 쉽다

    • 단점: 오케스트레이터 자체가 단일 장애점

👉 토스뱅크는 Orchestration 방식을 선택했습니다.

환전 서버가 오케스트레이터가 되어, 환전 상태를 추적하고 보상 트랜잭션을 지휘할 수 있었기 때문입니다.


🔄 환전 플로우: 성공 시나리오

예: 1,300원 → 1달러 환전

  1. 환전 서버 → 원화 서버: 1,300원 출금 요청

  2. 성공 시 → 외화 서버: 1달러 입금 요청

  3. 둘 다 성공 → 환전 성공


🚨 장애 시나리오와 대응

1. 출금 실패

  • 출금 단계에서 실패하면 → 그대로 환전 실패 처리

  • 추가 보상 불필요

2. 입금 실패

  • 출금은 성공했는데, 입금이 실패

  • 보상 트랜잭션: "출금 취소"를 원화 서버에 요청

  • 결과적으로 고객 돈은 안전하게 복원됨

3. 왜 출금 → 입금 순서인가?

입금을 먼저 하고 출금이 실패하면,

그 사이에 외화 계좌에서 돈이 빠져나가면 복구 불가능.

따라서 항상 출금 → 입금 순서로 실행.


🌐 통신 방식: HTTP vs 메시징

  • 출금/입금 요청: HTTP 동기 호출

    • 이유: 즉각적인 결과 확인, 타임아웃 처리 용이

  • 보상 트랜잭션(출금 취소): 메시징 기반 (Kafka)

    • 이유: 비동기로 재시도 가능, 유저는 기다릴 필요 없음


⏳ 비정상 에러와 지연 메시지 처리

네트워크 장애, 서버 타임아웃 같은 비정상 에러는 더 까다롭습니다.

  • 출금이 성공했는지/실패했는지 불확실한 경우

  • 해결책: 출금 결과 확인 API 재호출

  • 만약 이것조차 실패 → Kafka 지연 메시지 스케줄러 활용

    • 30초 후 재확인, 1분 후 재확인 등 점진적 재시도

    • 결국 수동 개입까지 가능하게 설계


🛠️ 데드레터 큐(Dead Letter Queue)로 보상 보장

  • 출금 취소조차 실패하면?

  • Kafka **Dead Letter Queue (DLQ)**로 메시지를 보관

  • 재시도 실패 시 → 개발자 알림 후 수동 재발행 가능

  • 결과적으로 결과적 정합성(Eventual Consistency) 보장

📌 결과적 정합성(Eventual Consistency)

분산 시스템에서 즉각적인 일관성은 보장되지 않지만, 시간이 지나면 최종적으로 모든 데이터가 일관된 상태에 도달하는 성질.


📊 모니터링: 상태 추적과 스테이트 머신

환전 서버는 모든 환전 상태를 **스테이트 머신(State Machine)**으로 관리했습니다.

  • Exchange Request 테이블: 환율·금액 스냅샷

  • Exchange State Log 테이블: 상태 전이 로그 (추가 삽입 방식)

이를 통해:

  • 모든 실패 원인 추적 가능

  • 중간에 멈춘 환전 탐지 가능 (Alert 발송)

  • 계좌 입출금 내역과 Cross-check 가능

상황별 정리

✅ 정상 동작 (Happy Path)

🧭 상황 설명

  • 전제조건: 사용자가 1,300원 → 1달러 환전을 요청한다. 환전 서버(오케스트레이터)가 요청을 접수.

  • 흐름:

    1. 환전 서버 → 원화(KRW) 서버출금(1300 KRW) HTTP 동기 호출 (짧은 타임아웃)

    2. 성공 시 → 외화(FX) 서버입금(1 USD) HTTP 동기 호출

    3. 성공 시 → 환전 서버는 상태를 COMPLETED로 기록, 이벤트 발행(푸시/SSE) → 클라이언트는 조회 API로 확정 표시

🔎 증상/리스크

  • 별도 장애 없음. 단, 정상 경로도 멱등성은 필요(중복 클릭/네트워크 재전송 대비).

🧯 대응 절차

  • 없음(정상 완료). 상태 로그(append-only)와 스냅샷 저장.

👤 사용자 영향

  • 화면에서 즉시 “완료”로 전환. 영수증/타임라인 표시.

🧰 운영 체크리스트

  • P99 응답시간, 성공률.

  • 상태전이(예: KRW_DEBIT_SUCCESS → USD_CREDIT_SUCCESS → COMPLETED) 누락 여부.

🧪 예방/테스트 포인트

  • 멱등키(exchangeId)로 동일 요청 중복 시 동일 결과 반환 테스트.

  • COMPLETED 이후 중복 이벤트 무해성 테스트.

주석

  • 멱등성(Idempotency): 같은 요청이 여러 번 와도 결과가 변하지 않도록 보장하는 성질.

  • append-only 로그: 기존 레코드를 수정하지 않고 추가만 하는 로그 구조(감사/복구에 유리).


🚨 시나리오 1 — KRW 출금 “정상 실패”(잔액 부족/계좌 해지/한도)

🧭 상황 설명

  • 사용자가 환전을 시도했지만 업무 규칙(잔액/한도/계좌상태)에 막혀 출금 자체가 거절된다.

🔎 증상/리스크

  • 위험은 낮음. 돈 이동 없음. 환율 변동에 따른 사용자 불만 가능.

🧯 대응 절차

  • 환전 서버가 즉시 실패 처리(FAILED), 이유코드 저장, 사용자 안내.

  • FX 입금/보상 없음.

👤 사용자 영향

  • “잔액 부족/계좌 상태로 환전 불가” 등 명확한 메시지. 재시도 가이드(충전/한도 변경 등).

🧰 운영 체크리스트

  • 사유별 실패율 모니터링(상품/정책 개선 힌트).

  • 악성 재시도(봇) 탐지.

🧪 예방/테스트 포인트

  • 경계값 테스트(한도 딱 맞을 때/1원 부족 등).

  • 다국어/정책 변경 시 오류문구 회귀 테스트.

주석

  • 업무적 정상 실패: 규칙에 의해 의도적으로 거절되는 실패(에러가 아님).


🚨 시나리오 2 — KRW 출금 “비정상 실패”(타임아웃/500/네트워크)

🧭 상황 설명

  • 환전 서버가 KRW 서버로 출금을 동기 호출했는데 응답이 타임아웃되거나 5xx, 네트워크 에러가 발생.

  • 중요: 출금이 실제로 성공했을 수도, 실패했을 수도 있다(불확실 상태).

🔎 증상/리스크

  • 가장 큰 리스크: 미확실 상태에서 섣불리 실패 처리 → 실제로는 출금이 되었는데 사용자는 실패로 인지 → 돈이 묶이는 체감.

  • 중복 재호출로 중복 출금 위험(멱등성으로 방지 필요).

🧯 대응 절차

  1. 사용자에게는 즉시 PENDING(또는 “처리 지연”) 응답 → UI 블로킹 최소화

  2. 환전 서버는 결과 확인 API를 1차 조회

  3. 실패 시 **Kafka 지연 메시지(예: 30초/1분)**로 출금 결과 확인 예약

  4. 회복되면 결과 확정:

    • 성공이면 FX 입금 단계로 진행

    • 실패면 환전 실패로 종결

  5. 결과 확인조차 연쇄 실패 시 → 배치 복구가 미완료 환전을 스캔, 동일 절차 반복

👤 사용자 영향

  • 처음엔 PENDING/실패 예정으로 보였어도 나중에 성공으로 정정될 수 있다(알림 + 재조회).

  • 장시간 지연 시 배너/푸시로 안심 커뮤니케이션 제공.

🧰 운영 체크리스트

  • 타임아웃 건수, 재확인 성공률, 지연 메시지 큐 적체.

  • “출금 미확정” 상태의 체류 시간 SLA.

🧪 예방/테스트 포인트

  • 카나리 장애(일부 5xx/타임아웃), 네트워크 플랩(간헐 끊김) 시나리오.

  • 결과확인 API 스로틀/레이트 리미트와 백오프 전략.

주석

  • 지연 메시지: 메시지를 미래 시점에 재발행하여 회복 시간을 주는 전략.

  • SLA: 합의된 서비스 수준(예: PENDING 최대 2분 이내 확정 등).


🚨 시나리오 3 — FX 입금 “정상 실패”(규칙 위반/계좌 제약)

🧭 상황 설명

  • KRW 출금은 성공했으나, FX 입금이 업무 규칙으로 거절된 케이스.

🔎 증상/리스크

  • 고객 돈은 이미 KRW 계좌에서 빠져나감. 즉시 복원(환불) 필요.

🧯 대응 절차

  • 보상 트랜잭션 필수: 환전 서버가 KafkaKRW 출금 취소 메시지 발행

  • 원화 서버 컨슈머가 처리, 실패 시 DLQ로 재시도 → 결과적 정합성으로 환불 보장

  • 최종 상태는 FAILED(환불완료)

👤 사용자 영향

  • “환전 실패, 원화로 환불 완료” 메시지 + 타임라인 표기.

🧰 운영 체크리스트

  • 보상 성공률, 보상 지연 시간(P50/P95), DLQ 적재량.

  • 보상 실패 알람 임계치.

🧪 예방/테스트 포인트

  • 보상 메시지 중복 수신 시 멱등 처리.

  • Outbox/Producer-DL로 메시지 발행 보장.

주석

  • DLQ(Dead Letter Queue): 소비 실패 메시지를 모아 두고 정책적으로 재시도하는 큐.


🚨 시나리오 4 — FX 입금 “비정상 실패”(타임아웃/500/네트워크)

🧭 상황 설명

  • KRW 출금 이후, FX 입금에서 네트워크/서버 오류로 입금 결과가 불확실.

🔎 증상/리스크

  • 실제 입금은 되었는데 응답만 못 받은 경우 ↔ 실제 입금도 실패인 경우.

  • 잘못 판단하면 이중 입금/중복 보상 위험.

🧯 대응 절차

  1. 사용자에게 PENDING/지연 안내, 즉시 종료

  2. 환전 서버가 FX 입금 결과 확인 API로 1차 조회

  3. 실패 시 지연 메시지 예약(30초/1분 백오프)

  4. 결과 확정:

    • 성공이면 COMPLETED로 정정(알림/푸시)

    • 실패KRW 출금 취소 보상 트랜잭션 발행 → FAILED(환불완료)

  5. 재확인이 계속 실패하면 배치 복구가 동일 루틴 수행

👤 사용자 영향

  • 일시적으로 실패/진행중으로 보였다가 성공으로 정정될 수 있음(사가 특성).

🧰 운영 체크리스트

  • FX 서버 가용성/에러율, 결과확인 API 성공률.

  • 정정(update) 이벤트 비율.

🧪 예방/테스트 포인트

  • FX/환전 서버 간 네트워크 파티션(한쪽만 통신 불가) 시나리오.

  • 결과확인 API 시간창/일치성(replica 지연) 테스트.

주석

  • 네트워크 파티션: 서비스 간 통신 불능 상태가 영역적으로 발생하는 현상.


🚨 시나리오 5 — “보상 트랜잭션” 메시지 발행 실패(프로듀서 측)

🧭 상황 설명

  • 환전 서버가 KRW 출금 취소 메시지를 Kafka에 보내려다 브로커 장애/네트워크 문제로 실패.

🔎 증상/리스크

  • 보상 메시지 유실 시 고객 돈이 제때 복원되지 않음(치명적).

🧯 대응 절차

  • 트랜잭셔널 메시징 적용:

    • Transactional Outbox 사용 → DB 커밋과 함께 outbox에 메시지를 기록, 별도 폴러가 브로커 회복 후 발행

    • 또는 Producer Dead Letter대체 브로커에 안전 저장 후 재전달

  • 운영 알람/모니터링: outbox 적체/전달지연 탐지

👤 사용자 영향

  • 환불 지연 가능(알림/타임라인에 지연 안내).

🧰 운영 체크리스트

  • outbox 잔량/체류시간, Producer-DL 적재량.

  • 브로커 가용성 지표.

🧪 예방/테스트 포인트

  • 브로커 전면/부분 장애, 네트워크 단절에서 메시지 손실 없음 검증.

  • 중복 발행 시 멱등 소비 확인.

주석

  • Transactional Outbox: “DB 트랜잭션과 메시지 발행의 원자성”을 outbox 테이블로 보장하는 패턴.


🚨 시나리오 6 — “보상 트랜잭션” 소비/처리 실패(컨슈머 측)

🧭 상황 설명

  • KRW 서버 컨슈머가 출금 취소 메시지를 받았지만 내부 오류로 처리 실패.

🔎 증상/리스크

  • 보상 지연/유실 시 고객 돈 복원 지연.

🧯 대응 절차

  • 컨슈머 DLQ로 이동 → 정책적 재시도(횟수/간격)

  • 재시도 초과 시 운영자 알람 → 수동 재발행/재처리

  • 컨슈머는 멱등 처리(이미 취소된 출금은 무해)

👤 사용자 영향

  • 환불 지연. 명확한 지연 메시지/푸시 필요.

🧰 운영 체크리스트

  • DLQ 적재, 재시도 성공률, 보상 평균/최대 지연.

🧪 예방/테스트 포인트

  • 컨슈머 재기동/롤링 중 처리 보장(Safe Shutdown).

  • 정합성 점검 배치로 미처리 식별.


🚨 시나리오 7 — 오케스트레이터(환전 서버) 중간 다운

🧭 상황 설명

  • 예: KRW 출금 성공 로그까지 남기고 환전 서버 컨테이너가 크래시 → FX 입금/보상 미실행.

🔎 증상/리스크

  • 중간 상태가 장시간 지속되면 고객 체감 불안/민원.

🧯 대응 절차

  • 재기동 후 배치 복구ExchangeStateLog를 조회:

    • “출금 성공/입금 없음” → 보상 발행 후 실패 확정

    • “출금 불확실” → 결과 확인 루틴 재개

  • 복구 완료 시 이벤트 발행(알림)

👤 사용자 영향

  • 일시 PENDING, 이후 실패(환불) 혹은 성공으로 정리.

🧰 운영 체크리스트

  • “미완료 환전” 수/체류시간 알람.

  • 배치 복구 처리량/성공률.

🧪 예방/테스트 포인트

  • 서버 강제 종료/롤링 업데이트 중간에 정합성 유지.

  • 배치 중복 실행의 무해성(re-entrant) 검증.


🚨 시나리오 8 — “결과 확인 API”가 연쇄 실패

🧭 상황 설명

  • 상대 서버(원화/외화) 또는 네트워크가 계속 불안정 → 결과 확인 자체가 계속 실패.

🔎 증상/리스크

  • PENDING 장기화, 사용자 불안, 콜 폭증(폴링/재시도).

🧯 대응 절차

  • 지연 메시지 백오프(30초→1분→3분…)

  • *회로 차단기(Circuit Breaker)**로 과도한 재시도 방지

  • 일정 임계 초과 시 배치 복구 + 운영 개입(상태 강제 확정/공지)

👤 사용자 영향

  • “지연 안내” 고지, 확정 시 알림 보장.

🧰 운영 체크리스트

  • 결과확인 실패율, 백오프 단계 분포, 회로 차단 개수/기간.

🧪 예방/테스트 포인트

  • DNS 장애/방화벽 이슈/Region 장애 등 환경 레벨 테스트.

  • 회로차단/폴백 정책 튜닝.

주석

  • Circuit Breaker: 실패가 많을 때 호출을 잠시 끊어 더 큰 장애를 막는 패턴.


🚨 시나리오 9 — “중복/순서 꼬임/재시도 폭주”

🧭 상황 설명

  • 사용자 연타/네트워크 재전송/리버스 프록시 재시도 → 같은 환전이 여러 번 도착.

  • 메시지 중복 소비/순서 역전.

🔎 증상/리스크

  • 이중 출금/이중 입금, 상태 전이 역전 → 금전 리스크.

🧯 대응 절차

  • *멱등키(exchangeId)**로 API/컨슈머 모두 멱등화

  • 상태 전이에 버전/시퀀스 체크 → 허용 그래프 외 전이는 거부

  • 재시도 폭주 방지(백오프/서킷 브레이커/레이트리미팅)

👤 사용자 영향

  • 동일 요청 반복 시 “이미 처리됨” 안전 응답.

🧰 운영 체크리스트

  • 멱등 충돌률, 거부된 상태전이 카운트.

  • 재시도 폭주 경보.

🧪 예방/테스트 포인트

  • Proxy/Client 레벨 자동 재시도 조건 하에서 이중 청구 없음 검증.

  • 이벤트 정렬 보장(키 파티셔닝, 단일 파티션 소비).


🚨 시나리오 10 — “KRW·FX 로그 간 불일치”(데이터 드리프트)

🧭 상황 설명

  • 각 계좌 서버는 자기 입출금만 앎. 시간이 지나 보니 쌍이 안 맞는 거래가 존재.

🔎 증상/리스크

  • “입금만 있음/출금만 있음” → 재무 불일치/감사 리스크.

🧯 대응 절차

  • 주기 교차 검증 배치: KRW/Fx 로그를 분석DB에 적재 → 묶음 키로 매칭 → 불일치 탐지 시 자동 교정(보상/정산) 또는 운영 알림.

👤 사용자 영향

  • 드물지만 장표 보정/안내 필요.

🧰 운영 체크리스트

  • 불일치 탐지 건수 추세/해결 리드타임.

🧪 예방/테스트 포인트

  • 분석 적재 지연/중복 방지.

  • 묶음 키(상관 ID) 설계 일관성.


🚨 시나리오 11 — “라스트 마일” UX: 장시간 PENDING

🧭 상황 설명

  • 위 여러 케이스로 인해 사용자가 화면에서 오래 대기하거나 페이지를 떠난다.

🔎 증상/리스크

  • 불만/민원, 동일 요청 재시도(폭주).

🧯 대응 절차

  • 즉시 응답은 짧게: PENDINGSSE/푸시로 상태 변화 알림

  • 화면 체류 시 백오프 폴링 병행(알림 유실 대비)

  • N초 경과 시 “지연 안내 + 나중에 알림 드리겠습니다” UI 제공

👤 사용자 영향

  • 체감 신뢰도↑, 재시도 폭주↓.

🧰 운영 체크리스트

  • PENDING 체류시간 분포, 알림 도달률.

🧪 예방/테스트 포인트

  • SSE 재연결/Last-Event-Id 복구.

  • 푸시 도달 실패 시 백업 경로.

주석

  • SSE: 서버→클라이언트 단방향 실시간 스트림(HTTP 기반, 구현 간단/프록시 친화적).

  • Last-Event-Id: SSE 재연결 시 유실 이벤트 없이 이어받기 위한 헤더.


🧭 종합 타임라인(요지)

  1. 동기 HTTP출금 → 입금 시도(짧은 타임아웃)

  2. 불확실/실패 → 결과 확인 API → 실패 시 지연 메시지 예약

  3. 여전히 실패 → DLQ 재시도

  4. 그래도 실패/서버 다운 → 배치 복구(로그 기반 재개/보상)

  5. 최종 확정 시 알림 이벤트조회 API로 클라이언트 확정 표시

느낀점

발생할 수 있는 여러 문제 상황들을 모두 식별하고, 거기에 대한 대응책을 잘 마련했다고 생각이 듭니다. 평소에 이러한 습관이 부족하다고 느꼈는데, 어떤식으로 처리를 할 수 있는지 배웠습니다. 또한 이것마저 부족하여, 개발자가 개입하게 됐을때 필요한 정보를 쉽게 모을 수 있도록 로그 저장 처리를 잘 해두었다는게 좋았습니다. 실제로 구현을 해보면 좋을것 같다는 생각이 듭니다.

Last updated