Architecture(2) - 전자 지갑 설계(2PC, TC/C, Saga, 이벤트 소싱, CQRS)



1. 문제 이해 및 설계 범위 확정

  • 기능 범위: 이체 기능 집중
    • Q: 두 전자 지갑 사이의 이체에만 집중해야 하는가? 다른 기능도 신경써야 하는가?
    • A: 이체 기능에만 집중한다.
  • 처리량: 목표 TPS
    • Q: TPS 요구사항은 얼마인가?
    • A: 1,000,000 TPS
  • 일관성: 트랜잭션 보증
    • Q: 정확성은 데이터베이스가 제공하는 트랜잭션 보증이면 충분한가?
    • A: 그렇다.
  • 감사 및 정확성: 재현성 요구사항
    • Q: 정확성을 증명해야 하는가?
    • A: 그렇다. 일반적으로 정확성은 트랜잭션 완료 후에나 확인 가능하다.
      • 내부 기록과 은행 명세서를 비교하는 대장 조정(Reconciliation) 작업은 데이터 일관성이 깨졌다는 사실은 알려주지만, 그 차이가 발생한 근본 원인을 파악하기는 어렵다.
      • 따라서 처음부터 데이터를 재생하여 언제든지 과거 특정 시점의 잔액을 재구성할 수 있는 재현성(Reproducibility)을 갖춘 시스템을 설계해야 한다.
  • 가용성
    • Q: 가용성 요구사항은 99.99%로 가정해도 되는가?
    • A: 그렇다.
  • 기능범위: 환전 지원 여부
    • Q: 통화 간 환전이 가능해야 하는가?
    • A: 아니다.

요구사항을 요약하면 아래와 같다.

구분주요 요구사항상세 내용
기능 요구사항계정 간 자금 이체전자 지갑 간 신속하고 정확한 이체 지원
비기능 요구사항초당 처리량 (Throughput)1,000,000 TPS 수용
 가용성 (Availability)99.99% 고가용성 보장
 트랜잭션 일관성데이터베이스 수준의 ACID 트랜잭션 보증
 재현성 (Reproducibility)과거 특정 시점 잔액 재구성 및 원인 추적 가능

1.1. 개략적 추론

초당 100만 건의 트랜잭션을 처리한다는 것은 배후에 대규모 트랜잭션 데이터베이스 클러스터가 필요함을 의미한다.
일반적인 RDB 노드 1대가 초당 1,000 TPS를 처리할 수 있다고 가정할 때, 시스템 규모를 산정해보자.

  • 단일 이체 연산의 구조
    • 1건의 계좌 이체 명령이 실행되면 다음 2가지 필수 DB 연산이 발생한다.
      • 출금 계좌 잔액 차감(Withdrawal)
      • 입금 계좌 잔액 가산(Deposit)
  • 필요 총 DB 연산량
    • 1,000,000 이체 TPS * 2회 연산 = 2,000,000 DB TPS
  • 필요 DB 노드 수
    • 2,000,000 DB TPS / 1,000 TPS(노드 당 성능) = 2,000개 노드

동일한 하드웨어 스펙을 기준으로 단일 노드가 처리할 수 있는 TPS를 끌어올릴수록 필요한 전체 노드 수가 크게 감소한다.
따라서 이번 설계의 최우선 목표 중 하나는 단일 노드의 TPS를 극대화하여 전체 노드 수와 분산 시스템의 복잡도를 절감하는 것이다.


2. 개략적 설계안 및 초기 모델의 한계

전자 지갑 시스템의 핵심 기능인 계좌 간 자금 이체를 처리하기 위한 API 구조와 초기 인메모리 샤딩 모델을 정의하고, 해당 구조가 가지는 데이터 원자성 한계를 분석한다.


2.1. API 설계

전자 지갑 서비스는 자금 이체 연산을 수행하기 위해 단일 API 엔드포인트만 있으면 된다.

  • 엔드 포인트: POST /v1/wallet/balance_transfer
  • 역할: 한 지갑 계좌에서 다른 지갑 계좌로 지정한 금액을 이체

요청 파라미터 명세

필드명설명자료형
from_account자금을 인출할 계좌 IDstring
to_account자금을 이체받을 계좌 IDstring
amount이체할 금액string
currency통화 단위 (ISO 4217 규격)string
transaction_id중복 요청을 제거(Deduplication)하기 위한 멱등성 IDuuid

응답 예시

{
  "status": "success",
  "transaction_id": "12341234-1234-12dd-1234-1234sc127778"
}

Tip: 금융 시스템에서의 데이터 타입 선택

amount 필드의 자료형을 double이나 float 같은 부동 소수점 타입 대신 string 으로 전달받은 이유는 이진 부동 소수점 연산 시 발생하는 미세한 정밀도 손실 오차를 방지하기 위함이다.
금융 시스템에서는 백엔드에서 string을 받아 BigDecimal 또는 최소 단위 정수(Cent 등)로 변환하여 연산하는 것이 표준 관례이다.

2.2. 결제 서비스 API 참고


2.2. 인메모리 샤딩(Redis + ZooKeeper) 모델과 원자성 문제

사용자의 계정 잔액은 <사용자 ID, 잔액> 형태의 키-값 구조로 표현할 수 있으므로, 인메모리 저장소인 Redis를 활용한 초당 100만 건 처리 구조를 고려해 볼 수 있다.

  • 레디스 클러스터 파티셔닝
    • 단일 레디스 노드로 100만 TPS 수용이 불가능하므로, 전체 계정 데이터를 여러 레디스 노드에 샤딩(=파티셔닝)하여 분산 보관한다.
  • 주키퍼(ZooKeeper) 분산 관리
    • 높은 가용성을 보장하는 설정 전문 저장소인 주키퍼를 사용하여 모든 레디스 노드의 파티션 수 및 주소 메타데이터를 통합 관리한다.
  • Stateless 지갑 서비스(Wallet Service)
    • 이체 요청 수신, 유효성 검증, 주키퍼 질의를 통한 파티션 노드 탐색 및 잔액 갱신을 담당하며, Stateless 구조이므로 자유로운 Scale-out이 가능하다.

메모리 기반(In-memory) 솔루션

<초기 인메모리 모델의 치명적 한계>

  • 원자성(Atomicity) 결여
    • 이체 연산 시 출금 계좌와 입금 계좌가 서로 다른 레디스 로드에 존재하는 경우가 대부분이다.
  • 부분 실패 문제
    • 지갑 서비스에 출금 계좌 노드의 잔액을 감소시킨 후, 입금 계좌 노드의 잔액을 증가시키기 직전에 지갑 서비스 노드가 다운되거나 네트워크 오류가 발생하면 출금만 발생하고 입금은 누락된다.
  • 트랜잭션 보증 불가
    • 서로 다른 두 개의 저장소 노드에 대한 갱신 작업이 단일 원자적 트랜잭션으로 실행되지 않으므로, 데이터 일관성이 손실되는 문제가 발생한다.

3. 데이터 일관성을 위한 분산 트랜잭션 전략

서로 다른 노드에 분산된 데이터 저장소의 잔액을 원자적으로 갱신하기 위해서는 분산 트랜잭션 메커니즘이 필수적이다.
데이터베이스 샤딩부터 2PC, TC/C, 사가(Saga) 패턴까지 대표적인 분산 트랜잭션 전략들을 살펴본다.


3.1. 데이터베이스 샤딩

인메모리 레디스의 원자성 결여 문제를 해결하기 위한 첫 번째 단계는, 각 파티션을 트랜잭션을 지원하는 관계형 데이터베이스 노드로 교체하는 것이다.

관계형 데이터베이스

클라이언트 계정 잔액이 3개의 관계형 데이터베이스 노드로 분산 샤딩된 구조이다.
트랜잭션 DB를 도입하더라도 이체 명령 하나가 서로 다른 두 데이터베이스 서버의 계정을 동시에 업데이트해야 한다면, 두 작업이 정확히 동일하게 완료되거나 취소되는 것을 보장할 수 없다.


3.2. 2단계 커밋(2PC: 2-Phase Commit) 및 단점

2단계 커밋은 데이터베이스 자체 기능에 의존하면서 여러 노드의 프로세스를 하나의 원자적 트랜잭션으로 묶는 저수준(Low-Level) 방안이다.

2단계 커밋

  • 1단계(준비 단계, Prepare Phase)
    • 조정자(지갑 서비스)는 DB A와 DB C에 읽기/쓰기 작업을 수행하고 레코드에 Lock을 설정한다.
    • 이후 모든 DB에 트랜잭션 커밋 준비가 되었는지 요청한다.
  • 2단계(커밋 단계, Commit Phase)
    • 모든 DB가 ‘예’라고 응답하면 조정자는 모든 DB에 트랜잭션 커밋을 요청한다.
    • 단 하나라도 ‘아니오’라고 응답하거나 응답이 없으면 조정자는 모든 DB에 트랜잭션 중단(Rollback)을 요청한다.

<2PC의 한계점>

  • 성능 저하
    • 다른 노드의 메시지를 기다리는 동안 데이터베이스 Lock이 오랫동안 유지되어 동시 처리 성능(TPS)가 급격히 떨어진다.
  • SPOF(Single Point of Failure)
    • 조정자 노드에 장애가 발생하면 DB 노드들이 Lock을 해제하지 못하고 대기 상태에 빠질 수 있다.

조정자 장애


💡여러 DB에 트랜잭션 준비를 시켜도 결국 각각의 독립된 트랜잭션이 아닌가? 어떻게 트랜잭션이 하나로 묶일까?

2PC는 데이터베이스 엔진 내부의 트랜잭션 매니저(TM) 레벨에서 동작한다.
‘준비(Prepare) 단계’에서 각 DB는 변경할 데이터를 WAL(Write-Ahead Log)에 기록하고 Lock응ㄹ 쥔 채 ‘전역 커밋/롤백 대기 상태’로 멈춰 선다.
데이터베이스는 조정자가 최종 커밋/롤백 Signal을 줄 때까지 로컬 트랜잭션을 완료하지 않고 단계를 지연시키므로, 시스템 관점에서는 하나의 전역 원자적 트랜잭션으로 동작하게 된다.

