1. 서론: 100만 TPS 이체 엔진(Transfer Engine)의 도전

우리가 설계하려는 시스템은 단순한 포인트 조회용 앱이 아니다. 이것은 수천만 명의 사용자가 동시에 서로에게 자금을 전송하는, 초고성능 분산 원장(Distributed Ledger) 시스템이다.

 

우리의 목표는 명확하고도 가혹하다. 입금이나 출금 같은 단일 상태 변경을 넘어, '사용자 A의 계좌에서 차감하고 사용자 B의 계좌에 증액한다'는 이체(Transfer) 트랜잭션을 초당 1,000,000건(1M TPS) 처리하는 것이다. 이는 기존 금융권 코어 뱅킹(Core Banking) 시스템의 성능을 수백 배 상회하는 수치이며, 다음과 같은 극한의 제약 조건들을 아키텍처 레벨에서 정면으로 돌파해야만 달성 가능하다.

 

1.1. 이체(Transfer)의 본질적 모순과 제약 조건의 역설

이체는 본질적으로 분산된 두 상태(State)의 동기화를 요구한다. A와 B가 서로 다른 물리적 서버(샤드)에 존재할 때, 우리는 '관계형 데이터베이스(RDBMS) 수준의 강력한 데이터 정합성'을 유지하면서도 '99.99%의 고가용성'을 보장해야 한다. CAP 이론에 따르면 이 두 가지는 양립할 수 없다. 하지만 금융 거래에서 정합성을 포기하는 것은 금전 사고를 의미하며, 가용성을 포기하는 것은 서비스 중단을 의미한다. 우리는 분산 시스템의 물리적 한계를 인정하되, 이 모순을 기술적으로 우회하여 타협 없는 성능을 이끌어내야 한다.

 

1.2. 사후 대사(Reconciliation) 없는 즉시 완결성 (Reconciliation-Free)

기존의 대규모 금융 시스템은 실시간 성능의 한계로 인해, 주간에는 빠르게 처리하고 야간 배치(Batch)를 통해 틀어진 잔액을 맞추는 사후 대사(Reconciliation) 방식에 의존하곤 했다. 하지만 본 시스템은 이를 거부한다. 모든 이체는 발생 즉시 수학적으로 완결되어야 한다. "나중에 맞추겠다"는 안일한 타협은 100만 TPS 환경에서 수습 불가능한 데이터 불일치(Data Drift)를 낳는다. 시스템 내부의 모든 잔액은 언제 조회하더라도 참(Truth)이어야 한다.

 

1.3. 100% 재현성(Reproducibility)과 결정론적 시스템

이체 과정에서 자금이 증발하거나 복사되는 버그는 용납될 수 없다. 만약 문제가 발생한다면, 단순히 로그 텍스트를 분석하여 추측하는 수준을 넘어서야 한다. 우리는 특정 시점의 상태를 완벽하게 복원하고, 과거의 이체 요청들을 순서대로 다시 주입했을 때 현재와 정확히 동일한 결과가 도출되는 '결정론적(Deterministic) 시스템'을 구축해야 한다. 이것은 단순한 로깅을 넘어, 시스템의 신뢰성을 증명할 수 있는 유일한 수단이자 아키텍처의 근본 철학이다.

 


2. 아키텍처 진화: 왜 전통적인 DB와 Redis 클러스터조차 실패하는가?

100만 TPS라는 목표는 단순한 수치상의 목표가 아니라, 저장소의 물리적 한계를 시험하는 기준점이다. 우리는 범용적인 관계형 데이터베이스(RDBMS)를 넘어, 인메모리 솔루션인 Redis까지 검토해야 한다. 하지만 냉정한 분석 결과, 이들 모두 우리의 요구사항인 '데이터 무결성''초고성능'을 동시에 만족시키지 못한다.

 

2.1. RDBMS의 물리적 한계: 3,000 TPS의 벽

가장 신뢰할 수 있는 RDBMS조차 디스크 I/O라는 물리적 제약에 묶여 있다. B-Tree 인덱스 갱신, WAL(Write Ahead Log) 기록, 그리고 무엇보다 Row Level Lock 경합은 단일 노드의 처리량을 3,000 ~ 5,000 TPS 수준으로 제한한다. 100만 TPS를 달성하려면 이론적으로 200~300대의 고성능 DB 샤드가 필요하다. 이는 관리 비용의 폭증은 물론, 분산 트랜잭션의 복잡도로 인해 현실적으로 불가능한 시나리오다.

 

2.2. 인메모리(In-Memory)의 필요성과 Redis의 등장

디스크가 느리다면 답은 메모리다. 메모리는 디스크보다 수만 배 빠르며, 100만 TPS를 받아낼 유일한 하드웨어다. 업계 표준인 Redis가 자연스럽게 대안으로 떠오른다. Redis는 단일 스레드로 동작하며 메모리에서 직접 데이터를 조작하므로, 노드당 수만~십만 TPS를 가볍게 처리한다.

 

