Design E-Wallet

요구사항

  • 이체 기능 지원(다른 기능은 없다고 가정)

  • 초당 트랜잭션 수(TPS): 1,000,000

  • 재현성을 갖춘 시스템

  • 가용성 99.99%

1,000,000 트랜잭션을 지원하기 위해, 필요한 데이터베이스 노드는 다음과 같이 계산할 수 있다.

  • 하나의 데이터베이스 노드의 처리량: 1,000 TPS으로 가정

  • 1,000,000 * 2(입금 + 출금) / 1,000 = 2,000 노드

API 단위 기능 정의

POST /wallet/transfer

이체를 수행하는 API, 요청 매개변수는 다음과 같다.

  • from_account_id: 출금 계좌 ID

  • to_account_id: 입금 계좌 ID

  • amount: 이체 금액

  • currency: 통화 단위

  • transaction_id: 트랜잭션 ID(중복 방지용)

인메모리 샤딩과 분산 트랜잭션

이체 기능을 구현하기 위해, 인메모리 샤딩을 사용하여 계좌 정보를 분산 저장한다. 이를 통해 데이터베이스의 부하를 줄이고, 빠른 조회 및 업데이트가 가능하다.

  • 레디스와 같은 인메모리 데이터베이스를 사용하여 계좌 정보를 저장(<사용자, 잔액> 형태)

  • 1,000,000 TPS를 지원하기 위해, 클러스터를 구성하고 균등하게 샤딩

이 때 사용자의 계좌 정보가 다른 샤드에 저장될 수 있으므로, 이체 시 두 업데이트가 하나의 원자적 트랜잭션으로 처리되어야 한다.

2PC(2-Phase Commit)

데이터베이스 자체에 의존하는 방법으로, 두 단계로 커밋을 진행한다.

  1. 락 획득 단계: 모든 관련 데이터베이스 노드에 트랜잭션을 준비 상태로 설정하고, 락을 획득

  2. 준비 단계: 락을 획득한 데이터베이스 노드들이 커밋이 가능한지 확인

  3. 커밋 단계: 모든 노드가 준비 상태라면 커밋을 수행, 그렇지 않으면 롤백

이 방법은 안전하게 트랜잭션을 처리할 수 있을 것 같지만, 다음과 같은 문제들이 존재한다.

  • 성능 저하: 락을 획득하고 커밋을 처리하는 과정 자체에서 락 점유 시간이 길어져 성능이 저하됨

  • SPoF: 락을 획득하고 커밋을 처리해주는 조정자에 장애가 발생하면, 락이 획득한 상태로 계속 유지됨

TCC(try-confirm-cancel)

TCC는 시도 -> 확정 / 취소로 구성된 트랜잭션 처리 방식이다.

  1. 조정자는 모든 데이터베이스에 트랜잭션에 필요한 자원 예약 요청(=Try)

  2. 조정자는 모든 데이터베이스로부터 회신을 받음

    • 모두 성공 회신: 확정 요청(=Confirm)

    • 하나 이상의 실패 회신: 취소 요청(=Cancel)

이 처리 방식은 2PC가 두 단계를 하나의 트랜잭션으로 처리한 것에 비해, 각 단계를 별도 트랜잭션으로 처리한다는 차이가 있다. 하지만 이 방식도 다음과 같은 문제점이 있다.

  • 네트워크 지연이나 중간 단계 실패 시 복구 로직이 복잡해짐

  • 비즈니스 로직에서 각 단계(Try, Confirm, Cancel)에 대한 별도 구현이 필요해 개발 부담이 증가

Saga 패턴

Saga 패턴은 분산 트랜잭션을 처리하기 위한 패턴으로, MSA 환경에서는 표준으로 자리잡고 있다.

  1. 모든 연산은 순서대로 정렬되어, 각 연산은 자신의 데이터베이스에 독립 트랜잭션으로 실행

  2. 연산은 순서대로 실행되고, 각 연산이 성공하면 다음 연산이 실행

  3. 만약 연산이 실패하면, 이전 연산들을 취소하기 위해 역순으로 보상 트랜잭션을 통해 롤백