2PC가 저수준인 이유도 ‘준비 단계’를 실행하려면 데이터 트랜잭션 실행 방식을 이렇게 변경해야 하기 때문이다.


💡이기종 데이터데이스 간에 2PC를 실행하려면?

이기종 데이터 데이스 간에 2PC를 수행하려면 모든 DB가 X/Open XA 표준을 준수해야 한다.

XA는 분산 트랜잭션 조정자(Transaction Manager)와 데이터 베이스(Resource Manager) 간의 통신 규격이다.
DB가 xa_prepare(), xa_commit(), xa_rollback() 과 같은 전용 인터페이스 API를 제공하도록 설정하고, 애플리케이션 및 드라이버 레이어에서 XA 데이터소스를 사용하도록 데이터베이스 트랜잭션 실행 인터페이스를 변경해야 한다.


3.3. TC/C(Try-Confirm/Cancel) 보상 트랜잭션

TC/C(시도-확정/취소)는 애플리케이션 계층에서 구현하는 고수준 2단계 보상 트랜잭션 패턴이다.
2PC와 달리 각 단계가 완전한 독립 로컬 DB 트랜잭션으로 바로 커밋된다.

  • 조정자는 모든 데이터베이스에 트랜잭션에 필요한 자원 예약을 요청한다.
  • 조정자는 모든 데이터베이스로부터 회신을 받는다.
    • 모두 ‘예’라고 응답하면 조정자는 모든 데이터베이스에 작업 확인을 요청하는데 이것이 바로 ‘시도-확정(Try-Confirm)’ 절차다.
    • 어느 하나라도 ‘아니오’라고 응답하면 조정자는 모든 데이터베이스에 작업 취소를 요청하며, 이것이 바로 ‘시도-취소(Try-Cancel)’ 절차다.

2PC의 두 단계는 한 트랜잭션이지만 TC/C에서는 각 단계가 별도 트랜잭션이다.


3.3.1. TC/C 단계별 실행 흐름(시도-확정/취소)

계좌 A에서 계좌 C로 1달러를 이체하는 경우 단계별 연산 구조이다.

단계실행 연산계좌 A DB계좌 C DB
1단계시도 (Try)잔액 차감 (-$1)NOP (No Operation, 아무것도 하지 않음)
2단계 (성공 시)확정 (Confirm)NOP잔액 가산 (+$1)
2단계 (실패 시)취소 (Cancel)잔액 원복 (+$1)NOP

지갑 서비스가 TC/C의 조정자라고 가정한다. 분산 트랜잭션이 시작될 때 계정 A의 잔액은 1달러이고, 계정 C의 잔액은 0달러이다.


💡NOP 명령은 실제 데이터베이스 커맨드인가?

아니다. NOP(No Operation)은 실제 DB SQL 명령어가 아니다.
시스템 설계 예시에서 ‘아무 데이터 변경 연산도 수행하지 않고 항상 성공(True)를 반환한다’는 비즈니스 로직 처리 방식을 나타내기 위한 개념적 표기이다.


첫 번째 단계: 시도(Try)

조정자(지갑 서비스)는 계좌 A DB에 잔액을 1달러 차감하는 로컬 트랜잭션을 실행하고 즉시 커밋한다.
계좌 C DB에는 아무 연산도 하지 않는 NOP(No Operation) 명령을 전달한다.

시도 단계


💡계정 C가 포함된 DB에는 아무런 작업도 하지 않는데, C 계정 DB가 실패할 수도 있나?

Try 단계에서 C 계정 DB에는 데이터 Write 작업을 하지 않지만, 비즈니스 유효성 검증(Read/Validation)을 수행한다.
예를 들어 계정 C의 존재 여부, 휴면/동결 계좌 여부, 계좌 수신 한도 초과 여부 등을 검사한다.

만일 C 계정이 존재하지 않거나 불법 계정이면 Try 연산이 실패한 것으로 판단하여 전체 트랜잭션을 Cancel 처리한다.


두 번째 단계: 확정(Confirm)

모든 시도가 성공하면 확정 단계를 실행한다.
계좌 A는 이미 잔액을 줄었으므로 NOP 처리하고, 계좌 C DB에 1달러를 추가하는 로컬 트랜잭션을 실행한다.

확정 단계


두 번째 단계: 취소(Cancel)

시도(Try) 단계가 하나라도 실패하면 분산 트랜잭션을 취소한다.
이미 커밋된 계좌 A의 -$1 상태를 되돌리기 위해, 계좌 A DB에 1달러는 다시 가산(+$1)하는 별도의 보상 트랜잭션을 실행한다.
계좌 C DB에는 NOP을 보낸다.

취소 단계


3.3.2. 2PC vs TC/C

구분2PC (2-Phase Commit)TC/C (Try-Confirm/Cancel)
첫 번째 단계로컬 DB 트랜잭션 미완료 (Lock 점유 대기)모든 로컬 DB 트랜잭션 즉시 커밋 및 완료 (Lock 해제)
두 번째 단계 (성공)모든 로컬 트랜잭션을 일괄 커밋필요한 추가 로컬 DB 트랜잭션 실행
두 번째 단계 (실패)모든 로컬 트랜잭션을 일괄 롤백이미 커밋된 결과를 상쇄하는 보상 트랜잭션 실행
구현 계층데이터베이스 레벨 (XA 프로토콜)애플리케이션 레벨 (비즈니스 로직)
  • TC/C 장점(DBMS 종속성 해제)
    • XA 프로토콜 지원 여부와 관계없이, 단일 로컬 트랜잭션을 지원하는 DB라면 어떤 환경에서든 적용할 수 있다.
  • TC/C 단점(애플리케이션 복잡도 증가)
    • DB 엔진이 제어권을 갖는 2PC와 달리, 애플리케이션 비즈니스 로직에서 Try-Confirm/Cancel 상태 관리, 보상 트랜잭션, 예외 처리를 직접 구현하고 관리해야 한다.

3.3.3. 단계별 상태 테이블(Phase Status Table)

TC/C 실행 도중 지갑 서비스가 재시작되어도 상태를 복구할 수 있도록, 분산 트랜잭션의 진행 상황을 트랜잭션 데이터베이스에 보관한다.

단계별 상태 테이블

상태 테이블에는 최소한 아래 내용들이 포함되어야 한다.

  • 분산 트랜잭션 ID 및 내용
  • 각 DB에 대한 시도(Try) 상태(NOT_SENT_YET, HAS_BEEN_SENT, RESPONSE_RECEIVED)
  • 2단계 결정(CONFIRM, CANCEL): 시도 단계의 결과를 사용하여 계산
  • 2단계 실행 상태
  • 순서 어긋남 플래그

일반적으로 위 내용들은 출금 계좌 DB에 저장한다.


3.3.4. 불균형 상태 분석 및 유효한 연산 순서

TC/C는 Try 단계가 끝난 후 계좌 A에서 1달러가 차감되었지만 계좌 C에는 아직 반영되지 않은 일시적 불균형 상태가 발생한다.

불균형 상태

시도 단계 전에는 잔액의 총합은 $1 이었지만, 시도(Try) 단계가 끝나고 나면 계정 A 에서 $1이 차감되기 때문에 잔액의 총합이 $0이 된다.
이는 거래 후에도 잔액 총합은 동일해야 한다는 회계 기본 원칙을 위반한다.

하지만 TC/C 역시 애플리케이션 수준에서 최종적인 트랜잭션 정합성을 보장한다.

  • TC/C (애플리케이션 주도)
    • 독립된 여러 개의 로컬 트랜잭션으로 구성되며, 트랜잭션의 제어 주체가 애플리케이션이다.
    • 따라서 각 로컬 트랜잭션이 커밋될 때마다 데이터베이스에 반영된 중간 실행 결과를 애플리케이션이 조회할 수 있다.(격리성 수준 약화)
  • 단일 DB 및 2PC(데이터베이스 주도)
    • 트랜잭션 제어 주체가 데이터베이스 엔진이다.
    • 따라서 ACID의 격리성(Isolation) 보장에 의해 최종 커밋 전까지 애플리케이션은 중간 상태를 볼 수 없다.

이처럼 중간 상태가 노출되는 특성에도 불구하고, TC/C는 애플리케이션 레벨에서 최종적인 트랜잭션 정합성(궁극적 일관성)을 완벽히 보장한다.

단일 DB 트랜잭션이나 2PC 방식은 데이터베이스 엔진이 트랜잭션을 직접 제어하기 때문에, 격리성(Isolation) 원칙에 따라 최종 커밋 전까지 외부(애플리케이션)에서 중간 결과를 조회할 수 없다.

반면, TC/C는 애플리케이션이 트랜잭션의 제어 주체로서 개별 로컬 트랜잭션을 순차 실행하므로, 각 단계가 남기는 중간 상태를 애플리케이션이 인지하고 제어해야 한다.


💡애플리케이션이 중간 결과를 보는 것과 트랜잭션 보증은 어떤 연관이 있을까?

트랜잭션 보증이란 데이터 연산이 ACID 원칙(원자성, 일관성, 격리성, 내구성)을 만족하는 것을 의미한다.

2PC는 데이터베이스가 중간 상태를 외부에 숨기지만(높은 격리성), TC/C는 각 단계가 개별 로컬 트랜잭션으로 커밋되므로 중간 불귱형 상태가 애플리케이션에 잠시 노출된다.
그러나 Confirm/Cancel 이 최종 완료되면 결과적으로 자금의 총합이 정확히 맞춰지므로 궁극적 일관성(Eventual Consistency) 관점에서 트랜잭션 보증을 충족하게 된다.


3.3.5. 유효한 연산 순서

TC/C의 Try 단계에서 출금 계좌(A)와 입금 계좌(C)에 대해 선택할 수 있는 작업 조합은 총 3가지이다.

선택지계정 A (출금)계정 C (입금)유효성 여부
선택 1잔액 차감 (-$1)NOP (무연산)유효함 (올바른 순서)
선택 2NOP (무연산)잔액 가산 (+$1)유효하지 않음
선택 3잔액 차감 (-$1)잔액 가산 (+$1)유효하지 않음