2.3. 제1원칙(First Principles) 사고: Redis 클러스터로 100만 TPS가 가능한가?

그렇다면 Redis 클러스터를 구축하여 샤딩(Sharding)하면 해결될까? 비용과 예산의 관점에서 제1원칙적으로 따져보자.

  • 산술적 계산: 최적화된 Redis 노드 하나가 약 80,000 TPS를 처리한다고 가정하자 (Write 기준). 1,000,000 TPS를 처리하기 위해서는 약 12~15개의 마스터 노드가 필요하다.
  • 비용 분석: 15대의 마스터 노드와 고가용성을 위한 복제 노드(Replica)를 포함해도 30~50대 수준이다. 이는 예산 범위 내에서 충분히 감당 가능한 하드웨어 규모다.
  • 결론: 표면적으로만 보면, Redis 클러스터 샤딩은 100만 TPS를 달성하기 위한 가장 합리적이고 경제적인 해결책처럼 보인다. 하지만 여기에는 '데이터 유실'이라는 치명적인 함정이 숨어 있다.

 

2.4. 성능과 안정성의 트레이드오프: 1초의 데이터 유실

금융 시스템에서 데이터 유실은 절대 용납되지 않는다. Redis는 인메모리 기반이므로 전원이 꺼지면 데이터가 날아간다. 이를 막기 위해 AOF(Append Only File)와 RDB(Snapshot)를 사용해야 한다.

  • 현실적인 설정: 성능 저하를 막기 위해 보통 AOF fsync 옵션을 everysec(1초마다 디스크 기록)으로 설정한다.
  • 재앙적 시나리오: 만약 노드가 크래시(Crash)되면, 최대 1초 분량의 데이터가 유실된다. 100만 TPS 시스템에서 1초의 유실은 곧 100만 건의 금융 거래 증발을 의미한다. 이는 단순한 장애가 아니라 기업의 존폐가 걸린 재앙이다.

 

2.5. 동기식 복제(Synchronous Replication)와 I/O 패킷 폭풍

데이터 유실을 완벽히 막기 위해 선택할 수 있는 유일한 방법은 Redis의 WAIT 명령어를 활용한 동기식 복제다. 마스터에 쓴 데이터가 복제 노드(Replica)에 기록되었다는 확인(ACK)을 받아야만 클라이언트에게 성공을 응답하는 방식이다. 하지만 이 방식은 100만 TPS 환경에서 시스템을 붕괴시킨다.

  • 네트워크 지연의 누적: 메모리 연산은 1µs(마이크로초)면 끝나지만, 네트워크 왕복(Round Trip)은 최소 100~500µs가 소요된다. 배보다 배꼽이 더 크다.
  • I/O 패킷 폭풍 (Packet Storm): 100만 건의 쓰기 요청은 곧 100만 건의 복제 요청과 100만 건의 ACK 응답을 의미한다. 초당 수백만 개의 패킷이 네트워크 스위치를 강타한다.
  • 지연 시간 스파이크 (Latency Spike): 평균 처리량(Throughput)은 100만 TPS를 찍을지 몰라도, 폭발적인 패킷량으로 인해 네트워크 큐가 꽉 차면서 간헐적인 지연(Jitter)이 발생한다. 이로 인해 상위 1%의 요청(99th Percentile)은 수백 밀리초 이상의 지연을 겪게 되며, "99% 가용성과 안정적인 응답 속도"라는 목표는 처참히 깨진다.

 

2.6. 패러다임 시프트: 데이터 지역성(Data Locality)의 극대화

결국, 데이터베이스가 애플리케이션 외부에 존재하는 한 100만 TPS는 요원하다. 우리는 데이터베이스를 애플리케이션 내부(Local)로 가져와야 한다.

  • In-Process Memory: 네트워크를 타지 않고, 애플리케이션의 힙(Heap) 메모리나 로컬 임베디드 DB(RocksDB)에 직접 접근해야 한다.
  • Zero-Copy: 데이터 이동을 없애고, 메모리 주소 접근만으로 잔액을 연산한다면 10ns 이내의 처리가 가능하다.

3.  핵심 설계: Kafka와 인메모리 상태 머신 (The State Machine)

우리는 네트워크 지연과 I/O 병목을 제거하기 위해, 데이터베이스를 애플리케이션 외부가 아닌 내부에 두는 급진적인 아키텍처를 채택한다. 이 구조에서 Kafka는 단순한 메시지 큐가 아니라 '거대한 분산 로그 데이터베이스'이며, 애플리케이션(Kafka Streams)은 데이터를 처리하고 저장하는 '스토리지 겸 컴퓨팅 엔진'이 된다.

 