여기서 연산 수행 조율을 위한 방법은 다음 두 가지가 있다.

Choreography
Orchestration

설명

각 서비스가 자신의 상태 변경을 이벤트로 발행하고, 다른 서비스가 해당 이벤트를 구독하여 처리

중앙 조정자가 모든 연산을 조율하고, 각 서비스에 명령을 전달

장점

서비스 간 결합도가 낮아 유연성 증가, 서비스 확장 용이

중앙 집중식 관리로 복잡한 로직 처리 용이

단점

서비스 간 의존성이 생길 수 있어 복잡성 증가

중앙 조정자가 SPoF가 될 수 있음

이벤트 소싱

전자 지갑 서비스와 같이 금융 거래를 다루는 시스템에서는 재현성(reproducibility)과 감사(audit) 가능성이 필수적이다.

  • 특정 시점의 계정 잔액

  • 과거 및 현재 계정 잔액이 정확성

이러한 요구사항을 충족하려면, 단순히 현재 상태만 저장하는 방식으로는 부족하며, 모든 상태 변화를 기록하고 필요 시 재구성할 수 있는 이벤트 소싱 방식을 사용할 수 있다.

이벤트 소싱 개념

이벤트 소싱은 시스템의 상태를 이벤트로 기록하고, 이 이벤트들을 재생하여 현재 상태를 도출하는 방식으로, 다음과 같은 구성 요소로 이루어진다.

  • Command(명령)

    • 외부에서 전달된 의도가 명확한 요청

    • 순서가 중요하므로 FIFO에 저장

  • Event(이벤트)

    • 명령에 의해 발생한 상태 변화의 기록

    • 하나의 명령으로 여러 이벤트가 발생할 수 있음

    • 순서가 중요하므로 FIFO에 저장

  • State(상태)

    • 이벤트를 재생하여 도출한 현재 상태

    • 특정 시점으로의 복원이 가능

  • State Machine(상태 기계)

    • 이벤트 소싱 프로세스를 구동

    • 명령의 유효성을 검사하고 이벤트 생성하여 상태를 변경

명령 -----> 상태 기계 ------> 이벤트 -----> 상태 기계
              |                          |
              |                          |
             읽기 --------> 상태 < ------ 적용 

지갑 서비스 예시

지갑 서비스의 경우 명령은 이체 요청이 될 것이고, 다음과 같이 동작할 수 있다.

  1. 사용자가 이체 요청

  2. 이체 요청(명령)은 FIFO 큐에 기록되고, 순서대로 지갑 서비스(상태 기계)에 전달

  3. 데이터베이스에 저장된 잔액(상태)를 조회

  4. 지갑 서비스(상태 기계)는 잔액이 충분한지 유효성 검사

  5. 잔액이 충분하다면, 이체(이벤트) 생성하여 FIFO 큐에 기록

  6. 이벤트는 상태 기계에 전달되어, 잔액을 업데이트하고 새로운 상태를 생성

재현성

이벤트 소싱이 다른 아키텍처에 비해 가장 중요한 장점은 재현성이다.

  • 일반적인 상태 변경의 경우 최종 상태만 저장되며, 과거 상태를 복원하기 어려움

  • 이벤트 소싱은 모든 상태 변화를 기록하므로, 특정 시점의 상태를 재구성할 수 있음

+------------------------------ Commands -------------------------------+
| [ A -$1 -> C ]           [ A -$1 -> B ]            [ A -$1 -> D ]     |
+-----------------------------------------------------------------------+
        |                        |                          |
+------------------------------- Event Stream --------------------------+
| [A:-$1] [C:+$1]         [A:-$1] [B:+$1]          [A:-$1] [D:+$1]      |
+-----------------------------------------------------------------------+
        |                        |                           |