선택 1(유효): A 계좌 차감 후 C 계좌 NOP

자금을 보유한 출금 계좌 A에서 먼저 금액을 차감하여 자원을 선점한다.

이 방식은 이후 트랜잭션이 실패하더라도 이미 차감된 금액을 A에 다시 더해주는 보상 트랜잭션(Cancel)을 안전하게 실행할 수 있다.


선택 2(유효하지 않음): C 계좌 가산 후 A 계좌 NOP

계좌 C에 1달러를 먼저 입금한 뒤 A 계좌 차감을 시도하는 방식이다.

만약 C 입금은 성공했으나 A 계좌 차감이 실패할 경우 지갑 서비스는 취소(Cancel) 단계를 실행해야 한다.
그러나 취소 명령이 C DB에 도달하기 전에 사용자가 C 계좌의 1달러를 다른 곳으로 이체하거나 인출해 버린다면, 취소 단계에서 C 계좌 잔액을 차감할 수 업게 되어 시스템에 자금 손실(오류)이 발생한다.


선택 3(유효하지 않음): A 계좌 차감과 C 계좌 가산 동시 시도

두 계좌에 대해 동시 변경을 시도하는 방식이다.

한쪽 DB 연산만 성공하고 다른 쪽은 실패할 경우 자금 불일치가 발생한다.


💡계좌 A와 C에 동시에 연산을 보낸다면(선택 3), A 연산이 실패했을 때 C에 추가된 금액도 단일 트랜잭션처럼 자동 롤백되지 않을까?

데이터베이스 엔진 차원의 자동 롤백은 불가능하다.
계좌 A와 계좌 C는 서로 다른 물리 데이터베이스 노드(샤드)에 존재하기 때문이다.
단일 DB 내부 연산이 아니므로 DB 엔진 수준의 ACID 트랜잭션 롤백 메커니즘이 작동하지 않는다.

TC/C는 2PC와 달리 높은 성능을 확보하기 위해 각 DB의 Try 연산을 독립된 로컬 트랜잭션으로 즉시 커밋하고 DB Lock을 해제한다.
따라서 C 계좌 DB 의 Try 연산(+$1)이 성공하면 그 즉시 커밋되어 데이터베이스에 확정된다.

이후 네트워크 지연이나 잔액 부족 등의 이유로 A 계좌 DB의 Try 연산(-$1)이 실패하더라도, 이미 커밋되어 종료된 C 계좌 DB의 로컬 트랜잭션을 DB가 스스로 되돌릴 수는 없다.

반드시 애플리케이션(지갑 서비스 조정자)이 C 계좌 DB에 1달러 차감 커맨드를 보내는 별도의 보상 트랜잭션(Cancel)을 전송해야 한다.

문제는 A의 실패를 감지하고 C DB로 취소(Cancel) 명령을 보내는 그 짧은 찰나에, 사용자가 C 계좌에 방금 들어온 1달러를 출금해 버릴 수 있다는 점(Race Confidion)이다.
이 경우 C DB에 취소 명령이 뒤늦게 도착해도 깎을 잔액이 없어 롤백에 실패하게 된다.

이것이 동시 실행(선택 3)이나 선입금(선택 2) 방식을 금지하고, 반드시 출금 계좌의 자원을 먼저 선점(선택 1)해야 하는 이유이다.


3.3.6. 순서가 어긋난 명령의 처리 로직

네트워크 지연으로 인해 시도(Try) 명령보다 취소(Cancel) 명령이 데이터베이스에 먼저 도착하는 순서 역전 현상이 발새알 수 있다.

잘못된 순서로 명령이 실행되는 시나리오

<위와 같은 경우를 대비한 처리 알고리즘>

  • 취소(Cancel) 명령이 먼저 도착하면 해당 데이터베이스에 Received_Cancel_Before_Try = TRUE 플래그를 상태 테이블에 기록한다.
  • 나중에 도착한 시도(Try) 명령은 이 플래그를 확인하고, 즉시 작업을 거부하며 실패 응답을 반환한다.
    • C 계정 DB 입장에서 Try 단계에 변경된 바원이 없더라도, 취소 명령이 먼저 들어왔을 때 플래그를 남겨두지 않으면 뒤늦게 들어온 Try 명령이 정상 연산으로 오인되어 실행되는 치명적인 데이터 꼬임 현상이 발생한다.
    • 지각한 Try 를 실패 처리함으로써 전채 트랜잭션을 안전하게 Cancel 상태로 귀결시킨다.

3.3.3. 단계별 상태 테이블(Phase Status Table)에서 순서 어긋남 플래그를 둔 이유가 바로 이 때문이다.


3.4. 사가(Saga) 패턴: 선형적 명령 수행 방식

사가(Sage) 패턴은 MSA에서 분산 트랜잭션을 처리하는 표준 솔루션이다.

사가 작업 흐름

  • 선형적 실행
    • 모든 연산을 순서대로 정렬하여 개별 로컬 DB 트랜잭션으로 순차 실행한다.
  • 보상 트랜잭션 역순 실행
    • 중간 단계에서 연산이 실패하면 성공했던 이전 연산들을 역순으로 보상 트랜잭션을 호출하여 롤백한다. (총 n개 연산 시 최대 2n개 연산 필요)

3.4.1. 코레오그래피 vs 오케스트레이션

구분코레오그래피 (Choreography)오케스트레이션 (Orchestration)
제어 방식탈중앙화 (Decentralized)중앙 집중식 (Centralized)
통신 구조서비스 간 이벤트 발행 및 구독오케스트레이터가 각 서비스에 Command 전송
트랜잭션 추적어려움 (여러 서비스의 로그 추적 필요)용이함 (오케스트레이터 상태 테이블에서 확인)
적합한 환경서비스 수가 적고 흐름이 단순할 때복잡한 비즈니스 로직과 다단계 트랜잭션

일반적으로는 오케스트레이션 방식을 사용한다.


3.5. 2PC vs TC/C vs Outbox vs Saga vs

분산 트랜잭션 핵심 기법을 종합 비교해본다.
시스템 설계 시 요구되는 지연 시간, 격리성, 구현 복잡도에 따라 적절한 패턴을 선택해야 한다.

구분2PCTC/C트랜잭셔널 아웃박스사가(Saga) 패턴
주요 목적DB 레벨의 강한 원자성 보장앱 레벨 자원 예약 및 상쇄DB 변경과 메시지 발행의 원자성MSA 간 최종 일관성 보장
격리성 (Isolation)매우 높음 (Lock 유지)높음 (Try 단계 자원 선점)해당 없음 (단일 서비스 내부)낮음 (중간 커밋 상태 노출)
실행 방식동기적 2단계 커밋병렬 또는 단계별 실행DB 트랜잭션 + 메시지 릴레이순차적(선형) 실행
보상 연산 필요 여부없음 (DB가 롤백)필수 (Cancel 구현)없음필수 (보상 트랜잭션)
지연 시간 (Latency)매우 큼 (성능 저하)낮음 (병렬 실행 가능)매우 낮음보통~큼 (순차 실행)
추천 유즈케이스단일 DB 클러스터 내부 연산티켓 예매, 잔액 선점 등 고성능 이체이벤트 기반 아키텍처 메시지 발행다단계 쇼핑몰 주문/결제/배송 흐름

3.5.1. TC/C vs Saga

비교 항목TCC (Try-Confirm/Cancel)Saga 패턴
동작 방식2단계 진행 (예약 → 확정/취소)순차 실행 후 실패 시 역순 보상(Compensate)
격리성 (Isolation)높음 (Try 단계에서 자원을 임시 점유)낮음 (각 단계마다 즉시 DB 커밋)
구현 요소서비스당 Try, Confirm, Cancel 3개 API단계별 로컬 트랜잭션 + 보상(Undo) 트랜잭션
실패 처리Cancel을 수행하여 예약 자원 해제보상 트랜잭션을 실행하여 이미 커밋된 데이터 취소
실행 방식병렬 실행 가능선형적(순차) 실행
적합한 유즈케이스좌석 예약, 재고 선점, 포인트 차감 등 동시성이 민감한 경우쇼핑몰 주문-결제-배송 등 흐름이 길고 단계가 많은 경우

💡TC/C도 시도(Try) 후 확정(Confirm)이나 취소(Cancel)를 순서대로 거치는데, 왜 사가 패턴과 달리 병렬 처리가 가능하다고 하는걸까?

‘서비스 간 요청 관계’에서 차이가 발생한다.

  • 사가(Saga) 패턴
    • 각 마이크로서비스의 작업 결과가 다음 서비스의 입력값이나 실행 조건이 되는 연쇄 구조를 가진다.
    • 예를 들어 주문 생성 → 결제 처리 → 재고 차감 → 배송 접수 처럼 앞 단계가 완료되어야만 다음 단계 로컬 트랜잭션을 실행할 수 있으므로 선형적(순차적)으로 진행된다.
  • TC/C 패턴
    • 여러 참여 서비스의 자원을 사전에 예약(Try)하는 단계에서 조정자가 각 서비스의 Try API를 동시에 비동기/병렬로 호출할 수 있다.
    • 예를 들어 A 계좌 차감 Try와 B 계좌 검증 Try, C 서비스 알림 준비 Try를 동시에 병렬로 보낸 뒤 모든 응답을 기다린다.
    • 모든 Try 응답이 OK로 돌아오면, Confirm API 역시 여러 서비스에 동시에 병렬로 호출한다.

따라서 단계 구분(Try → Confirm)은 존재하지만, 동일 단계 내에서 여러 서비스와의 통신을 동시에 처리할 수 있으므로 전체 트랜잭션 완료 지연 시간을 크게 줄일 수 있다.


3.5.2. 아웃박스 패턴(Outbox Pattern)

분산 트랜잭션을 이벤트 기반으로 구현할 때, DB 상태 변경과 메시지 브로커(Kafka, RabbitMQ 등)로의 이벤트 발생이 원자적으로 처리되지 않아 한쪽만 성공하는 이중 작성 문제가 발생할 수 있다.
이를 해결하는 것이 아웃박스 패턴이다.