3.1. 입력 계층: I/O 패킷 폭풍의 차단과 제로 카피(Zero-Copy)

Redis 클러스터가 실패한 원인은 요청마다 발생하는 동기식 네트워크 통신 때문이었다. Kafka는 비동기 배치 전송OS 레벨의 최적화를 통해 이 문제를 근본적으로 해결한다.

  • 비동기 배치 전송 (Asynchronous Batching):
    • API 서버(Producer)는 이체 요청을 하나씩 보내지 않는다. 메모리 버퍼에 요청을 모았다가, 수천 건의 거래를 단 하나의 패킷(Batch)으로 압축하여 전송한다.
    • 효과: Redis가 100만 번의 시스템 콜(System Call)과 패킷 전송을 일으킬 때, Kafka는 이를 수백 번 수준으로 압축한다. 이는 네트워크 오버헤드와 CPU 인터럽트를 획기적으로 줄여 '패킷 폭풍'을 원천 봉쇄한다.
  • 페이지 캐시와 제로 카피 (Page Cache & Zero-Copy):
    • Kafka 브로커는 데이터를 힙 메모리가 아닌 커널 영역의 페이지 캐시(Page Cache)에 직접 저장한다.
    • 컨슈머가 데이터를 읽어갈 때, 브로커는 데이터를 유저 영역(User Space)으로 복사하지 않고 sendfile() 시스템 콜을 통해 디스크(페이지 캐시)에서 NIC(네트워크 카드)로 데이터를 바로 쏘아 보낸다. 이것이 Zero-Copy 기술이며, 이를 통해 네트워크 대역폭 한계치에 근접한 전송 속도를 달성한다.

 

3.2. 처리 계층: Kafka Streams + RocksDB (임베디드 엔진)

데이터 처리는 외부 DB와의 통신 없이, 애플리케이션 내부에서 완결된다. 이를 위해 Kafka Streams 라이브러리와 내장된 RocksDB를 결합한다.

  • RocksDB as a State Store:
    • RocksDB는 단순한 캐시가 아니다. 애플리케이션 인스턴스 내부(Local Disk/SSD)에 상주하는 고성능 임베디드 K-V 데이터베이스다.
    • LSM-Tree(Log-Structured Merge-Tree) 구조를 사용하여 쓰기 성능에 최적화되어 있으며, 자주 접근하는 데이터는 메모리(Block Cache)에 상주한다.
  • Lock-Free & Wait-Free 아키텍처:
    • Partition-Thread Affinity: Kafka의 파티션은 전담 싱글 스레드에 바인딩된다. 즉, 특정 사용자의 잔액(State)에는 오직 하나의 스레드만 접근한다.
    • No Locks: 따라서 복잡한 멀티 스레드 락(Lock)이나 뮤텍스(Mutex)가 전혀 필요 없다. 컨텍스트 스위칭 비용이 "0"에 수렴하며, CPU는 오직 비즈니스 로직 연산에만 집중한다.

 

3.3. 상세 워크플로우: 거래의 일생 (Life of a Transaction)

100만 TPS가 흐르는 시스템 내부의 구체적인 처리 과정은 다음과 같다.

  1. Ingest (배치 유입):
    • 사용자 A가 B에게 송금 요청. API 서버는 이를 버퍼링 후 Transaction_Topic의 파티션 #1(A의 파티션)로 배치 전송.
  2. Process (메모리 연산):
    • 스트림 프로세서(Stream Thread)가 배치를 읽어 들임(Poll).
    • 로컬 RocksDB(Memory)에서 A의 잔액 조회 (get).
    • 잔액 확인 후 차감 연산 수행 (balance - amount).
    • 갱신된 잔액을 RocksDB에 반영 (put). (이 과정에서 네트워크 통신 없음)
  3. Produce & Commit (원자적 확정):
    • 처리 결과 이벤트(WithdrawnEvent)를 출력 토픽으로 생성.
    • EOS (Exactly-Once Semantics): Kafka Streams는 [상태 저장(RocksDB 업데이트) + 결과 이벤트 발행 + 오프셋 커밋]을 하나의 트랜잭션으로 묶어 원자적으로 처리한다.
  4. Persistence (백그라운드 영속화):
    • RocksDB의 변경 사항(Changelog)은 비동기적으로 Kafka의 Changelog_Topic으로 전송되어 복제된다. 이는 메인 처리 스레드와 분리되어 수행되므로 성능에 영향을 주지 않는다.

 

3.4. 왜 이 구조가 Redis 클러스터보다 빠른가?

  • Redis: Network I/O Bound + Synchronous Replication Latency. (네트워크가 병목)
  • Kafka + RocksDB: Sequential Disk I/O + CPU Bound. (하드웨어 성능을 끝까지 사용)
  • 우리는 랜덤 액세스(Random Access)를 순차 액세스(Sequential Access)로 변환하고, 원격 호출(Remote Call)을 로컬 호출(Local Call)로 전환함으로써 물리적 성능의 한계를 돌파했다.