+--------------------+    +--------------------+    +--------------------+
| Snapshot t0        |    | Snapshot t1        |    | Snapshot t2        |
| A:5  B:4  C:3  D:2 |    | A:4  B:4  C:4  D:2 |    | A:3  B:5  C:4  D:2 |
+--------------------+    +--------------------+    +--------------------+

CQRS(명령-쿼리 책임 분리)

명령(Command)과 질의(Query)를 서로 다른 처리 경로와 데이터 모델로 분리하는 아키텍처로, 이벤트 소싱을 사용할 때 자주 결합하여 사용한다.

  • 쓰기 모델은 도메인 규칙과 트랜잭션 일관성에 맞게 처리

  • 읽기 모델은 조회 성능과 쿼리 최적화에 맞춰 별도 구성

  • 이벤트 소싱과 결합하면 쓰기 측에서 발생한 이벤트를 기반으로 읽기 모델(프로젝션)을 갱신

이 방식은 단순 DB 사본을 사용하는 방법과 비교할 때 다음과 같은 차이점이 있다.

DB Replica
CQRS

목적

읽기 부하 분산

읽기/쓰기 모델 분리

스키마

원본 DB 스키마 복제

읽기 모델 최적화 가능

데이터 반영 시간

실시간 또는 지연

이벤트 기반 반영

상세 설계

고신뢰성 솔루션

이벤트 소싱 아키텍처에서 래프트 노드 구조를 사용하여 SPOF 문제를 해결할 수 있다.

            +------------------------------------------------------------+
            | 팔로워               이벤트 -> 상태 기계 -> 상태 저장소            |
            +------------------------------------------------------------+
                                        |
                                      래프트
                                        |
            +------------------------------------------------------------+
 요청 ---->  | 리더                 이벤트 -> 상태 기계 -> 상태 저장소            |
            +------------------------------------------------------------+
                                        |
                                      래프트
                                        |
            +------------------------------------------------------------+
            | 팔로워               이벤트 -> 상태 기계 -> 상태 저장소            |
            +------------------------------------------------------------+

래프트 구조는 분산 시스템에서 일관성을 유지하기 위한 합의 알고리즘으로, 다음과 같은 특징이 있다.

  • 각 노드는 같은 데이터를 가짐

  • 리더 / 팔로워 구조로, 리더가 클라이언트 요청을 처리하고 팔로워는 리더의 로그를 복제

  • 과반수 노드가 작동하는 한 시스템은 안정적으로 동작 가능

데이터 반영 지연 최소화

CQRS 시스템에서는 쓰기 모델과 읽기 모델이 분리되어 있어 업데이트 시점을 정확히 알 수 없기 때문에, 폴링 방식으로 상태를 확인할 수 있지만 다음과 같은 문제점이 있다.

  • 반영이 지연될 수 있으며, 언제 반영될지 알 수 없음

  • 주기를 짧게 설정하면 성능 저하가 발생할 수 있음

이 문제는 리버스 프록시를 추가하여 개선할 수 있다.

               +---------------------- 이벤트 수신 후 실행 상태를 역방향 프락시에 푸시 ------------------------------+
     응답       |                                                                                          |
    <----      |           +---------------------------------------------------+                          |
 요청 ----> 리버스 프록시 ---> |   이벤트 -> 상태 기계 -> 이벤트 -> 상태 기계 -> 상태 저장소  |       +--> 상태 기계 --> 상태 저장소
                           +---------------------------------------------------+       |
                                                   |                                   |
                                                   +---------------------------- ------+
  1. 요청을 리버스 프록시로 전달

  2. 리버스 프록시는 쓰기 모델에 이벤트를 전달

  3. 쓰기 모델에서 이벤트를 처리하고 읽기 전용 상태 기계에 이벤트를 전달

  4. 읽기 전용 상태 기계는 상태를 갱신하고, 리버스 프록시에 상태를 푸시

  5. 클라이언트는 리버스 프록시로부터 상태를 받아 응답

참고자료

Last updated

Was this helpful?