비즈니스 데이터 변경과 발행할 이벤트를 단일 로컬 DB 트랜잭션 내에서 Outbox 테이블에 함께 저장한 뒤, 별도의 메시지 릴레이 프로세스가 메시지 브로커로 전송한다.

메시지 릴레이 구현 방식 비교

구분폴링 방식 (Polling Publisher)CDC 방식 (Transaction Log Tailing)
작동 원리스케줄러가 주기적으로 Outbox 테이블을 SELECT 조회DB 트랜잭션 로그(Binlog, WAL)를 실시간 감지
구현 난이도낮음 (애플리케이션 코드 내 구현)높음 (Debezium, Kafka Connect 등 CDC 인프라 필요)
처리 지연높음 (폴링 주기에 의존적)매우 낮음 (밀리초 단위 실시간 감지)
DB 부하주기적인 SELECT 조회로 부하 발생 가능DB에 직접적인 조회 부하 없음
추천 환경트래픽이 적거나 빠르게 적용해야 할 때대규모 트래픽 및 실시간 처리가 중요할 때
  • 폴링 재발행 방식(Polling Publisher)
    • 애플리케이션 내부 스케쥴러가 정기적으로 Outbox 테이블에서 미발생 상태 데이터를 조회하여 메시지 브로커로 전송 후 상태를 완료로 변경하거나 삭제함
    • 주의점
      • 폴링 주기에 따른 지연이 생기며, 애플리케이션 인스턴스가 여러 대일 경우 중복 처리를 막기 위해 분산 Lock 이나 동시성 제어가 필요함
  • 트랜잭션 로그 트레일링 방식(Log Trailing/CDC)
    • DB가 남기는 트랜잭션 로그(MySQL Binlog, PostgreSQL WAL 등)를 CDC 도구(Debezium 등)가 감지하여 메시지 브로커로 직접 발행
    • 애플리케이션에 폴링 로직을 둘 필요가 없고, DB 부하 없이 실시간 처리가 가능함

<아웃박스 패턴 적용 시 주의사항>

  • Outbox 테이블 주기적 정리
    • 성공적으로 발행된 레코드를 방치하면 DB 용량이 급증하므로, 발행 완료된 즉시 DELETE 하거나 아카이빙 배치로 정기 삭제해야 함
  • 수신 측 멱등성 보장
    • 네트워크 재시도로 인해 메시지가 최소 한 번 이상 전달될 수 있으므로, 수신 서비스는 이벤트 ID를 기반으로 중복 메시지를 걸러내는 멱등성 로직을 갖추어야 함

아웃박스 패턴 vs 사가 패턴의 관계

  • 아웃박스 패턴:
    • 단일 마이크로서비스 내부에서 DB 변경과 이벤트 발행의 원자성을 보장(Dual Write 방지)하는 패턴
  • 사가 패턴:
    • 여러 마이크로서비스에 걸친 분산 트랜잭션의 최종 일관성을 보장하는 패턴
  • 결합 방식:
    • ‘주문 서비스’가 ‘결제 서비스’로 사가 이벤트를 보낼 때, 주문 DB 저장과 이벤트 발행이 유실 없이 안전하게 이루어져야 다음 사가 단계가 실행될 수 있음
    • 이때 주문 서비스 내부에서는 아웃박스 패턴을 사용하여 이벤트를 안전하게 발행하고, 이 이벤트들이 이어서 실행되는 전체 분산 흐름을 사가 패턴으로 관리

3.5.3. 분산 트랜잭션의 한계와 데이터 감사의 필요성

잔액 이체를 원자적 트랜잭션으로 처리하기 위해 RDB 교체 및 TC/C, 사가, 아웃박스 패턴을 도입하면 데이터 일관성 문제를 상당 부분 해결할 수 있다.

그러나 분산 트랜잭션 솔루션 만으로는 다음과 같은 문제가 발생했을 때 대처하기 어렵다.

  • 사용자가 애플리케이션 수준에서 잘못된 금액을 입력하거나 실수로 중복 이체를 실행한 경우
  • 시스템 내부 버그나 악의적인 공격으로 인해 과거 특점 시점의 잔액이 변조된 경우

현재 데이터베이스 잔액(=최종 상태)만 저장하는 구조에서는 과거의 특정 시점 잔액이 얼마였는지, 잔액이 왜 그렇게 변경되었는지 근본 원인을 역추적하거나 검증할 방법이 없다.

따라서 모든 계정 연산의 이력을 완벽히 보관하고 데이터 정확성을 증명할 수 있는 재현성(Reproducibility) 기반의 아키텍처(이벤트 소싱)가 필요해진다.


4. 이벤트 소싱: 데이터 감사 및 재현성

분산 트랜잭션 패턴을 통해 데이터의 원자성을 확보하더라도, 현재 잔액만 저장하는 일반적인 데이터베이스 구조로는 과거 특정 시점의 데이터 검증이나 문제 발생 시 원인 추적이 불가능하다.
이러한 문제를 근본적으로 해결하는 설계 철학이 바로 이벤트 소싱(Event Sourcing)이다.


4.1. 전자 지갑에 이벤트 소싱이 필요한 이유

금융 및 전자 지갑 시스템은 규제 기관이나 외부 감사관으로부터 다음과 같은 엄격한 질문을 받게 된다.

  • 특정 시점 감사
    • ‘지난 달 15일 14:00 정각 기준, 특정 계정의 잔액은 정확히 얼마였는가?’
  • 정확성 검증
    • ‘현재 계정 잔액이 올바르다는 것을 어떻게 입증할 수 있는가?’
  • 로직 검증
    • ‘수수료 계산 방식이나 비즈니스 로직을 변경한 후에도 과거 데이터 처리에 오류가 없음을 어떻게 검증하는가?’

일반적인 CRUD 기반 시스템은 데이터베이스의 잔액 레코드를 직접 UPDATE 하므로 과거 잔액 상태가 소멸한다.
대장 조정(Reconciliation) 작업을 통해 잔액 불일치를 발견하더라도, 불일치가 발생했는지 역추적하기 어렵다.

이와 달리 이벤트 소싱은 상태 변화를 일으키는 모든 이벤트를 변경 불가능한 로그로 순차 저장함으로써 과거 어떤 시점의 잔액이든 완벽하게 재생 및 복원할 수 있는 재현성(ReProducibility)을 제공한다.


4.2. 이벤트 소싱 핵심 개념

4.2.1. 명령(Command)

  • 외부 사용자나 시스템에서 전달된 의도가 명확한 요청이다. (예: ‘A 계좌에서 C 계좌로 1달러를 이체하라’)
  • 명령은 아직 검증되지 않은 요청이므로 유효하지 않을 수 있다.
  • 순서 보장이 핵심이므로 FIFO 대기열 큐에 기록된다.

4.2.2. 이벤트(Event)

Command가 유효성 검사를 통과한 후 이행된 결과가 바로 Event 이다.
Command 순서에 맞춰 생성되므로 이벤트 역시 FIFO 큐에 저장된다.


Command와 Event의 차이점

  • 상태 및 시제(State & Tense)
    • Command는 유효하지 않을 수 있는 미래의 요청이지만, EVent는 검증을 마친 과거의 확정된 사실(Fact)이다.
    • 따라서 Event를 다룰 때는 항상 과거 시제를 사용한다. (예: ‘A 계좌에서 C 계좌로 1달러 송금을 완료하였음’)
  • 결정론성(Determinism)
    • Command 처리 과정에는 외부 I/O나 난수 등이 개입될 수 있지만, 한번 생성되어 기록된 Event는 과거에 일어난 불변의 사실이므로 결정론적 특성을 가진다.

Event 생성 프로세스의 특성

  • 1:N 매핑 관계:
    • 하나의 Command로부터 여러 개의 Event가 만들어질 수 있다.
    • 예) A 계좌에서 C 계좌로 1달러 이체 Command 1건은 아래 2개의 이벤트를 생성
      • A 계좌 1달러 차감됨 Event
      • C 계좌 1달러 가산됨 Event
  • 이벤트 생성 시 무작위성 개입:
    • Command 를 검증하고 Event를 생성하는 과정에는 외부 환경 요소가 반영될 수 있으므로, 동일한 Command라 할지라도 항상 완전히 동일한 Event들이 만들어진다는 보장은 없다.

💡 Event에는 I/O가 포함될 수 없는 걸까? Command는 무작위성 I/O 가 포함될 수 있지만 Event는 결정론적이라고 했는데, 왜 Event의 생성 과정에서는 무작위성이 개입될 수 있는 걸까?

두 단계의 차이를 이해해야 한다.

  • Command → Event 변환 과정(이벤트 생성 과정)
    • Command를 검증하고 이벤트를 변환하는 시점에는 외부 API의 호출(I/O), 현재 시간 측정, 난수 생성 등의 연산이 개입될 수 있다.
    • 따라서 동일한 Command라 할지라도 실행되는 시점이나 외부 환경에 따라 생성되는 Event 결과는 달라질 수 있다.
  • Event → State 적용 단계(이벤트 소싱 적용 과정)
    • 한번 검증을 거쳐 확정 및 기록된 Event는 그 자체로 변경할 수 없는 과거의 확정된 사실이다.
    • 이미 만들어진 Event를 읽어 State에 반영하는 과정에는 난수나 외부 I/O가 절대로 개입해서는 안 되며, 오직 Event에 적힌 값(e.g. A: -$1, C: +$1)만 순수하게 계산해야 한다.(= 100% 결정론적으로 동작)
    • 그래야만 이벤트를 몇 번을 다시 재생하더라도 항상 동일한 최종 상태(State)가 복원된다.

즉, 이벤트를 생성할 때는 외부 I/O나 무작위 요소가 개입하여 결과 이벤트 데이터가 달라질 수 있지만,
생성된 이벤트를 상태(State)에 적용할 때는 어떤 무작위성도 없이 100% 결정론적으로 동작하므로, 과거 이벤트 로그를 수백 번 재생하더라도 항상 완전히 동일한 최종 잔액(State)를 얻을 수 있다.