4. 분산 환경의 딜레마: 파티션의 벽을 넘어서

섹션 3에서 우리는 User_ID 해싱을 통해 특정 유저의 거래를 특정 노드에 가두는 전략(Data Locality)을 취했다. 덕분에 '같은 파티션 내'에서의 거래는 로컬 ACID 트랜잭션으로 완벽하고 빠르게 처리된다.

 

그러나 문제는 '다른 파티션 간'의 거래다. 사용자 A(Partition 1)가 사용자 B(Partition 2)에게 돈을 보내는 순간, 우리는 안전한 메모리 영역을 벗어나 거친 분산 시스템의 바다로 나아가야 한다.

 

4.1. 샤딩(Sharding)의 필연성과 ACID의 종말

100만 TPS를 수용하기 위해 수백 개의 파티션으로 데이터를 쪼개는(Sharding) 순간, 우리가 익숙했던 관계형 데이터베이스의 신화는 깨진다.

  • 원자성(Atomicity)의 물리적 한계:
    • 단일 노드에서는 CPU 명령 몇 줄로 입금과 출금을 원자적으로 처리할 수 있다.
    • 그러나 분산 환경에서 노드 1(출금)과 노드 2(입금)는 물리적으로 분리되어 있다. 노드 1에서 돈을 뺐는데, 그 순간 노드 2가 전원이 꺼져버린다면? 네트워크 단절로 입금 요청이 유실된다면? 전통적인 원자성은 여기서 붕괴한다.
  • CAP 이론에 입각한 선택 (CP vs AP):
    • 네트워크 분단(Partition)은 막을 수 없는 상수다. 우리는 일관성(Consistency)과 가용성(Availability) 중 하나를 택해야 한다.
    • 금융 시스템이라 해서 무조건 일관성(CP)을 택할 수는 없다. 100만 TPS 시스템에서 강한 일관성을 위해 모든 노드를 잠그는 것은 곧 서비스 중단(Availability 포기)을 의미한다. 우리는 가용성(AP)을 선택하고, 일관성을 '시간의 축'으로 넓혀 해결해야 한다.

 

4.2. 강한 일관성(Strong Consistency)에 대한 철학적 고찰

우리는 '즉시성'에 대한 집착을 버려야 한다. 섹션 3에서 네트워크 I/O를 배제했던 그 철학 그대로, 데이터 동기화에서도 물리적 현실을 직시해야 한다.

  • "동시성(Simultaneity)은 환상이다":
    • 상대성 이론에서 관찰자의 위치에 따라 사건의 순서가 다르듯, 분산 시스템에서도 물리적으로 떨어진 두 노드의 상태가 '동시에' 변하는 것은 불가능하다.
    • A의 지갑에서 돈이 빠져나가는 시점(t1)과 B의 지갑에 돈이 채워지는 시점(t2) 사이에는 반드시 지연(Latency)이 존재한다. 이를 억지로 t1 = t2로 맞추려는 노력(Global Lock)은 시스템 전체를 멈추게 하는 자살행위다.
  • PACELC 이론과 타협:
    • 정상 상황(Else)에서도 우리는 일관성(Consistency) 대신 지연 시간 최소화(Latency)를 택한다.
    • 사용자가 원하는 것은 "전 우주의 데이터베이스가 동시에 갱신되는 것"이 아니다. "내가 보낸 돈이 상대방에게 빨리 도착하는 것"이다. 우리는 이를 위해 최종적 일관성(Eventual Consistency)을 수용한다. 즉, t1과 t2 사이의 미세한 시차를 허용하되, 최종적으로는 반드시 일치함을 보장하는 것이다.

 

4.3. 분산 트랜잭션 방법론 비교: 2PC vs TCC vs Saga

