전자 지갑
- 12.1 문제 이해 및 설계 범위 확정
- 12.2 개략적 설계안 제시
- 12.3 상세 설계
- 12.4 마무리
**전자 지갑(Digital Wallet)**은 카카오페이, 토스, Apple Pay 잔액 같은 서비스야. 사용자가 지갑에 돈을 충전하고, 송금하고, 결제하는 시스템인데, 핵심 난이도는 이중 지불(double spending) 방지와 분산 환경에서의 정확한 잔액 관리지. 이벤트 소싱과 CQRS라는 아키텍처 패턴이 등장해.
기능 요구사항을 보면:
- 지갑 간 송금(transfer) — A의 지갑에서 B의 지갑으로 돈을 옮긴다
- 잔액 조회 — 현재 내 지갑에 얼마가 있는지
비기능 요구사항은:
- 정확성 — 잔액이 틀리면 안 돼. 1원이라도.
- 이중 지불 방지 — 잔액이 100원인데 동시에 100원짜리 결제 2건을 성공시키면 안 되지
- 높은 처리량 — 초당 수백만 건의 트랜잭션
- 높은 가용성 — 결제 시스템이니까 다운되면 큰일이야
핵심 문제는 이거야 — 분산 환경에서 여러 노드가 동시에 같은 지갑의 잔액을 변경하려 할 때, 정확한 잔액을 어떻게 보장할 것인가.
가장 직관적인 방법을 보자. wallets 테이블에 balance 컬럼이 있고, 송금할 때 트랜잭션 안에서:
- A의 잔액이 충분한지 확인
- A의 잔액을 차감
- B의 잔액을 증가
- 커밋
단일 DB 인스턴스라면 이게 동작해. ACID 트랜잭션이 동시성 문제를 해결해주니까.
하지만 규모가 커지면 문제가 생겨. 인기 지갑(쇼핑몰 지갑 등)에 동시 접근이 몰리면 락 경쟁(lock contention)이 심해지고, 단일 DB가 처리량을 감당 못 하지. DB를 샤딩하면 A와 B가 다른 샤드에 있을 때 분산 트랜잭션이 필요해지는데, 이건 느리고 복잡해.
책에서 제안하는 핵심 아키텍처가 **이벤트 소싱(Event Sourcing)**이야.
전통적 방식은 "현재 잔액"을 직접 저장하고 업데이트하지. 이벤트 소싱은 잔액을 직접 저장하지 않아. 대신 발생한 모든 **이벤트(거래 내역)**를 순서대로 기록해.
- "A가 1000원 충전" (이벤트 1)
- "A → B 300원 송금" (이벤트 2)
- "A → C 200원 송금" (이벤트 3)
A의 현재 잔액을 알고 싶으면? 이벤트 1부터 3까지 **순서대로 재생(replay)**하면 돼. 1000 - 300 - 200 = 500원.
이벤트 소싱의 핵심 원칙은 이래.
- 이벤트는 **불변(immutable)**이야. 한 번 기록되면 수정이나 삭제가 없어
- 이벤트는 추가(append)만 돼
- 현재 상태는 이벤트들의 **순차적 적용(fold)**으로 도출되지
왜 이벤트 소싱인가?
- 감사 추적(audit trail)이 완벽해. 모든 거래가 이벤트로 남아있으니 "왜 잔액이 이렇게 됐는지" 역추적이 가능하지
- 버그가 발견돼도 이벤트를 다시 재생해서 올바른 상태를 복구할 수 있어
- 이벤트가 불변이니 동시 쓰기 충돌이 줄어들어 — 같은 행을 업데이트하는 게 아니라 새 이벤트를 추가하는 거니까
이벤트는 append-only 로그에 저장해. 카프카가 이 역할을 할 수도 있고, 이벤트 전용 DB를 쓸 수도 있지.
이벤트의 순서가 핵심이야. 같은 지갑에 대한 이벤트는 반드시 발생 순서대로 처리돼야 해. 안 그러면 잔액이 틀어지거든. 카프카를 쓴다면 같은 지갑 ID를 파티션 키로 해서 같은 파티션에 들어가게 하면 순서가 보장돼.
이벤트 소싱의 문제점 — "A의 현재 잔액"을 알려면 A의 모든 이벤트를 처음부터 재생해야 해. 이벤트가 수만 건이면 느리지.
**CQRS (Command Query Responsibility Segregation)**는 쓰기(Command)와 읽기(Query)를 분리하는 패턴이야.
- 쓰기 쪽: 이벤트 저장소에 이벤트를 추가한다 (이벤트 소싱)
- 읽기 쪽: 이벤트를 소비해서 현재 상태(잔액)를 계산하고 별도의 읽기 DB에 저장한다
읽기 DB에는 (wallet_id, balance) 형태의 최신 잔액이 들어있어. 잔액 조회는 이 DB에서 바로 읽으면 돼. 이벤트를 다시 재생할 필요 없이 O(1)이지.
쓰기와 읽기가 분리되어 있으니 각각 독립적으로 확장할 수 있어. 읽기가 훨씬 많으니 읽기 쪽에 복제본을 많이 두면 되지.
단점은 **쓰기와 읽기 사이에 약간의 지연(eventual consistency)**이 있다는 거야. 이벤트가 발행되고 읽기 DB가 업데이트되기까지의 시간차.
잔액이 100원인데 동시에 100원 결제 2건이 들어오면?
방법 1: 이벤트 순서로 해결 — 같은 지갑의 이벤트가 같은 파티션에서 순차적으로 처리돼. 첫 번째 결제 이벤트가 처리되면 잔액이 0원이 되고, 두 번째 이벤트는 잔액 부족으로 거부하지.
이게 가능한 이유는 같은 지갑에 대한 이벤트가 단일 파티션에서 단일 컨슈머가 순차 처리하기 때문이야. 동시성 문제가 원천적으로 제거되지.
방법 2: 낙관적 잠금 + 버전 — 읽기 DB의 잔액에 버전 번호를 두고, 결제 시 현재 버전을 확인한 후 조건부 업데이트. 7장의 호텔 예약과 같은 원리야.
이벤트가 수만 건 쌓이면 재생이 느려지지. 이를 최적화하기 위해 주기적으로 스냅샷을 찍어.
"이벤트 5000번까지 재생한 결과, A의 잔액은 50,000원" — 이걸 스냅샷으로 저장해. 이후에는 5001번 이벤트부터 재생하면 되지.
A → B 송금에서 A와 B가 다른 서비스(또는 다른 샤드)에 있으면? 전통적인 2PC(Two-Phase Commit)는 느리고 가용성이 떨어져.
Saga 패턴 — 분산 트랜잭션을 여러 개의 로컬 트랜잭션으로 분해하지.
- A의 서비스: A 잔액 차감 (로컬 트랜잭션)
- B의 서비스: B 잔액 증가 (로컬 트랜잭션)
2번이 실패하면? **보상 트랜잭션(compensating transaction)**을 실행해서 1번을 취소해 — A 잔액을 다시 복구하지.
이벤트 소싱과 결합하면 보상 트랜잭션도 그냥 새로운 이벤트를 추가하는 거라 깔끔해.
전자 지갑은 결제 시스템(11장)의 연장선이지만, 이벤트 소싱과 CQRS라는 아키텍처 패턴의 실전 적용이 핵심 주제야. 이 패턴들은 금융 시스템 외에도 감사 추적이 중요한 다양한 도메인에서 활용되지.
정리
12장 읽고 기억할 거 세 가지:
- 이벤트 소싱은 현재 상태를 직접 저장하지 않고, 불변 이벤트의 시퀀스로 상태를 도출해. 감사 추적이 완벽하고, 이벤트를 재생해서 상태를 복구할 수 있지.
- CQRS로 쓰기(이벤트 저장)와 읽기(현재 잔액 조회)를 분리하면 각각 독립적으로 확장 가능해. 대신 eventual consistency를 감수해야 하지.
- 이중 지불 방지는 같은 지갑의 이벤트를 단일 파티션에서 순차 처리함으로써 달성해. 동시성 문제를 자료구조와 아키텍처 수준에서 원천 차단하는 접근이야.