4.2.3. 상태(State)

  • Event가 적용될 때 변경되는 시스템의 데이터
  • 지갑 시스템에서의 State는 <계정 ID, 잔액> 형태의 Map 구조(키-값 저장소 또는 RDB)로 료현됨

4.2.4. 상태 기계(State Machine)

Command 유효성 검사 및 Event 생성기Event 적용을 통한 State 갱신기, 두 가지 상태 기계 역할로 구분된다.

이벤트 소싱 아키텍처를 구동하는 State Machine은 반드시 결정론적(Deterministic)으로 동작해야 한다.
무작위성이 포함되면 이벤트 소싱의 근본 목적인 과거 상태 복원이 불가능해진다.

  • 결정론적 동작의 정의
    • 동일한 초기 상태에서 출발하여 동일한 순서의 이벤트 목록을 적용하면, 몇 번을 실행하더라도 항상 100% 동일한 최종 상태(State)가 만들어져야 한다.
  • 금지되는 요소
    • 이벤트를 State에 반영하는 과정에서 외부 데이터베이스나 API를 조회하는 외부 I/O 연산
    • 시스템 현재 시각에 의존하는 로직
    • 난수 생성기의 사용
  • 원인과 결과
    • 만일 이벤트를 잔액에 반영하는 State Machine 내부에서 난수나 외부 I/O 결과값을 참조하여 잔액을 계산한다면, 장애 복구 시 과거 이벤트를 재생할 때마다 서로 다른 잔액 결과가 만들어지는 심각한 시스템 오류가 발생한다.

아래 그림은 command를 이벤트로 변환하고, 이벤트를 적용하는, 두 가지 기능을 지원해야 하므로, 명령 유효성 검사를위한 상태 기계 하나와 이벤트 적용을 위한 상태 기계 하나를 두었다.

정적 관점에서 표현한 이벤트 소싱 아키텍처

위 그림은 정적 관점에서의 이벤트 소싱 구조를 보여준다.
Command 유효성 검사를 담당하는 State Machine과 이벤트를 상태에 적용하는 State Machine가 분리되어 있다.

동적 관점에서 표현한 이벤트 소싱 아키텍처

시간 차원을 추가한 동적 관점의 구조이다.
Command 수신 → 유효성 검사 및 이벤트 생성 → 상태 적용 과정이 시간 흐름에 따라 연속적으로 반복된다.


4.3. 지갑 서비스에서의 이벤트 소싱 작동 원리

지갑 서비스에서 이체 요청 Command가 도착하면, Command 는 카프카와 같은 FIFO 대기열 큐에 먼저 저장된다.

Command Queue

State Machine은 Command 큐에서 이체 요청을 순서대로 꺼내어 다음 5단계를 거쳐 처리한다.

State Machine 동작 원리

① Command 읽기
Command 큐에서 이체 Command(A-$1 → D)를 읽는다.

② State 읽기
DB에서 계좌 A와 D의 현재 잔액 State를 읽어온다.

③ 유효성 검사 및 Event 생성
A의 잔액이 충분한 지 검증한다.
유효하다면 각 계정별 이벤트(A: -$1, D: +$1)를 생성하여 이벤트 큐에 기록한다.

④ Event 읽기
이벤트 큐에서 다음 이벤트를 순차적으로 읽어온다.

⑤ State 갱신
읽어온 이벤트를 데이터베이스 잔액 상태에 반영하여 업데이트를 완료한다.


4.4. 재현성(Reproducibility)을 통한 감사 대응

이벤트 소싱이 제공하는 가장 강력한 장점은 재현성이다.

State 재현

이벤트 로그는 한번 기록되면 수정할 수 없는 불변 객체이며, State Machine의 Event 적용 로직은 결정론적이다.
따라서 시스템 장애가 데이터 손상이 발생하더라도 최초 Event부터 순서대로 Replay하기만 하면 과거 어떤 시점의 상태든 100% 동일하게 재구성할 수 있다.

감사관의 질문에 대한 대응 방법은 다음과 같다.

  • 특정 시점 감사
    • Q: ‘지난 달 15일 14:00 정각 기준, 특정 계정의 잔액은 정확히 얼마였는가?’
    • A: 시스템 시작 시점부터 원하는 시점까지의 Event만 재생하여 잔액을 즉시 계산합니다.
  • 정확성 검증
    • Q: ‘현재 계정 잔액이 올바르다는 것을 어떻게 입증할 수 있는가?’
    • A: 전체 이벤트 로그의 가감 연산을 다시 수행하여 현재 DB 잔액과 일치함을 입증합니다.
  • 로직 검증
    • Q: ‘수수료 계산 방식이나 비즈니스 로직을 변경한 후에도 과거 데이터 처리에 오류가 없음을 어떻게 검증하는가?’
    • A: 신규 로직을 적용한 State Machine 에 과거 Event 이력을 입력으로 주고, 기존 결과와 동일하게 나오는지 회귀 테스트를 수행한다.

4.5. 명령-질의 책임 분리(CQRS)

이벤트 소싱 구조는 상태 변경(Write)에 최적화되어 있어, 외부 클라이언트가 자신의 현재 계정 잔액(State)를 조회하는 요청을 효율적으로 처리하기 어렵다.
이를 해결하기 위해 CQRS(Command-Query Responsibility Separation) 패턴을 결합한다.


도입 배경: 잔액 조회의 한계 극복

  • 직관적인 해결책
    • 상태 이력 데이터베이스의 읽기 전용 사본(Read Replica)를 단순 생성하여 외부에 공유하는 방식
  • 이벤트 소싱의 해결책(CQRS)
    • 계정 잔액이라는 State 자체를 직접 공개하는 대신, 발생한 모든 Event를 외부에 Publish 한다.
    • Event를 수신한 외부 주체는 전송받은 Event를 바탕으로 직접 자신에게 필요한 상태를 재구축한다.
    • 이처럼 데이터 변경(Command)과 데이터 조회(Query)의 책임을 분리하는 설계 철학을 CQRS라고 한다.

State Machine의 역할 분리

CQRS 구조에서는 State를 다루는 엔진을 쓰기와 읽기 역할로 명확히 나눈다.

  • 쓰기 전용 상태 기계(Write-side State Machine)
    • 단 하나만 존재하며, Command 유효성 검사, Event 생성, 원본 상태 기록을 전담
  • 읽기 전용 상태 기계(Read-side State Machine)
    • 여러 개 존재할 수 있으며, Event 큐에서 Event를 구독하여 조회(Query)에 최적화된 다양한 상태 뷰(State View)를 생성

읽기 전용 상태 기계의 주요 활용 사례

  • 잔액 질의 전용 데이터베이스 구축
    • 클라이언트의 빠른 잔액 조회 요청을 처리하기 위해, 이벤트 큐의 이벤트를 순차적으로 읽어 별도의 읽기 전용 DB(RDB, NoSQL 등)에 최신 잔액 상태를 프로젝션(Projection)함
  • 이중 청구 및 데이터 오류 조사
    • 이중 청구, 자금 불일치 등의 이상 현상이 발생했을 때, 특정 기간 동안 발생한 이벤트 이력만 추출 및 재생하여 해당 시점의 상태를 그대로 복원하고 원인을 정밀 추적함

궁극적 일관성

이벤트 큐를 거쳐 비동기로 이벤트가 전달되는 특성상, 읽기 전용 상태 기계가 표현하는 상태는 쓰기 전용 상태 기계의 실시간 상태보다 약간의 시간 차(Lag)를 두고 뒤쳐질 수 있다.

그러나 이 지연은 매우 짧은 시간 내에 해소되며, 이벤트 처리가 완료되면 읽기 영역의 데이터가 쓰기 영역의 데이터와 결국 동일해지는 궁극적 일관성 모델을 따르게 된다.


아래는 전형적인 CQRS 아키텍처이다.

CQRS 아키텍처


5. 고성능 및 고신뢰성 분산 이벤트 소싱 상세 설계

원격 카프카나 관계형 데이터베이스에 의존하는 기존 이벤트 소싱 모델은 네트워크 I/O 병목으로 인해 초당 100만 건(1,000,000 TPS)의 트래픽을 처리하기 어렵다.

이를 극복하기 위해 로컬 디스크/메모리 direct 입출력, Raft 합의 알고리즘 기반 복제, CQRS Push 통신, 파티셔닝 및 Saga 조정자를 결합한 고성능/고신뢰성 분산 이벤트 소싱 아키텍처를 구축한다.


5.1. 로컬 파일 기반 고성능 최적화

위에서는 카프카를 Command 및 Event 저장소로, 데이터베이스를 State 저장소로 사용했다.

여기서는 원격 저장소와의 통신 지연시간을 단축하기 위해 데이터 처리 엔진과 저장소를 동일한 물리 노드의 로컬 디스크 및 메모리로 통합한다.


5.1.1. mmap 기술을 활용한 Command 및 Event 목록

원격 메시지 브로커(Kafka)나 별도 DB 저장소를 사용할 때 발생하는 네트워크 I/O 지연 시간을 제거하기 위해, Command와 Event를 로컬 디스크의 추가만 가능한(Append-only) 파일 구조로 저장한다.


네트워크 지연 회피 및 순차 I/O 최적화

  • 네트워크 전송 시간 단축
    • 데이터를 원격 노드로 주고받는 과정에서 생기는 RTT(Round Trip Time)를 제거하여 로컬 수준의 처리 속도 확보
  • Append-only 자료 구조
    • Event 로그는 수정이나 삭제 없이 오직 맨 뒤에 추가(Append) 연산만 발생함
    • OS 커널은 순차적 읽기/쓰기 연산에 대해 디스크 입출력을 극도로 최적화하므로 HDD 환경에서도 매우 빠르게 동작함

순차적 디스크 접근은 경우에 따라서는 무작위 메모리 접근보다도 빠르게 실행될 수 있다.

Comparing Random and Sequential Access in Disk and Memory


mmap 매핑 및 OS 자동 메모리 캐싱

로컬 파일에 데이터를 지속 저장하면서도 메모리 수준의 처리 속도를 내기 위해 Linux의 mmap 시스템 콜 기술을 활용한다.