이제 남은 과제는 "어떻게 최종적 일관성을 달성할 것인가"이다. 100만 TPS라는 목표 앞에서는 방법론의 선택도 냉정해야 한다.

  • 2PC (Two-Phase Commit) - 즉시 폐기 (Reject):
    • 모든 참여 노드가 준비될 때까지 기다리는(Blocking) 구조다. 섹션 3에서 기껏 싱글 스레드와 Non-blocking I/O로 성능을 끌어올렸는데, 2PC를 쓰는 순간 모든 스레드가 네트워크 응답을 기다리며 멈춰버린다. 이는 고성능 아키텍처의 안티 패턴(Anti-pattern)이다.
  • TCC (Try-Confirm-Cancel) - 보류 (Hold):
    • 자원을 미리 선점(Reservation)하여 데이터 격리성(Isolation)을 확보하는 훌륭한 모델이다.
    • 그러나 TCC는 상태 관리를 위한 중앙 코디네이터를 필요로 한다. Kafka Streams 기반의 이벤트 드리븐 아키텍처에서 중앙 통제자를 두는 것은 확장성을 저해한다. 또한, 모든 비즈니스 로직에 3단계 상태를 구현하는 것은 개발 복잡도를 지나치게 높인다.
  • Saga (Long Lived Transaction) - 채택 (Accept):
    • 긴 트랜잭션을 여러 개의 짧은 로컬 트랜잭션으로 쪼개고, 이벤트(Event)를 통해 다음 단계로 넘기는 방식이다.
    • 노드 1은 출금 처리를 즉시 커밋(Commit)하고 이벤트를 던진다. 노드 2는 이벤트를 받아 입금을 처리한다. 락(Lock)을 전혀 잡지 않는다.
    • 격리성을 포기하는 대신, 섹션 3에서 설계한 Kafka의 처리량(Throughput)을 그대로 유지할 수 있는 유일한 대안이다. 실패 시에는 보상 트랜잭션(Compensation)으로 되돌린다. 이것이 우리가 나아갈 길이다.

5. 구현의 디테일: TCC와 Saga의 심층 분석 및 장애 대응전략

이론적인 일관성 모델을 선택했다면, 이제는 코드 레벨에서의 전쟁이다. 분산 트랜잭션은 정상적인 상황(Happy Path)이 아니라, 네트워크가 끊기고 서버가 죽는 실패 상황(Failure Path)을 어떻게 처리하느냐에 따라 그 성패가 갈린다. 우리는 TCC와 Saga, 두 가지 패턴의 내부 메커니즘을 해부하고 100만 TPS를 위한 최적의 생존 전략을 도출한다.

 

5.1. TCC (Try-Confirm-Cancel) 상세 구현: "자원 예약과 상태 관리의 미학"

TCC는 2PC의 성능 저하(Global Lock)를 해결하기 위해, 애플리케이션 레벨에서 자원을 제어하는 패턴이다. 핵심은 단순한 데이터 변경이 아니라, '자원의 선점(Reservation)'과 이를 관리하는 '상태 머신(State Machine)'에 있다.

1) 핵심 메커니즘: 2단계 자원 관리

이체(Transfer) 트랜잭션을 예로 들면, TCC는 다음과 같이 작동한다.

  • Try (시도/예약): 비즈니스 유효성을 검사하고 자원을 예약한다.
    • 송금자: 잔액(balance)에서 금액을 차감하지 않고, frozen_amount라는 임시 컬럼으로 이동시킨다. (실제 출금은 안 됐지만, 가용 잔액은 줄어듦)
    • 수금자: 계좌가 유효한지 검증하고, 입금될 공간을 확보한다(필요시).
  • Confirm (확정): 예약된 자원을 사용하여 트랜잭션을 확정한다.
    • 송금자: frozen_amount를 소멸시킨다(실제 차감 완료).
    • 수금자: balance를 증가시킨다.
    • 핵심: Try가 성공했다면 Confirm은 비즈니스 로직상 실패해서는 안 된다.
  • Cancel (취소/보상): Try 중 하나라도 실패하면 실행된다.
    • 송금자: frozen_amount를 다시 balance로 원상 복구시킨다.

 

2) 트랜잭션 코디네이터와 상태 관리 (State Management)

TCC는 여러 마이크로서비스에 걸쳐 진행되므로, 전체 프로세스를 관장하는 '트랜잭션 코디네이터(Transaction Coordinator)'가 필수적이다. 코디네이터는 시스템이 크래시(Crash)되더라도 복구할 수 있도록, 트랜잭션의 상태를 영구 저장소(Log)에 기록하며 관리해야 한다.

[상태 전이 라이프사이클]

  • INIT: 트랜잭션이 생성되었으나 아무것도 시작하지 않음.
  • TRYING: 참여자(Participant)들에게 Try 요청을 보내는 중.
  • CONFIRMING: 모든 Try가 성공하여, Confirm 요청을 보내는 중. (이 상태에 진입하면 롤백 불가)
  • CANCELLING: Try 중 실패가 발생하여, 성공했던 참여자들에게 Cancel 요청을 보내는 중.
  • DONE: 트랜잭션이 성공적으로 완료되거나 취소됨.

[장애 복구 전략] 만약 코디네이터가 CONFIRMING 상태를 로그에 기록하고 죽었다면? 재시작 시 로그를 읽고 "아, 나는 Confirm을 보내던 중이었지"라고 인지하여, 중단된 시점부터 Confirm 요청을 다시 보낸다.

 

3) 분산 네트워크의 불확실성 극복: 멱등성과 펜싱

네트워크는 신뢰할 수 없다. 요청은 유실되거나, 두 번 오거나(Duplication), 순서가 뒤바뀌어(Out-of-Order) 도착한다. TCC 구현체는 이에 대한 강력한 방어 로직을 갖춰야 한다.

① 멱등성 (Idempotency): "여러 번 맞아도 결과는 하나"

  • 상황: 코디네이터가 Confirm을 보냈는데 응답을 못 받아(Time-out), 다시 Confirm을 보냄.
  • 대응: 참여자(Participant)는 Transaction_ID를 키로 하여 처리 결과를 저장하고 있어야 한다. 이미 처리된 ID로 Confirm이 다시 오면, 로직을 재수행하지 않고 저장해 둔 '성공 응답'만 반환해야 한다. Try와 Cancel 또한 마찬가지다.

② 펜싱 (Fencing): "좀비 요청 방어" 네트워크 지연으로 인해 [Cancel 도착 -> 처리 완료] 이후에, 뒤늦게 [Try 도착]이 발생하는 치명적인 시나리오가 있다. 이를 막지 못하면 이미 취소된 거래가 부활하여 자원을 영구 점유하는 '자원 누수(Resource Leak)'가 발생한다.

  • 방어 로직 (Tombstone):
    • Cancel 요청이 먼저 도착하면, 해당 트랜잭션 ID에 대해 '취소됨(CANCELED)'이라는 마커(비석)를 DB에 남긴다.
    • 나중에 Try 요청이 도착하면, 로직 수행 전 이 마커를 확인한다. "어? 이건 이미 취소된 거래네?"라고 판단하고 즉시 거절(Reject)해야 한다.

③ 공갈 취소 (Null Compensation): "빈 깡통 처리"

  • 상황: Try 요청이 유실되어 도착조차 안 했는데, 코디네이터가 타임아웃 처리 후 Cancel을 보냄.
  • 대응: 참여자는 Cancel을 받았는데 Try 기록이 없다면 에러를 내는 것이 아니라, '성공'으로 응답해야 한다. 취소의 목적은 '아무 일도 없던 상태'로 만드는 것이며, Try가 안 왔다면 이미 그 상태이기 때문이다.

 

5.2. Saga 상세 구현: 격리성을 희생하여 처리량을 얻다

Saga 패턴은 TCC의 조심성을 버리고 과감한 '선(先) 실행, 후(後) 수습' 전략을 택한다. 우리는 이 패턴을 통해 데이터의 격리성(Isolation)을 포기하는 대신, 시스템 전체의 처리량(Throughput)을 극한으로 끌어올린다.

1) 메커니즘: 선형적 실행과 즉시 커밋 (Linear Execution & Immediate Commit) Saga는 트랜잭션을 일련의 로컬 트랜잭션 단계로 쪼개어 순차적(Sequential)으로 실행한다.

  • Step 1: 송금자 A의 노드에서 즉시 출금 처리 및 커밋(Commit). (자원 점유 해제)
  • Event: MoneyWithdrawn 이벤트를 Kafka로 발행.
  • Step 2: 수금자 B의 노드에서 이벤트를 수신하여 입금 처리 및 커밋.
  • 특징: A의 계좌에서 돈은 빠져나갔지만 B에게는 아직 입금되지 않은 '중간 상태'가 존재하며, 다른 트랜잭션이 이 상태를 볼 수 있다(Dirty Read 허용).

2) 보상 트랜잭션 (Compensating Transaction) 만약 Step 2(입금)에서 실패한다면? 시스템은 거꾸로 가는 이벤트를 발행하여 이전 단계들을 취소한다.

  • 시스템은 DepositFailed 이벤트를 발행하고, 송금자 노드는 이를 받아 A의 계좌에 다시 돈을 입금(Refund)하는 보상 로직을 수행한다. 이것은 TCC의 Cancel과는 다르다. TCC는 '예약 취소'지만, Saga는 '새로운 반대 거래'를 일으키는 것이다.

3) TCC vs Saga: 지연시간(Latency)과 처리량(Throughput)의 딜레마 이 지점에서 냉철한 비교가 필요하다. 흔히 Saga가 빠르다고 오해하지만, 개별 트랜잭션의 지연시간(Latency)은 TCC가 더 짧을 수 있다.

  • TCC (병렬 실행 = 낮은 지연시간): 코디네이터가 송금자와 수금자에게 Try 요청을 동시에(Parallel) 보낸다. 두 노드의 검증이 병렬로 이루어지므로, 전체 응답 시간은 가장 느린 노드의 응답 시간과 비슷하다.
  • Saga (선형 실행 = 높은 지연시간): A가 처리되고 이벤트가 날아가서 B가 처리될 때까지 순차적(Linear)으로 기다려야 한다. 단계가 많아질수록 지연시간은 누적된다.