파일 기반의 Command 및 Event 저장소

  • 디스크 파일의 메모리 대응
    • mmap은 디스크 내 저장된 파일 영역을 프로세스의 가상 메모리 주소 배열(Array)에 1:1로 직접 매핑한다.
    • 애플리케이션은 디스크 입출력을 수행할 때 별도의 read(), write() 시스템 콜과 메모리 복사 단계 없이 RAM 메모리 배열에 데이터를 읽고 쓰는 것처럼 동작한다.
  • OS 자동 캐싱
    • 운영체제 커널은 매핑된 디스크 파일의 특정 물리 블록을 RAM에 읽어와 자동으로 캐시한다.

💡mmap 기술이란?

mmap(Memory Mapping)은 로컬 디스크의 파일을 프로세스의 가상 메모리 주소 공간에 직접 매핑하는 운영체제 시스템 콜이다.
기존의 read(), write() 시스템 콜 호출과 유저-커널 공간 간의 메모리 복사 단계를 거치지 않고, C/Java 등 애플리케이션 메모리 배열에 직접 접근하듯 디스크 I/O를 고속 처리할 수 다.


Append-only 파일 연산의 속도 극대화 원리

추가만 가능한(Append-only) 파일에 이루어지는 데이터 연산의 특성상, 최근 처리되는 모든 활성 데이터(Recent Command & Event)는 방금 쓰여진 것으로 OS 페이지 캐시(RAM)에 거의 100% 상주하게 된다.

따라서 디스크 물리 디바이스까지 직접 가리키지 않고 RAM 메모리 캐시 영역에서 입출력이 마무리되므로 In-memory 저장소에 준하는 초고속 연산 처리 속도를 달성할 수 있다.


5.1.2. RocksDB 기반 파일 State 저장소

상태(State, 계정 잔액) 데이터 역시 외부 DB 대신 로컬 디스크의 임베디드 키-값 저장소인 RocksDB를 도입하여 처리한다.

RocksDB는 쓰기 연산에 특화된 LSM-Tree(Log-Structured Merge-tree) 구조를 사용하며, 자주 접근하는 최신 상태 데이터를 메모리 캐시(MemTable/Block Cache)에 유지하여 읽기 및 쓰기 처리량을 대폭 끌어올린다.

아래는 Command, Event, State 저장에 파일 기반 솔루션을 적용한 아키텍처이다.

Command, Event 및 State 저장을 위한 파일 기반 아키텍처


5.1.3. 스냅숏을 통한 재현 속도 향상

이벤트 로그가 수억 건 쌓이면, 장애 복구나 시스템 재시작 시 최초 이벤트부터 전체를 Replay하는 데 긴 시간이 소요된다.
이를 방지하기 위해 특정 시점의 잔액 상태를 고정 기록하는 스냅숏을 주기적으로 생성한다.

스냅숏

스냅숏이 존재하면 State Machine은 첫 이벤트부터 계산할 필요 없이 가장 최근 스냅숏을 로드한 뒤, 스냅숏 생성 이후에 발생한 이벤트만 읽어와 상태를 고속 복원한다.
완성된 binary 스냅숏 파일은 HDFS나 S3 같은 분산 객체 저장소로 아카이빙한다.


5.2. 신뢰성 분석 및 Raft 합의 알고리즘

단일 서버의 로컬 디스크에만 데이터를 저장하는 구조는 노드 물리 장애 시 데이터가 손실되는 단일 장애 지점(SPOF) 문제가 발생한다.


5.2.1. 데이터 유형별 신뢰성 평가

시스템 내 4가지 데이터 유형의 신뢰성 가치를 평가한다.

  • 상태(State) & 스냅숏
    • 이벤트 로그만 존재하면 언제든 다시 연산하여 만들 수 있는 유도 데이터이다.
  • Command
    • 외부 사용자의 요청 정보이지만, 이벤트를 만드는 과정에 난수/시간 등 무작위 요소가 포함될 수 있으므로 Command 파일만으로는 이벤트 재현성을 보장하지 못한다.
  • Event
    • 상태 변경의 근원이자 불변의 사실이다.
    • 이벤트 데이터를 손실하면 잔액 상태 복원이 불가능하므로, 시스템에서 높은 신뢰성을 보장해야 하는 핵심 데이터이다.

💡Command 와 Event 데이터 신뢰성은 어떻게 보장할까?

  • Command
    • 분산 데이터 복제가 아닌 클라이언트 재시도 메커니즘으로 보장
    • Command가 노드로 전달되는 도중 서버가 다운되면, 클라이언트는 타임아웃을 감지하고 동일한 멱등성 ID를 부여하여 새로 선출된 리더 노드에 Command를 다시 전달한다.
  • Event
    • 한 번 생성된 Event는 싯템의 유일한 불변 기록이므로 복제 알고리즘(Raft)를 통해 손실을 막아야 한다.

5.2.2. Raft 알고리즘 기반 고신뢰성 복제

Event 로그의 내구성을 확보하기 위해, 다중 노드 간에 이벤트 추가 전용(Append-only) 리스트를 완벽히 동기화하는 Raft 합의 알고리즘을 도입한다.

Raft

Raft 클러스터는 노드 과반수(Quorum) 이상이 작동하면 전체 데이터 일관성을 보장한다. (예: 5개 노드 중 3개 노드 이상 정상 작동 시 서비스 유지)

  • Leader
    • 외부 Command를 수신하고, 이벤트를 생성하여 팔로어 노드들에게 안정적으로 동기화 복제
  • Follower
    • 리더로부터 전달받은 이벤트를 자신의 로컬 이벤트 파일ㄹ에 복제 기록

5.2.3. 이벤트 소싱 노드 그룹 구성 및 장애 처리

복제 메커니즘을 활용하면 파일 기반 이벤트 소싱 아키텍처에서 단일 장애 지점(SPOF) 문제를 없앨 수 있다.
아래는 Raft 합의 알고리즘 기반의 3개 이벤트 소싱 노드 클러스터가 동작하고 데이터를 동기화하는 구체적인 방식이다.

Raft 노드 그룹

각 컴포넌트는 아래와 같다.

  • Raft 노드 그룹(중앙)
    • 1대의 리더 노드(파란색)와 팔로어 노드들로 구성된 복제 단위
  • 쓰기 영역
    • Command 수신 → 유효성 검사 및 Event 변환 → Raft 이벤트 복제 → 로컬 RocksDB State 반영 과정
  • 읽기 영역
    • CQRS 파이프라인에 따라 이벤트를 비동기로 전달받아 질의 전용 Read DB를 갱신하고 외부 조회를 처리

위 그림에는 총 3개의 State Machine(=이벤트 소싱 노드) 이 있다.

  • Command → Event 상태 기계(리더 전용)
    • Command의 유효성을 검증하고 Event를 생성
  • Event → State 상태 기계(모든 Raft 노드)
    • 확정된 Event를 개별 노드의 로컬 RocksDB 잔액 상태에 반영
  • CQRS 읽기 상태 기게(Read Path)
    • Event를 읽어 외부 질의용 DB 뷰를 업데이트

노드 간 데이터 동기화 흐름

  • Command 수신 및 Event 변환
    • 외부 클라이언트로부터 들어오는 모든 이체 명령(Command)은 오직 Leader 노드가 수신한다.
    • 리더는 Command의 유효성을 검증한 후 Event로 변환하여 자신의 로컬 이벤트 파일에 먼저 Append 한다.
  • 팔로어 복제
    • 리더는 Raft 알고리즘을 사용하여 새로 생성된 이벤트를 모든 팔로어 노드에 복제 전송한다.
  • State Update
    • 팔로어를 포함한 모든 노드를 동일하게 복제된 이벤트 목록을 순차적으로 읽어 자신의 로컬 잔액 상태(State)를 업데이트한다.
  • 일관성 보장
    • Raft 알고리즘이 리더와 팔로어 간에 완전히 동일한 이벤트 목록을 유지하도록 보장하며, 이벤트 소싱 엔진은 동일한 이벤트 목록으로부터 항상 동일한 잔액 상태를 결정론적으로 만들어낸다.
      • Raft 알고리즘을 통해 리더와 팔로어 노드는 100% 동일한 순서의 이벤트 파일을 보유한다.
      • 각 노드의 상태 기계는 외부 I/O나 난수 없이 100% 결정론적으로 이벤트를 연산하여 잔액에 반영하므로, 모든 노드의 로컬 RocksDB 잔액 상태는 완벽히 일치하게 된다.

노드 장애 발생 시 대처 메커니즘

안정적인 금융 시스템은 일부 노드가 고장 나더라도 서비스가 끊기지 않고 장애를 원활히 수습할 수 있어야 한다.

1) 리더 노드 장애

  • 새 리더 선출
    • 리더 노드가 다운되면 Raft 알고리즘은 살아있는 나머지 정상 팔로어 노드들 중에서 자격 조건을 갖춘 새로운 리더를 즉시 선출한다.
  • 서비스 연속성
    • 새 리더가 외부 사용자 Command를 수신하는 책임을 이어받으므로, 1대 노드에 장애가 생기더라도 클러스터 전체 서비스는 중단 없이 지속된다.
  • Command 변환 전 장애 시 클라이언트 재시도
    • 리더 노드 장애가 Command 를 Event로 변환하여 Raft에 복제하기 전에 발생한 경우, 해당 Command는 클러스터에 반영되지 못한다.
    • 이 때 클라이언트는 응답 타임아웃 또는 연결 오류 응답을 받게 되며, 클라이언트는 동일한 멱등성 ID를 보존하여 새로 선출된 리더에게 해당 Command를 다시 Retry함으로써 정상 처리한다.

2) 팔로어 노드 장애

  • 요청 실패 및 자동 재시도
    • 팔로어 노드에 장애가 생기면 해당 팔로어로 전송되는 Raft 이벤트 복제 요청이 실패한다.
  • 무한 재시도
    • Raft 리더는 죽은 팔로어 노드가 다시 시작되어 복구되거나, 새로운 대체 노드로 교체될 때까지 기한 없이 복제 요청을 계속 재시도한다.
    • 노드가 복구되면 낙후되었던 이벤트 로그를 순차적으로 전송받아 빠르게 최신 상태로 복구된다.