그럼에도 불구하고 왜 Saga인가? 우리의 목표는 '한 명의 사용자가 0.1초 빨리 송금하는 것(Latency)'이 아니라, '초당 100만 명의 요청을 동시에 소화하는 것(Throughput)'이기 때문이다.

  • TCC의 병목: Confirm이 올 때까지 자원(frozen_amount)을 잡고 있어야 한다. 즉, 동시성 처리에 물리적 한계(Lock Holding Time)가 존재한다.
  • Saga의 승리: 로컬 트랜잭션은 1µs 만에 끝나고 즉시 커밋된다. 자원을 점유하는 시간이 거의 '0'에 수렴한다. 따라서 지연시간이 조금 길어지더라도, 단위 시간당 처리할 수 있는 트랜잭션의 총량은 Saga가 압도적으로 높다.

4) 구현 전략: 코레오그래피(Choreography) 중앙 오케스트레이터(Orchestrator)를 두는 방식은 관리가 쉽지만 SPOF가 된다. 우리는 Kafka 기반의 코레오그래피를 채택한다. 각 노드는 중앙의 명령 없이, 오직 자신에게 할당된 이벤트만을 구독하고 처리한 뒤 다음 이벤트를 던진다. 이것만이 병목 없는 무한 확장을 가능케 한다.

 

5.3. TCC/Saga의 장애 상황별 대응 시나리오 (Failure Scenarios)

분산 트랜잭션에서 "에러가 났다"는 것은 단순하지 않다. 어느 단계에서 에러가 났느냐에 따라 대응 전략은 180도 달라진다.

  • Phase 1 실패 (Try 또는 첫 번째 로컬 트랜잭션 실패):
    • 아직 돌이킬 수 있는 단계다. 일부만 성공했다면 즉시 취소(Cancel) 또는 보상 트랜잭션을 발송한다. 타임아웃이 발생하면 보수적으로 실패로 간주하고 롤백한다.
  • Phase 2 실패 (Confirm 또는 보상 트랜잭션 실패) - "무한 재시도의 늪":
    • 이곳이 지옥이다. 예를 들어, TCC에서 Try가 모두 성공했는데 Confirm 단계에서 네트워크가 끊겼다면? 혹은 Saga에서 돈은 뺐는데 보상 입금이 실패한다면?
    • 원칙: Phase 1이 성공했다면 Phase 2는 반드시 성공해야 한다. 롤백은 불가능하다.
    • 대응: 시스템은 성공할 때까지 무한 재시도(Retry until Success)를 수행한다. 자동 처리가 불가능한 경우(예: 계좌 동결 등 비즈니스 로직 오류)에는 데드 레터 큐(DLQ)로 메시지를 빼내어 운영자가 수동으로 개입할 수 있도록 격리한다.

 

5.4. 최종 선택: 100만 TPS를 위한 최적해

우리는 TCC의 안정성과 Saga의 성능 사이에서 결단을 내려야 한다. TCC는 중앙 집중식 코디네이터가 모든 트랜잭션의 상태를 관리해야 하므로, 100만 TPS 상황에서는 코디네이터 자체가 거대한 병목 구간(SPOF)이 될 위험이 크다. 또한, 모든 서비스가 TCC 인터페이스를 구현해야 하는 결합도(Coupling) 문제도 존재한다.

 

결론: 우리는 데이터 격리성(Isolation)이 다소 낮더라도, Kafka Streams를 활용한 비동기 이벤트 기반 Saga 패턴 (Choreography Saga)을 채택한다. 일시적인 데이터 불일치는 감수하되, 앞서 언급한 3대 난제 방어 로직을 통해 최종적 일관성(Eventual Consistency)을 완벽하게 보장한다. 이것이 성능과 안정성이라는 두 마리 토끼를 잡을 수 있는 유일한 현실적 대안이다.

 


6. 재현성(Reproducibility) 확보: 이벤트 소싱(Event Sourcing)의 마법

100만 TPS 시스템에서 데이터 정합성만큼 중요한 것은 '설명 가능성(Explainability)'이다. "잔액이 왜 이렇게 변했습니까?"라는 질문에 대해 기존의 스냅샷 위주 DB는 대답할 수 없다. 우리는 시스템의 모든 상태 변화를 추적하고, 필요하다면 과거의 특정 시점으로 완벽하게 되돌아갈 수 있는 이벤트 소싱(Event Sourcing) 아키텍처를 도입한다.

 

6.1. 데이터 모델의 분리: 의도(Command)와 사실(Event)

이벤트 소싱의 시작은 사용자의 요청(의도)과 시스템이 확정한 결과(사실)를 엄격히 구분하는 것이다.

① Command (명령/의도)

  • 정의: 사용자가 시스템에 "하고 싶다"고 요청한 것. 아직 유효성이 검증되지 않았으며, 비즈니스 로직에 의해 거절될 수 있다.
  • 저장소: wallet-commands 토픽
  • 예시: RequestTransfer(from: A, to: B, amount: 100)

② Event (이벤트/사실)

  • 정의: 비즈니스 로직을 통과하여 확정된 불변의 역사(Immutable History). 한번 기록되면 수정되거나 거절될 수 없으며, 반드시 상태에 반영되어야 한다. 이것이 진정한 Source of Truth다.
  • 저장소: wallet-events 토픽
  • 예시: MoneyWithdrawn(user: A, amount: 100), TransferFailed(reason: "잔액부족")

 

6.2. 프로세스 흐름: "결정론적 프로세서(Deterministic Processor)"

우리의 애플리케이션(Kafka Streams)은 f(Command, State) = Event라는 단순한 수식을 수행하는 함수형 프로세서가 된다.

  1. Input: wallet-commands 토픽에서 RequestTransfer를 읽는다.
  2. Validate (with State): 로컬 RocksDB에서 사용자 A의 현재 잔액을 조회한다.
    • if (balance >= 100): 성공 로직 수행
    • else: 실패 로직 수행
  3. Decide (Event Generation): 검증 결과에 따라 MoneyWithdrawn 이벤트를 생성한다. (이 시점까지 DB는 갱신되지 않음)
  4. Atomic Commit (핵심): Kafka Streams의 EOS(Exactly-Once Semantics) 기능을 사용하여 다음 두 가지를 원자적(Atomic)으로 수행한다.
    • wallet-events 토픽에 이벤트 발행.
    • 로컬 RocksDB(State Store)에 잔액 업데이트 (balance - 100).
    • Command 토픽의 오프셋 커밋.

결과: 시스템의 상태(RocksDB)는 최신화되었고, 그 근거가 되는 역사(Event)는 Kafka에 영구 저장되었다.

6.3. 재현성(Reproducibility) 활용 전략: "타임 머신"

이제 시스템에 버그가 발생했거나, 금융 감독 기관의 감사가 들어왔을 때 이 아키텍처가 어떻게 빛을 발하는지 보자.

① 과거 시점 상태 복원 (Time Travel)

  • 상황: "어제 오후 2시에 A의 잔액이 왜 0원이었는지 증명하라."
  • 실행:
    1. 검증용 Kafka Streams 애플리케이션을 별도로 띄운다.
    2. wallet-events 토픽을 처음(Offset 0)부터 어제 오후 2시의 오프셋까지만 리플레이(Replay)하도록 설정한다.
    3. 리플레이가 끝나면, 로컬 RocksDB의 잔액은 어제 오후 2시 시점과 1원 하나 틀리지 않고 수학적으로 완벽하게 일치하게 된다.

② 버그 디버깅 (Deterministic Replay)

  • 상황: 특정 순서로 이체가 발생했을 때만 잔액이 꼬이는 희귀한 경쟁 상태(Race Condition) 버그 발견.
  • 실행:
    1. 운영 환경(Production)의 wallet-commands 토픽 데이터를 그대로 복사해 온다.
    2. 개발 환경(Stage)에서 로직을 수정한 후, 복사해 온 데이터를 입력으로 넣는다.
    3. 결정론적(Deterministic) 특성 때문에, 입력이 같다면 결과도 같아야 한다. 수정된 로직이 버그를 잡았는지 100% 확신을 가지고 검증할 수 있다.

6.4. 스냅샷 전략: "빠른 재현을 위하여"

이벤트가 10억 개 쌓여 있다면, 재현할 때마다 처음부터 다시 돌리는(Replay) 것은 너무 느리다. 이를 위해 RocksDB 자체가 스냅샷 역할을 수행한다.

  • Changelog Topic 활용:
    • 앞서 언급한 RocksDB의 백업 토픽인 changelog-topic은 이미 '이벤트들이 합쳐진 최종 상태(Snapshot)'와 같다.
    • Kafka의 Log Compaction 기능 덕분에 이 토픽은 각 키(Key)의 '최종 값(Last Value)'만을 유지한다.
  • 복구 전략:
    • "최근 상태부터 재현"하고 싶다면, changelog-topic을 먼저 로드하여 현재의 잔액 상태(Base State)를 순식간에 만든다.
    • 그 이후에 발생한 wallet-events만 리플레이하면 복구 시간을 획기적으로 단축할 수 있다.

'System Design > General' 카테고리의 다른 글

Consistent Hashing 설계  (0) 2024.09.22
Rate Limiter 설계  (0) 2024.09.19
시스템 설계 면접 팁  (0) 2024.09.05
Cache 잘 쓰는 방법 정리  (0) 2024.06.16
7 Must-know Strategies to Scale Your Database  (0) 2024.06.16

+ Recent posts