5.3. 분산 이벤트 소싱 확장 및 실시간 응답 개선

앞서 구축한 파일 기반 고성능 이벤트 소싱과 Raft 합의 알고리즘 복제 아키텍처는 서버 노드 장애에 대한 신뢰성 문제는 완벽히 해결한다.
하지만 대규모 트래픽 환경에서는 아래와 같은 또 다른 한계에 직면하게 된다.

  • 실시간 응답 요구와 CQRS 응답 지연 문제
    • 클라이언트는 디지털 지갑 잔액 이체 연산의 결과를 즉시 확인하기를 원한다.
    • 그러나 기본 CQRS 시스템에서는 Command 처리와 Query가 비동기로 분리되어 있어 요청/응답 흐름이 느려질 수 있다.
  • 단일 Raft 그룹의 용량 한계
    • 단일 Raft 클러스터의 쓰기 처리량(TPS)은 리더 노드 1대의 CPU, 디스크 I/O, 네트워크 대역폭 성능을 초과할 수 없다.
    • 따라서 백만 TPS 이상의 스케일을 수용하려면 데이터베이스를 샤딩하고, 샤드 간 분산 트랜잭션을 구현해야 한다.

💡CQRS 시스템에서는 요청/응답 흐름이 왜 느린걸까?

CQRS와 이벤트 소싱 아키텍처의 비동기 처리 구조클라이언트의 통신 방식(Pull/Polling) 때문에 지연이 발생한다.

  • 비동기 파이프라인 지연(궁극적 일관성)
    • 전통적인 RDB 시스템은 DB에 잔액을 직접 UPDATE하고 그 결과를 클라이언트에 즉시 응답(동기식)한다.
    • 반면 CQRS 시스템에서는 다음 파이프라인을 비동기로 거친다.
      • Command 수신 → Event 생성 및 Raft 복제 → Event 큐 전송 → Read DB 프로젝션
    • 이 과정에서 궁극적 일관성에 따른 미세한 시차(Lag)가 발생하므로, 쓰기가 끝나자마자 Read DB를 조회하면 아직 변경된 잔액이 반영되지 않았을 수 있다.
  • 주기적 폴링(Polling)에 의한 지연
    • 클라이언트는 자신이 보낸 이체 연산이 읽기 DB에 언제 최종 반영되었는지 정확한 시점을 알 수 없다.
    • 따라서 클라이언트는 읽기 DB에 이체가 완료되었는지에 대한 질의를 일정 주기(예: 1초)마다 반복해서 물어보는 주기적 폴링(Pull 방식)에 의존해야 한다.
    • 클라이언트가 이체 성공 여부를 확인하는 시점에 폴링 주기에 매여 있기 되므로 전체적인 응답 속도가 느리게 체감된다.

이러한 이유로 뒤이어 나오는 해결책으로는 역방향 프록시를 도입하여 읽기 전용 상태 기계가 결과를 즉시 클라이언트로 밀어주는 Push 통신 모델로 전환하여 지연 문제를 해결한다.


5.3.1. Pull vs Push 통신 모델(역방향 프록시 활용)

CQRS 모델에서 쓰기 연산과 읽기 연산은 비동기로 분리되므로, 클라이언트가 자신의 잔액 반영 여부를 즉시 확인하기 어렵다.
따라서 클라이언트가 자신이 보낸 이체 연산의 최종 반영 상태를 어떻게 조회할 것인지가 중요한 과제이다.


순수 Pull 모델과 한계

Pull 모델

  • 동작 원리
    • 외부 클라이언트가 읽기 경로의 읽기 전용 상태 기계에 주기적으로 질의(Polling)하여 잔액 갱신 여부를 직접 확인
  • 한계
    • 클라이언트가 폴링 주기를 너무 단축하면 지갑 서비스에 과부하가 발생하고, 주기가 길어지면 실시간으로 잔액 변경 결과를 확인할 수 없음

역방향 프록시를 도입한 개선된 Pull 모델

순수 Pull 모델의 클라이언트 복잡도를 줄이기 위해 클라이언트와 이벤트 소싱 노드 사이에 역방향 프록시(Reverse Proxy)를 추가하여 통신 구조를 개선한다.

역방향 Proxy가 있는 Pull 모델

  • 동작 원리
    • 외부 클라이언트는 역방향 프록시로 이체 명령(Command)을 전송
    • 역방향 프록시는 이 명령을 백엔드 이벤트 소싱 노드로 전달
    • 역방향 프록시가 클라이언트를 대신해 내부적으로 이벤트 소싱 노드에 주기적으로 실행 상태를 질의함
  • 장점 및 한계
    • 클라이언트는 복잡한 폴링 로직을 직접 구현할 필요가 없어지므로 클라이언트 코드가 단순해짐
    • 하지만 역방향 프록시가 내부적으로 폴링을 수행하는 구조이므로 통신이 완전히 실시간으로 이루어지지는 암ㅎ음

💡프록시 vs 역방향 프록시(Reverse Proxy)

프록시는 클라이언트 단에 위치하여 서버로 향하는 요청을 중계하는 항편, 역방향 프록시는 서버 단에 위치하여 클라이언트로 들어오는 요청을 받아 내부 서버로 전달한다.

구분순방향 프록시 (Forward Proxy)역방향 프록시 (Reverse Proxy)
위치클라이언트와 인터넷 사이인터넷과 내부 웹 서버 사이
보호 대상클라이언트 (사용자 IP 및 신원 감춤)서버 (내부 네트워크 구조 및 IP 감춤)
주요 목적클라이언트 보안, 접근 제한 우회, 캐싱로드 밸런싱, 서버 보안, SSL 암호화 처리
대표 예시기업 내 웹 필터링, VPN, 우회 접속 서비스Nginx, HAProxy, Cloudflare
  • Forward Proxy
    • 익명성 보장
      • 클라이언트의 실제 IP 주소를 숨기고 프록시 서버의 IP로 요청을 보냄
    • 접근 제어 및 필터링
      • 기업이나 학교에서 차단된 외부 사이트 접근을 막거나 통제할 때 사용
    • 클라이언트 캐싱
      • 자주 요청하는 외부 웹 페이지 데이터를 저장해두어 클라이언트의 대역폭을 절약하고 속도를 높임
  • Reverse Proxy
    • 서버 보안 강화
      • DB나 백엔드 서버의 IP를 외부에 노출하지 않아 DDoS 공격 및 외부 해킹 방지
    • 부하 분산(Load Balancing)
      • 하나의 대표 주소로 들어오는 대량의 트래픽을 여러 대의 내부 서버로 나누어 전달
    • SSL 부하 경감
      • 클라이언트와의 SSL/TLS 암호화/복호화 통신을 프록시 수준에서 일괄 처리하여 실제 애플리케이션 서버의 연산 부담을 줄임

Push 기반 모델: 실시간 응답 속도 극대화

역방향 프록시가 존재하는 구조에서는 읽기 전용 상태 기계의 커스텀 로직을 활용하여 응답 속도를 극도로 높일 수 있다.

읽기 전용 상태 기계는 단순히 조회용 DB 뷰를 업데이트하는 역할 외에도 자신만의 교유한 이벤트 처리 로직을 탑재할 수 있다.

Push 모델

위 그림에서 읽기 영역에 있는 톱니바퀴가 읽기 전용 상태 기계이다.

  • 동작 원리
    • 클라이언트는 역방향 프록시에 Command를 보낸 후, 프록시와의 응답 커넥션(Long-polling, WebSocket, gRPC Streaming 등)을 유지한 채 대기한다.
    • 이벤트 소싱 엔진이 이벤트를 확정하고 발행하면, 읽기 전용 상태 기계가 이벤트를 수신하자마자 실행 상태를 역방향 프록시에 즉시 Push한다.
    • 역방향 프록시는 대기 중이던 플라이언트 연결로 푸시받은 최종 결과를 즉시 전달한다.
  • 결과
    • 외부 클라이언트는 비동기 CQRS 시스템임에도 불구하고 마치 동기식 API 호출을 한 것처럼 실시간으로 이체 결과를 응답받는 느낌을 얻게 된다.

5.3.2. 사가(Saga) 조정자를 적용한 최종 분산 이벤트 소싱 이체 흐름

모든 이벤트 소싱 노드 그룹이 동기적 실행 모델을 채택하면, 앞서 살펴본 TC/C나 사가(Saga)와 같은 분산 트랜잭션 패턴을 수정 없이 그대로 재사용할 수 있다.


💡비동기 이벤트 소싱 아키텍처인데 왜 동기적 실행 모델이라고 하고, 또 어느 부분에서 동기적으로 작동하는 걸까?

이벤트 소싱 노드 내부(쓰기 → 읽기 파이프라인)는 비동기로 동작하지만, 사가 조정자가 파티션들을 제어하는 트랜잭션 조율 방식이 동기적으로 진행된다.

  • 단계별 동기적 대기
    • 사가 조정자는 파티션 1에 A: -$1 Command를 보낸 후, 파티션 1의 읽기 경로로부터 성공 푸시 응답을 받을 때까지 멈춰서 대기한다.
    • 파티션 1 연산이 확정 완료되어 응답이 돌아온 후에야 비로소 다음 단계인 파티션 2에 C: +$1 Command를 전송한다.
  • 클라이언트 동기 커넥션 유지
    • 최전방 역방향 프록시 역시 클라이언트의 HTTP/gRPC 요청 커넥션을 끊지 않고 유지하고 있다가, 사가 조정자의 모든 단계가 최종 완료되는 순간 응답을 돌려준다.

즉, ‘이전 단계의 파티션 연산 완결을 확인한 뒤 다음 파티션 연산을 순차적으로 실행한다’는 점에서 사가 조정자 관점의 동기적 실행 모델이 성립하며, 이를 통해 기존 분산 트랜잭션 알고리즘을 그대로 적용할 수 있게 된다.


파티셔닝(샤딩) 및 분산 연산 가정

계정 데이터를 여러 파티션에 분산하기 위해 계정 키의 해시값을 나눈 나머지(Hash(Key) % 2)를 이용하여 데이터 저장 파티션을 결정한다고 가정한다.

  • 파티션 1: 계정 A 저장(Hash(A) % 2 = 1)
  • 파티션 2: 계정 C 저장(Hash(C) % 2 = 0)

이 구조에서 계정 A에서 계정 C로 1달러를 송금하는 단일 이체 작업은 서로 다른 파티션에 위치한 2개의 분산 연산으로 분할되어 실행된다.

  • 파티션 1 연산: 계정 A 잔액 차감(A: -$1)
  • 파티션 2 연산: 계정 C 잔액 가산(C: +$1)

사가 조정자에 의한 최종 이체 연산 흐름

이해를 돕기 위해 사가 분산 트랜잭션 모델에서 롤백(보상 트랜잭션) 없이 정상적으로 이체가 완료되는 해피 패스(Happy Path) 경로를 살펴보자.

송금 연산에는 2개의 분산 연산이 필요하다.

  • 파티션 1 연산: 계정 A 잔액 차감(A: -$1)
  • 파티션 2 연산: 계정 C 잔액 가산(C: +$1)

사가 조정자는 아래 그림과 같이 실행을 조율한다.

최종 설계

  • ① 분산 트랜잭션 수신
    • 사용자 A가 사가 조정자에게 분산 트랜잭션 요청을 보낸다.
    • 포함 연산: A: -$1, C: +$1
  • ② 상태 테이블 기록
    • 사가 조정자는 단계별 상태 테이블에 레코드를 생성하여 트랜잭션 진행 상태를 기록한다.
  • ③ 첫 번째 명령 전송(A: -$1)
    • 사가 조정자는 작업 순서를 검토한 후 출금 연산을 먼저 처리하기로 결정하고, 계정 A가 위치한 파티션 1A: -$1 Command를 전송한다.
  • ④ 파티션 1 이벤트 확정 및 로컬 반영
    • 파티션 1의 Raft 리더가 A: -$1 Command를 받아 유효성을 검증한 뒤 Event로 변환한다.
    • Raft 합의 알고리즘에 의해 팔로어 노드들로 이벤트를 복제 동기화하고, 동기화가 완료되면 로컬 RocksDB 잔액에서 1달러를 차감한다.
  • ⑤ 파티션 1 CQRS 동기화
    • 이벤트 동기화가 끝나면 파티션 1의 이벤트 소싱 프레임워크가 CQRS를 통해 읽기 경로(Read Path)로 데이터를 동기화한다.
  • ⑥ 파티션 1 프록시 푸시
    • 파티션 1의 읽기 경로는 처리 완료 상태를 호출 주체인 역방향 프록시에 실시간 Push 한다.
  • ⑦ 조정자 성공 응답 수신
    • 사가 조정자는 역방향 프록시를 통해 파티션 1 연산이 성공했음을 수신한다.
  • ⑧ 상태 테이블 성공 기록
    • 사가 조정자는 단계별 상태 테이블에 파티션 1 작업이 성공했음을 기록한다.
  • ⑨ 두 번째 명령 전송(C: +$1)
    • 첫 번째 작업이 성공했으므로 사가 조정자는 두 번째 작업인 C: +$1 Command를 계정 C가 위치한 파티션 2로 전송한다.
  • ⑩ 파티션 2 이벤트 확정 및 로컬 반영
    • 파티션 2의Raft 리더가 C: +$1 Command를 받아 유효성을 검증한 뒤 Event로 변환한다.
    • Raft 합의 알고리즘에 의해 팔로어 노드들로 이벤트를 복제 동기화하고, 동기화가 완료되면 로컬 RocksDB 잔액에서 1달러를 가산한다.
  • ⑪ 파티션 2 CQRS 동기화
    • 이벤트 동기화가 끝나면 파티션 2의 이벤트 소싱 프레임워크가 CQRS를 통해 읽기 경로(Read Path)로 데이터를 동기화한다.
  • ⑫ 파티션 2 프록시 푸시
    • 파티션 2의 읽기 경로는 처리 완료 상태를 호출 주체인 역방향 프록시에 실시간 Push 한다.
  • ⑬ 조정자 성공 응답 수신
    • 사가 조정자는 역방향 프록시를 통해 파티션 2 연산이 성공했음을 수신한다.
  • ⑭ 상태 테이블 최종 완료
    • 사가 조정자는 단계별 상태 테이블에 파티션 2 작업이 성공했음을 기록한다.
  • ⑮ 클라이언트 최종 응답
    • 모든 분산 연산이 성공함에 따라 사가 조정자가 최초 호출자(클라이언트)에게 완료 결과를 응답한다.

6. 마무리

전자 지갑 서비스 아키텍처는 데이터의 단순 저장(In-Memory)에서 출발하여 분산 트랜잭션(2PC/TCC/Saga)을 거쳐, 최종적으로 고성능·고신뢰성 분산 이벤트 소싱(Event Sourcing + CQRS + Raft) 단계로 진화하였다.


1단계: 요구사항 정의 및 문제 범위 확정

  • 기능 요구사항: 지갑 간 자금 이체 연산 (POST /v1/wallet/balance_transfer)
  • 비기능 요구사항:
    • 1,000,000 TPS 초고속 처리량 수용
    • 99.99% 고가용성 보장
    • 데이터베이스 수준의 ACID 트랜잭션 보증
    • 과거 특정 시점의 잔액을 언제든 100% 복원 가능한 재현성(Reproducibility) 지원

2단계: 개략적 설계 및 분산 트랜잭션 탐색

  • 인메모리 샤딩(Redis + ZooKeeper)
    • 빠른 성능을 제공하지만, 이종 노드 간 원자적 트랜잭션이 불가능하여 출금 성공 후 입금 실패 시 데이터가 파괴되는 한계 노출
  • RDB 샤딩 & 2PC
    • DB 레벨 원자성은 확보되나, 락(Lock) 점유에 따른 성능 저하와 조정자 SPOF 문제 발생
  • TC/C 및 Saga 패턴
    • 애플리케이션 레벨 보상 트랜잭션 도입
    • 병렬 처리가 가능한 TC/C와 선형적 흐름에 적합한 Saga 패턴을 통해 분산 트랜잭션 문제 완화
  • 이벤트 소싱 도입
    • 잔액(최종 상태) 대신 변경 불가능한 이벤트(Fact) 로그를 순차 저장하여 감사 및 완벽한 과거 데이터 재현성 확보

3단계: 고성능·고신뢰성 분산 이벤트 소싱 상세 설계

  • 고성능 로컬 파일 I/O
    • mmap 메모리 매핑과 Append-only 파일 구조, RocksDB(LSM-Tree), 스냅숏(Snapshot)을 결합하여 디스크 I/O 및 네트워크 지연 최소화
  • Raft 합의 알고리즘
    • 이벤트 로그를 다중 노드 간 과반수(Quorum) 복제하여 단일 장애 지점(SPOF) 제거 및 데이터 내구성 확보
  • Push 기반 CQRS
    • 읽기 전용 상태 기계와 역방향 프록시(Reverse Proxy)를 연결하여 비동기 CQRS 구조에서도 실시간 응답 환경 구현
  • 분산 사가 조정자
    • 파티셔닝된 Raft 노드 그룹 간에 사가(Saga) 오케스트레이터를 배치하여 100만 TPS 스케일의 분산 트랜잭션 완결

6.1. 전자 지갑 아키텍처 단계별 솔루션 비교

설계 단계도입 기술 및 아키텍처주요 장점치명적 한계 및 문제점
초기 모델Redis 샤딩 + ZooKeeper매우 빠른 읽기/쓰기 속도노드 장애 시 데이터 손실 및 원자적 이체 트랜잭션 불가
저수준 분산 DBRDB 샤딩 + 2PCDB 레벨의 강한 원자성 보장장시간 DB 락(Lock) 점유로 인한 TPS 급감, 조정자 SPOF
보상 트랜잭션TC/C & Saga 패턴DB 독립적 애플리케이션 트랜잭션중간 불균형 상태 노출, 과거 상태 감사 및 원인 역추적 불가
단일 이벤트 소싱원격 Kafka + RDB완벽한 재현성 및 감사 가능원격 네트워크 I/O 병목으로 인하여 100만 TPS 수용 불가
최종 분산 모델로컬 File(mmap) + Raft + CQRS Push + Saga100만 TPS 초고속 성능, 무손실 신뢰성, 실시간 응답, 재현성 완성시스템 복잡도 증가 및 상태 기계의 엄격한 결정론 구현 요구

마인드맵


💡 Modern Tech Tip: 2026년 실무 시스템에서의 분산 이벤트 소싱 및 Saga 구현

본 설계에서 다룬 이론적 아키텍처 컴포넌트들은 최근 다음과 같은 차세대 오픈소스 및 Cloud-Native 기술 스택을 통해 실무에 도입되고 있다.

  • Saga 오케스트레이터
    • 직접 커스텀 상태 테이블을 구현하는 대신, Temporal.ioUber Cadence 같은 상태 기반 워크플로 엔진을 활용하여 보상 트랜잭션 및 재시도 로직을 코드 레벨에서 안전하게 선언 및 관리
  • Transactional Outbox & CDC
    • RDB 변경 이벤트를 유실 없이 메시지 브로커로 넘기기 위해 Debezium 기반의 CDC(Change Data Capture) 파이프라인을 표준으로 사용
  • 임베디드 State DB
    • Java/Go/Rust 서비스 내부에서 초고속 로컬 키-값 상태를 유지하기 위해 RocksDBPebble 엔진을 CGO/JNI 바인딩으로 결합하여 사용
  • Raft consensus Engine
    • Raft 알고리즘을 직접 구현하지 않고 HashiCorp Raft (Go), Apache Ratis (Java), 또는 SOFAJRaft와 같은 검증된 분산 합의 라이브러리를 애플리케이션 내부 엔진으로 내장

참고 사이트 & 함께 보면 좋은 사이트

본 포스트는 알렉스 쉬, 산 람 저자의 가상 면접 사례로 배우는 대규모 시스템 설계 기초 2를 기반으로 스터디하며 정리한 내용들입니다.






© 2020.08. by assu10

Powered by assu10