[설계] 가상 면접 사례로 배우는 대규모 시스템 설계 기초 2 - 12장. 전자 지갑 (분산 트랜잭션 - 2PC, TC/C, Saga, 이벤트 소싱+CQRS) ✅

hailey·2025년 2월 2일

시스템설계

목록 보기
3/8

12장. 전자 지갑 요약

  • 결제 플랫폼은 일반적으로 고객에게 전자 지갑 서비스를 제공해서 고객으로 하여금 지갑에 돈을 넣어두고 필요할 때 사용할 수 있도록 한다.
  • 예를 들어, 은행 카드에서 전자 지갑에 돈을 이체해 두면 전자상거래 사이트에서 제품을 구매할 때 그 지갑의 돈을 사용해서 결제하는 옵션을 선택할 수 있다.
  • 전자 지갑결제 기능만 제공하는 것이 아니다!
    일례로 페이팔 같은 플랫폼의 다른 사용자 지갑으로 직접 송금을 지원한다.
  • 전자 지갑 간 이체는 은행 간 이체보다 빠르며,
    일반적으로 추가 수수료를 부과하지 않는다는 중요한 차이가 있다!
  • 지갑 간 이체를 지원하는 전자 지갑 애플리케이션의 백엔드를 설계하라는 요청을 받았다고 가정하자.

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

👩🏻‍💼(지원자) : 두 전자지갑 사이의 이체에만 집중해야 하나요? 다른 기능도 신경 써야 할까요?

👨🏻‍💻(면접관) : 이체 기능에만 집중합시다.

👩🏻‍💼(지원자) : 시스템이 지원해야 하는 초당 트랜잭션수(TPS)는 얼마인가요?

👨🏻‍💻(면접관) : 1,000,000(백만)TPS로 가정하겠습니다.

👩🏻‍💼(지원자) : 전자 지갑은 정확성에 대한 엄격한 요건이 있을 텐데요. 데이터베이스가 제공하는 트랜잭션 보증이면 충분하다고 볼 수 있을까요?

👨🏻‍💻(면접관) : 좋습니다.

👩🏻‍💼(지원자) : 정확성을 증명해야 하나요?

👨🏻‍💻(면접관) : 좋은 질문입니다. 일반적으로 정확성은 트랜잭션이 완료된 뒤에나 확인할 수 있습니다. 한가지 검증법은 내부기록과 은행의 명세서를 비교하는 것이죠. 그러나 이런 조정(reconciliation) 작업으로는 데이터 일관성이 깨졌다는 사실은 알 수 있으나 그 차이가 왜 발생했는지는 알기 힘듭니다.
따라서 우리는 재현성(reproducibility)를 갖춘 시스템을 설계하고자 합니다. 즉, 처음부터 데이터를 재생하여 언제든 과거 잔액을 재구성할 수 있는 시스템을 만들려는 겁니다.

👩🏻‍💼(지원자) : 가용성 요구사항이 99.99%라고 가정해도 되나요?

👨🏻‍💻(면접관) : 좋습니다.

👩🏻‍💼(지원자) : 환전이 가능해야 하나요?

👨🏻‍💻(면접관) : 아니요, 그럴 필요는 없습니다.

요구사항 요약

  • 전자 지갑 간 이체
  • 백만 TPS
  • 99.99% 안정성
  • 트랜잭션
  • 재현성 ⭐️

개략적 추정

  • TPS를 거론한다는 것은 배후에 트랜잭션 기반 데이터베이스를 사용한다는 뜻이다.
  • 오늘날 일반적 데이터센터 노드에서 실행되는 관계형 디비는 초당 수천건의 트랜잭션을 지원할 수 있다.
  • 본 설계안에서 사용할 디비 노드는 1000TPS를 지원할 수 있다고 가정하자. 따라서 1백만 TPS를 지원하려면 1000개의 데이터베이스 노드가 필요하다.
  • 하지만 이 계산은 살짝 부정확한데, 이체 명령을 실행하려면 두번의 연산이 필요하다. (인출+입금)
  • 1백만건 TPS 처리를 위해서는 2백만 TPS를 지원해야 하고, 결국 2000개 노드가 필요하다는 뜻이다.
  • 이번 장의 설계 목표 중 하나는 단일 노드가 처리할 수 있는 트랜잭션 수를 늘리는 것이다!

2단계: 개략적 설계안 제시및 동의 구하기

  • API 설계
  • 세가지 개략적 설계안
    1. 간단한 메모리 기반 솔루션 ex) 레디스
    2. 데이터베이스 기반 분산 트랜잭션 솔루션
    3. 재현성을 갖춘 이벤트소싱 솔루션

API 설계

  • POST /v1/wallet/balance_transfer : 한 지갑에서 다른 지갑으로 자금 이체
  • 요청 인자
    • from_account : 자금 인출 계좌
    • to_account : 자금 이체 계좌
    • amount : 이체 금액 (string)
    • currency : 통화 단위
    • transaction_id : 중복 제거 ID (uuid)
  • 응답 본문
    • status : "success"
    • "transaction_id": "0123-234-12dd-234-dfsdf112"

  • 한가지 유의할 부분은 amount 필드 자료형이 double 이 아닌 string 이다.
    • 실제론 float 이나 double 을 쓰기도 하는데, 대부분의 프로그래밍 언어와 디비가 지원하기 때문이다.
    • 정밀도를 잃을 위험이 있음을 이해하고 사용한다면 적절한 선택일 수도 있다!

인메모리 샤딩

  • 지갑 애플리케이션은 모든 사용자 계정의 잔액을 유지한다.
  • <사용자, 잔액> 관계를 나타내기 좋은 자료구조는 해시 테이블이라고도 불리는 맵 또는 키-값 저장소다.
  • 인메모리 저장소로 인기있는 선택지 하나는 레디스다.
  • 그러나 레디스 노드 한대로 백만 TPS 처리는 벅차다.
  • 클러스터를 구성하고 사용자 계정을 모든 노드에 균등하게 분산시켜야 한다.
  • 이 절차를 파티셔닝 또는 샤딩이라고 한다.
  • 키-값 데이터를 n개의 파티션에 고르게 분배하려면 키의 해시값을 계산하고 이를 파티션의 수 n으로 나누는 것이 한가지 방법이다.
  • 그 결과로 얻은 나머지 값이 데이터를 저장할 파티션 번호다.
    String accountID = "A";
    Int partitionNumber = 7;
    Int myPartition = accountID.hashCode() % partitionNumber;
  • 모든 레디스 노드의 파티션 수 및 주소는 한군데 저장해 둔다.
  • 높은 가용성을 보장하는 설정 정보 전문 저장소 주키퍼를 이 용도로 쓰면 좋다.
  • 이 방안의 마지막 구성 요소는 이체 명령 처리를 담당하는 서비스로, "지갑 서비스"로 부르겠다.
  • 지갑 서비스의 역할
    1. 이체 명령 수신
    2. 이체 명령 유효성 검증 (validation)
    3. 명령이 유효한 것으로 확인되면, 이체에 관계된 두 계정의 잔액 갱신. 이 두계정은 서로 다른 레디스 노드에 있을 수 있다.

메모리 기반 솔루션

  • 이 서비스는 무상태(stateless) 서비스다. 따라서 수평적 규모 확장이 용이하다.
  • 이 예제에서 3개의 레디스 노드가 있다.
  • A/B/C 세 클라이언트가 있으며, 이들의 계정 잔액 정보는 이 세개 레디스 노드에 균등하게 분산되어 있다.
  • 이체 요청을 처리하는 지갑 서비스 노드는 2개 있다.
    • 그 가운데 하나가 클라이언트 A에서 클라이언트 B로 1달러 이체하라는 명령을 받으면, 두개의 레디스 노드에 두개의 명령이 전달된다.
  • 즉, 클라이언트A의 계정이 포함된 레디스 노드는 1달러 차감 명령을 받고, 클라이언트 B계정이 포함된 레디스 노드는 1달러를 더하라는 명령을 받는다.

👩🏻‍💼(지원자) : 이 설계에서 계정 잔액이 여러 레디스 노드에 분산됩니다. 주키퍼는 샤딩 정보 관리에 사용합니다. 무상태 서비스인 지갑 서비스는 주키퍼에 샤딩 정보를 질의해서 특정 클라이언트의 정보를 담은 레디스 노드를 찾고, 그 잔액을 적절히 갱신합니다.

👨🏻‍💻(면접관): 이 설계는 작동은 하지만, 정확한 요구사항을 충족하지 못합니다!
지갑 서비스는 이체할 때마다 두개의 레디스 노드를 업데이트 하는데, 그 두 연산이 모두 성공하리라는 보장은 없죠.
예를 들어, 첫번째 업데이트를 끝낸 후 두번째 업데이트를 완료하기 전에 지갑 서비스 노드가 죽어버리면, 이체는 온전히 마무리되지 못합니다. 그러니 두 업데이트 연산은 하나의 원자적 트랜잭션으로 실행되어야 해요!

분산 트랜잭션 - 2PC, TC/C, Saga, 이벤트 소싱(⭐️)

데이터베이스 샤딩

  • 서로 다른 두 개 저장소 노드를 갱신하는 연산을 원자적으로 수행하려면 어떻게 해야 할까???
  • 첫번째 단계는 각 레디스 노드를 트랜잭션을 지원하는 관계형 디비 노드로 교체하는 것이다.
  • 클라이언트 A/B/C 잔액 정보가 레디스 노드가 아닌 3개의 관계형 디비 노드로 분산된다.
  • 하지만 트랜잭션 디비를 사용해도 이런 식이면 문제의 일부만 해결할 수 있다. 앞서 말했듯, 한 이체 명령이 서로 다른 두 디비 서버에 있는 계정 두개를 업데이트해야 할 가능성이 아주 높은데, 이 두 작업이 정확히 동시에 처리된다는 보장이 없는 것이다.
  • 첫번째 계정의 잔액을 갱신한 직후 지갑 서비스가 재시작된 경우를 생각해보자. 두번째 계정의 잔액도 반드시 갱신되도록 하려면 어떻게 해야 할까?

분산 트랜잭션 [1] - 2단계 커밋(2PC) (두단계가 하나의 트랜잭션)

  • 분산 시스템에서 한 트랜잭션에는 여러 노드의 프로세스가 관여할 수 있다.
  • 분산 트랜잭션은 이들 프로세스를 원자적인 하나의 트랜잭션으로 묶는 방안이다.
  • 분산 트랜잭션의 구현법
    • 저수준 방안과, 고수준 방안 2가지가 있다.
  • 저수준 방안 : 데이터베이스 자체에 의존하는 방안이다.
    이때 가장 일반적으로 사용되는 알고리즘이 2단계 커밋(2PC) 이다.
    이름에서 알 수 있듯, 두단계로 실행된다.

  1. 조정자(지갑 서비스)는 정상적으로 여러 디비에 읽기 및 쓰기 작업을 수행한다. → 데이터베이스 A/C에 락이 걸린다.
  2. 애플리케이션이 트랜잭션을 커밋하려고 할 때, 조정자는 모든 디비에 트랜잭션 준비를 요청한다.
  3. 두번째 단계에서 조정자는 모든 디비의 응답을 받아 다음 절차를 수행한다.
    a. 모든 데이터베이스가 '예'라고 응답하면 조정자는 모든 디비에 해당 트랜잭션 커밋을 요청한다.
    b. 어느 한 디비라도 '아니요'를 응답하면 조정자는 모든 디비에 트랜잭션 중단을 요청한다.

  • 이 방안이 저수준 방안인 이유는,
    준비 단계를 실행하려면 데이터베이스 트랜잭션 실행 방식을 변경해야 하기 때문이다. 예를 들어, 이기종 디비 사이에 2PC를 실행하려면 모든 디비가 X/Open XA 표준을 만족해야 한다.
  • 2PC 가장 큰 문제점은 다른 노드의 메시지를 기다리는 동안 락이 오랫동안 잠긴 상태로 남을 수 있어서 성능이 좋지 않다는 것이다.
  • 또 다른 문제는 조정자가 SPOF, 단일 장애 지점(Single Point Of Failure)가 될 수 있다는 것이다.

분산 트랜잭션 [2] - TC/C (각 단계가 별도의 트랜잭션)

  • TC/C (시도-확정/취소, 즉 Try-Confirm/Cancel)는 두 단계로 구성된 보상 트랜잭션이다.
  1. 조정자는 모든 디비에 트랜잭션에 필요한 자원 예약을 요청한다.
  2. 조정자는 모든 디비로부터 회신을 받는다.
    a. 모두 '예'라고 응답하면 모든 디비에 작업 확인을 요청하는데, 이것이 바로 '시도-확정' 절차다.
    b. 어느 하나라도 '아니요'라고 응답하면 조정자는 모든 디비에 작업 취소를 요청하며, 이것이 바로 '시도-취소' 절차다.
  • 2PC의 두 단계는 한 트랜잭션이지만, TC/C에서는 각 단계가 별도의 트랜잭션이다.

TC/C 사례

  • 계좌 A에서 C로 1달러를 이체한다고 가정하자.
  • 지갑 서비스가 TC/C 조정자라고 가정하자.
  • 분산 트랜잭션이 실행될 때, 계정 A 잔액은 1달러고, C 잔액은 0달러다.
  • 첫번째 단계 (Try: 시도 단계)

    : 시도 단계에서는 조정자 역할을 하는 지갑 서비스가 두개의 트랜잭션 명령을 두 데이터베이스로 전송한다.
    1. 조정자는 계정A가 포함된 디비에 A잔액을 1달러 감소시키는 트랜잭션을 실행한다.
    2. 조정자는 계정C가 포함된 디비에는 아무 작업도 하지 않는다.
      조정자가 디비에 NOP(No Operation; 아무 작업도 하지 않는 연산)명령을 보낸다고 가정하자. 디비는 NOP 명령에 대해 아무 작업도 수행하지 않으며 항상 성공했다는 응답을 보낸다.
  • 두번째 단계 (Confirm: 확정 단계)

    : 확정 두 디비가 모두 예라고 응답하면 지갑 서비스는 확정 단계를 시작한다.
    -계정 A 잔액은 이미 첫번째 단계에서 갱신되어서 잔액을 변경할 필요가 없다.
    • 그러나 C는 아직 1달러를 받지 못했다. 따라서 확인 단계에서 지갑서비스는 계정C 잔액에 1달러를 추가해야한다.
  • 두번째 단계 (Cancel: 취소 단계)

    첫번째 시도 단계가 실패하면 어떻게 해야 할까?
    • 위 예에서 계정 C에 대한 NOP 작업이 항상 성공한다고 가정했지만, 실제론 실패할 수도 있다. 예를들어, C계정이 불법계정이어서 자금 유입/유출을 막도록 하고 있다던가... 그런 경우엔 분산 트랜잭션을 취소하고 관련된 자원을 반납해야 한다!
    • 시도 단계의 트랜잭션에서 A계정 잔액은 이미 바뀌었고 트랜잭션은 종료되었다. 이미 종료된 트랜잭션 효과를 되돌리려면 지갑 서비스는 또다른 트랜잭션을 시작해서 계정A에 1달러를 다시 추가해야 한다.
    • 시도 단계에서 계정C 잔액은 업데이트 하지 않았으므로, 계정C 데이터베이스에는 NOP 명령을 전송하기만 하면 된다.

2PC 와 TC/C 비교

2PC

  • 두번째 단계가 시작될 때 모든 로컬 트랜잭션이 완료되지 않은 상태다. (락도 여전히 잠겨 있음)
  • 즉, 2PC 두번째 단계는 미완성 트랜잭션을 중단하거나 커밋하여 끝낸다.

TC/C (보상 기반 분산 트랜잭션)

  • 두번째 단계가 시작될 때 모든 로컬 트랜잭션이 완료된 상태다.
    (락도 풀린 상태)
  • 즉, TC/C 두번째 단계는 오류가 발생했을 때 이전 트랜잭션 결과를 상쇄하는 새로운 트랜잭션을 실행한다.
  • TC/C는 보상 기반 분산 트랜잭션이라고도 부른다.
  • 실행 취소(undo) 절차를 비즈니스 로직으로 구현하므로 고수준 해법이다.
  • 정점 : 특정 데이터베이스에 구애받지 않는다.
    • 트랜잭션을 지원하는 디비이기만 하다면 TC/C 는 작동한다.
  • 단점 : 애플리케이션 계층의 비즈니스 로직에서 세부 사항을 관리하고 분산 트랜잭션의 복잡성을 처리해야 한다.

단계별 상태 테이블 (phase status table)

  • TC/C 실행 도중 지갑 서비스가 다시 시작된다면?
  • 과거 모든 작업 기록이 사라질 수 있으며, 어떻게 복구해야 할 지 알 수 없게 된다.
  • 해결책은 간단하다.
    TC/C 진행 상황, 각 단계 상태 정보를 트랜잭션 데이터베이스에 저장하면 된다.
    이 상태정보는 최소한 다음을 포함해야 한다.
    • 분산 트랜잭션 ID, 내용
    • 각 디비에 대한 시도(Try) 단계 상태.
      not sent yet, has been sent, response received 세가지 값 중 하나다.
    • 두번째 단계 이름. Confirm / Cancel 둘 중 하나.
      시도 단계의 결과를 사용해서 계산할 수 있다.
    • 두번째 단계의 상태.
    • 순서가 어긋났음을 나타내는 플래그.

단계별 상태 테이블은 어디에 저장하는게 좋을까?

일반적으로 돈을 인출할 지갑 계정이 있는 디비에 둔다.

불균형 상태

  • 시도 단계가 끝나고 나면 1달러가 사라진다는 것을 알아챘는가?
  • 모든 것이 순조롭게 진행된다고 가정하자. 시도 단계가 끝나고 나면 계정A에서는 1달러가 차감, 계정C는 변화가 없다.
  • A,C 계정 잔액 합은 0이다. TC/C 시작 시점보다 적은 값이다.
    이는 거래 후에도 잔액 총합은 동일해야 한다는 회계 기본 원칙을 위반한다.
  • 다행스럽게도 트랜잭션 보증은 TC/C 방안에서도 여전히 유효하다. TC/C는 여러개의 독립적인 로컬 트랜잭션으로 구성된다. TC/C의 실행주체는 애플리케이션이며, 애플리케이션은 이런 독립적인 로컬 트랜잭션이 만드는 중간 결과를 볼 수 있다.
  • 반면, 디비 트랜잭션이나 2PC 같은 분산 트랜잭션의 경우실행 주체가 디비애플리케이션이 중간 실행 결과를 알 수 없다.
  • 분산 트랜잭션 실행 도중엔 항상 데이터 불일치가 발생하며, 디비 같은 하위 시스템에서 불일치를 수정하는 경우엔 그 사실을 알필요는 없지만 TC/C 같은 메커니즘을 사용하는 경우엔 우리가 직접 처리해야 한다!

유효한 연산 순서

  • 시도 단계에서 할 수 있는 일은 3가지다.
  • 선택2 : 계정C 연산은 성공했으나 A는 실패했다. 지갑서비스는 취소 단계를 실행해야 한다. 그러나,,, 취소단계 실행 전에 누군가가 C계정에서 1달러를 이미 이체했다면??? 나중에 지갑 서비스가 C에서 1달러를 차감하려고 하면 아무것도 남지 않은 것을 발견할텐데, 이는 분산 트랜잭션의 트랜잭션 보증을 위반하는 것이다.
  • 선택3 : A에서 1달러를 차감하고, 동시에 C에 추가하면 많은 문제가 발생할 수 있다. 예를 들어, C에는 추가되었으나 A에서 실패했다면?

→ ✅ 선택2, 선택3은 유효하지 않다. 선택1 만이 올바른 방법이다...

잘못된 순서로 실행된 경우

  • TC/C 에는 실행 순서가 어긋날 수 있다는 문제가 있다.
  • 계정A에서 C로 1달러 이체 예제를 계속 보겠다.
  • 시도 단계에서 A에 대한 작업이 실패해서, 지갑 서비스에 실패를 반환한 다음 취소 단계로 진입해서 계정A, C 모두에 취소 명령을 전송한다.
  • 이때 계정C를 관리하는 디비에 네트워크 문제가 있어서 시도 명령 전에 취소 명령부터 받았다고 가정하자. 그 시점엔 취소할게 없는 상태다.
  • 순서가 바뀌어도 도착하는 명령도 처리할 수 있도록 하려면 기존 로직을 수정하면 된다.
    • 취소 명령이 먼저 도착하면, 디비에 아직 상응하는 시도 명령을 못보았음을 나타내는 플래그를 참으로 설정해서 저장한다.
    • 시도 명령이 도착하면 항상 먼저 도착한 취소 명령이 있는지? 확인한다. 있었으면 바로 실패를 반환한다.
  • "단계별 상태 테이블" 절에서 테이블 순서가 어긋난 경우를 처리하기 위한 플래그를 마련했던 것은 바로 이런 이유에서다!!

분산 트랜잭션: 사가(Saga)

선형적 명령 수행

  • Saga는 유명한 분산 트랜잭션 솔루션 가운데 하나로 MSA에서는 사실상 표준이다.
  • 개념
    1. 모든 연산은 순서대로 정렬된다. 각 연산은 자기 데이터베이스에 독립 트랜잭션으로 실행된다.
    2. 연산은 첫번째부터 마지막까지 순서대로 실행된다.
      한 연산이 완료되면 다음 연산이 개시된다.
    3. 연산이 실패하면 전체 프로세스는 실패한 연산부터 맨 처음 연산까지 역순으로 보상 트랜잭션을 통해 롤백된다. 따라서 n개 연산을 실행하는 분산 트랜잭션은, 보상 트랜잭션을 위한 n개 연산까지 총 2n개의 연산을 준비해야 한다.

연산 실행 순서 조율 방법

  1. 분산 조율(Choreography, '안무') : MSA에서 Saga 분산 트랜잭션에 관련된 모든 서비스가 다른 서비스의 이벤트를 구독해서 작업을 수행하는 방식이다. 완전히 탈 중앙화된 조율 방식이다.
    → 비동기 통신, 내부적으로 상태 기계를 유지해야 한다.
  2. 중앙 집중형 조율(Orchestration) : 하나의 조정자가 모든 서비스가 올바른 순서로 작업을 실행하도록 조율한다.
  • 어떤 방식으로 조율할지는 사업상의 필요, 목표에 따라 정한다.
  • 분산 조율 방식은 서비스가 서로 비동기식으로 통신하기 때문에, 모든 서비스는 다른 서비스가 발생시킨 이벤트의 결과로 어떤 작업을 수행할지 정하기 위해 내부적으로 상태 기계(state machine)을 유지해야 한다.
    • 서비스가 많으면 관리가 어려워질 수 있는 부분이다.
  • 일반적으로는 중앙 집중형 조율 방식을 선호하는데, 복잡한 상황을 잘 처리하기 때문이다.

TC/C 와 Saga 비교 ✅

  • 둘 다 모두 애플리케이션 수준의 분산 트랜잭션이다.
  • 실무에서 둘 가운데 어떤 방안이 더 좋을까?
    지연시간 요구사항에 따라 다르다. Saga의 연산은 순서대로 실행되어야 하고, TC/C에서는 병렬로 실행할 수 있다.
  • 따라서 다음 요건을 고려해 결정을 내려야 한다.
    1. 지연 시간 요구사항이 없거나, 송금 사례처럼 서비스 수가 매우 적다면 아무거나 사용하면 된다. MSA에서 흔히 하는대로 하고 싶다면 Saga를 선택하면 된다.
    2. 지연 시간에 민감하고, 많은 서비스/운영이 관계된 시스템이라면 TC/C가 더 낫다.

👩🏻‍💼(지원자) : 잔액 이체를 원자적 트랜잭션으로 처리하려면 레디스를 관계형 디비로 대체하고, TC/C 나 Saga를 사용해서 분산 트랜잭션을 구현하면 될 것 같습니다.

👨🏻‍💻(면접관) : 좋은 생각입니다! 그런데 분산 트랜잭션 방안도 제대로 작동하지 않는 경우가 있을 수 있죠... 예를 들어 사용자가 애플리케이션 수준에서 잘못된 작업을 입력할 수도 있습니다. 입력된 금액 자체가 잘못될 수도 있죠. 그러니 문제의 근본 원인을 역추적하고 모든 계정에서 발생하는 연산을 감사할 방법이 있다면 좋을 텐데, 어떻게 하면 좋을까요?

이벤트 소싱 ⭐️

배경

  • 실제로 전자 지갑 서비스 제공 업체도 감사를 받을 수 있다. 외부 감사는 다음과 같은 까다로운 질문들을 던질 수 있는데,,,
    1. 특정 시점의 계정 잔액을 알 수 있나요?
    2. 과거 및 현재 계정 잔액이 정확한지 어떻게 알 수 있나요?
    3. 코드 변경 후에도 시스템 로직이 올바른지 어떻게 검증하나요?
  • 이러한 질문에 체계적으로 답할 수 있는 설계 철학 중 하나는 "도메인 주도 설계(DDD)"에서 개발된 기법인 이벤트 소싱이다. ✅

정의

  • 이벤트 소싱에는 4가지 중요한 용어가 있다.
    1. 명령 (Command)
    2. 이벤트 (Event)
    3. 상태 (State)
    4. 상태 기계 (State machine)

명령 (Command)

  • 명령은 외부에서 전달된, 의도가 명확한 요청이다.
  • 예를 들어, 고객 A에서 C로 1달러를 이체하라는 요청은 명령이다.
  • 이벤트 소싱에서 순서는 아주 중요하다!
    따라서 명령은 일반적으로 FIFO 큐에 저장된다.

이벤트 (Event)

  • 명령은 의도가 명확하지만, 사실은 아니기때문에 유효하지 않을 수 있다.
  • 유효하지 않은 명령은 실행할 수 없다.
  • 가령 이체 후 잔액이 음수가 된다? 이체는 실패한다.
  • 작업 이행 전 반드시 명령의 유효성을 검사해야 한다. 그리고 검사를 통과한 명령은 반드시 이행되어야 한다.
  • 명령 이행 결과를 이벤트라고 부른다.
  • 명령과 이벤트 사이에는 두가지 중요한 차이점이 있다.
    1. 이벤트는 검증된 사실로, 실행이 끝난 상태다.
    그래서 이벤트에 대해 이야기할 땐 과거 시제를 사용한다. 따라서 명령이 "A에서 C로 1달러 송금"인 경우, 이벤트는 "A에서 C로 1달러 송금을 완료하였음" 이 된다.
    2. 명령에는 무작위성이나 I/O가 포함될 수 있으나, 이벤트는 결정론적이다! 이벤트는 과거에 실제로 있었던 일이다.
  • 이벤트 생성 프로세스에는 2가지 중요한 특성이 있다.
    1. 하나의 명령으로 여러 이벤트가 만들어질 수 있다.(0이상)
    2. 이벤트 생성 과정에는 무작위성이 개입될 수 있어서, 같은 명령에 항상 동일한 이벤트들이 만들어진다는 보장이 없다. 이벤트 생성 과정에는 외부 I/O 또는 난수가 개입될 수 있다.
  • 이벤트 순서는 명령 순서를 따라야 하므로 이벤트도 FIFO 큐에 저장한다.

상태 (State)

  • 상태는 이벤트가 적용될 때 변경되는 내용이다.
  • 지갑 시스템에서 상태는 모든 클라이언트 계정의 잔액으로, 맵 자료구조로 사용하여 표현할 수 있다.
  • 키는 계정 이름 또는 ID고, 값은 계정 잔액이다.
  • 이 자료구조의 저장소로는 보통 키-값 저장소를 사용한다.
  • 관계형 디비도 키-값 저장소로 불릴 수 있는데, 그 경우에 키는 기본키고 값은 레코드다.

상태 기계 (State machine)

  • 상태 기계는 이벤트 소싱 프로세스를 구동한다.
  • 크게 두가지 기능이 있다.
    1. 명령의 유효성을 검사하고 이벤트를 생성한다.
    2. 이벤트를 적용해서 상태를 갱신한다.
  • 이벤트 소싱을 위한 상태 기계는 결정론적으로 동작해야 한다.
  • 따라서 무작위성을 내포할 수 없다.
  • 예를 들어, I/O를 통해 외부에서 무작위적 데이터를 읽거나 난수를 사용하는 것은 허용되지 않는다. 이벤트를 상태에 반영하는 것 또한 항상 같은 결과를 보장해야 한다.
  • 명령을 이벤트로 변환하고 이벤트를 적용하는 2가지 기능을 지원해야 하므로, 명령 유효성 검사를 위한 상태기계 하나와 이벤트 적용을 위한 상태기계 하나를 두었다.
  • 여기에 시간을 하나의 차원으로 추가하면 동적 관점으로도 표현할 수 있다. 명령을 수신하고 처리하는 과정을 계속 반복하는 시스템이다.

지갑 서비스 예시

  • 지갑 서비스의 경우 명령은 "이체 요청"일 것이다.
  • 명령은 FIFO 큐에 기록하며, 큐로는 카프카를 널리 사용한다.
  • 상태, 즉 계정 잔액관계형 디비에 있다고 가정하자.
  • 상태 기계는 명령을 큐에 들어간 순서대로 확인한다. 명령을 하나 읽을 때마다 계정에 충분한 잔액이 있는지 체크한다.
  • 충분하다면 상태기계는 각 계정에 대한 이벤트를 만든다.
  • 예를 들어, 명령이 "A→1달러→C" 라면 상태기계는 "A:-1달러"와 "C:+1달러" 두 이벤트를 만든다.

상태기계의 5단계 동작 원리


1. 명령 대기열에서 명령을 읽는다.
2. 디비에서 잔액 상태를 읽는다.
3. 명령의 유효성을 체크한다. 유효하면 계정별 이벤트를 생성한다.
4. 다음 이벤트를 읽는다.
5. 디비의 잔액을 갱신해서 이벤트 적용을 마친다.

재현성

  • 이벤트 소싱이 다른 아키텍처에 비해 갖는 가장 중요한 장점은, 재현성 이다.
  • 앞서 언급한 분산 트랜잭션 방안의 경우 지갑 서비스는 갱신한 계정 잔액(즉, 상태)를 데이터베이스에 저장한다.
  • 계정 잔액이 변경된 이유는 알기가 어렵다. 또한 한번 업데이트되고 나면 과거 잔액이 얼마였는지 알 수 없다. 데이터베이스는 특정 시점의 잔액이 얼마인지만 보여준다.
  • 하지만 이벤트를 처음부터 다시 재생하면, 과거 잔액 상태는 언제든 재구성할 수 있다. 이벤트 리스트는 불변이고, 상태 기계 로직은 결정론적이므로 이벤트 이력을 재생하여 만들어낸 상태는 언제나 동일하다!
  • 재현성을 갖추면 감사관이 던지는 까다로운 질문에 쉽게 답할 수 있다.
    1. 특정 시점의 계정 잔액을 알 수 있나요?
      → 시작부터 계정 잔액을 알고 싶은 시점까지 이벤트를 재생하면 알 수 있다.
    2. 과거 및 현재 계정 잔액이 정확한지 어떻게 알 수 있나요?
      → 이벤트 이력에서 계정 잔액을 다시 계산해보면 잔액이 정확한지 알 수 있다.
    3. 코드 변경후에도 시스템 로직이 올바른지 어떻게 증명할 수 있나요?
      → 새로운 코드에 동일한 이벤트 이력을 입력으로 주고 같은 결과가 나오는지 보면 된다.
  • 감사 기능 시스템이어야 한다는 요건 때문에 이벤트 소싱이 지갑 서비스 구현의 실질적인 솔루션으로 채택되는 경우가 많다.✅

명령-질의 책임 분리 (CQRS; Command-Query Responsibility Separation) ⭐️

  • 지금까지 효과적인 계좌 이체가 가능한 지갑 서비스를 설계했다. 하지만 클라이언트는 여전히 계정 잔액을 알 수 없다.
  • 이벤트 소싱 프레임워크 외부의 클라이언트가 상태(즉, 잔액)을 알도록 할 방법이 필요하다.
  • 직관적인 해결책 하나는 상태 이력 디비의 읽기 전용 사본을 생성한 다음 외부와 공유하는 것이다. But, 이벤트 소싱은 이와 조금 다른 해결책을 제시한다.
  • 이벤트 소싱은 상태(즉, 잔액)을 공개하는 대신 모든 이벤트를 외부에 보낸다. 따라서 이벤트를 수신하는 외부 주체가 직접 상태를 재구축할 수 있다.
  • 이런 설계 철학을 명령-질의 책임 분리 (CQRS)라고 한다.
  • CQRS 에서는 상태 기록을 담당하는 상태기계는 하나고, 읽기 전용 상태기계는 여러개 있을 수 있다. 읽기 전용 상태 기계는 상태 뷰(view)를 만들고, 이 뷰는 질의(query)에 이용된다.

CQRS 아키텍처

  • 읽기 전용 상태 기계는 이벤트 큐에서 다양한 상태 표현을 도출할 수 있다.
  • 예를 들어, 클라이언트의 잔액 질의 요청을 처리하기 위해 별도 데이터베이스에 상태를 기록하는 등의 작업을 할 수도 있다.
  • 이중 청구 문제를 쉽게 조사할 수 있도록 하기 위해 특정한 기간 동안의 상태를 복원할 수도 있다.
  • 이렇게 복원된 상태 정보는 재무 기록과 대조할 감사 기록으로 활용 가능하다.
  • 읽기 전용 상태 기계는 실제 상태에 어느 정도 뒤처질 수 있으나, 결국에는 같아진다.
  • 따라서 결과적 일관성 모델을 따른다 할 수 있다.

👩🏻‍💼(지원자): 이벤트 소싱 아키텍처를 사용하면 전체 시스템에 재현성을 확보할 수 있습니다. 모든 유효한 사업 기록은 변경이 불가한 이벤트 대기열에 저장되어, 정확성 검증에 사용될 수 있습니다.

👨🏻‍💻(면접관) : 훌륭하네요~ 하지만 제안하신 이벤트 소싱 아키텍처는 한번에 하나의 이벤트만 처리하는 데다 여러 외부 시스템과 통신해야 합니다. 더 빠르게 만들 수 있을까요?

3단계: 상세 설계

  • 높은 성능안정성확장성을 달성하기 위한 기술에 대해 자세히 살펴보자.

고성능 이벤트 소싱

  • 앞선 예제에서 카프카를 명령 및 이벤트 저장소로, 데이터베이스를 상태 저장소로 사용했다. 가능한 몇가지 최적화 방안을 살펴보자.

파일 기반의 명령 및 이벤트 목록

  • 명령과 이벤트를 카프카 같은 원격 저장소가 아닌 로컬 디스크에 저장하는 방안을 생각해 볼 수 있다.
  • 이렇게 하면 네트워크를 통한 전송 시간을 피할 수 있다.
  • 이벤트 목록은 추가 연산만 가능한 자료구조에 저장한다.
  • 추가는 순차적 쓰기 연산으로, 일반적으로 매우 빠르다.
  • 운영체제는 보통 순차적 읽기/쓰기 연산에 엄청나게 최적화되어 있어서 HDD에서도 잘 작동한다.
  • 순차적 디스크 접근은 경우에 따라 무작위 메모리 접근보다도 빠르게 실행될 수 있다.
  • 최근 명령과 이벤트를 메모리에 캐시하는 방안도 생각해볼 수 있다. 앞서 설명했듯 명령, 이벤트는 지속성 저장소에 보관된 이후 처리된다.
  • 메모리에 캐시해 놓으면 로컬 디스크에서 다시 로드하지 않아도 된다.
  • 구체적인 구현 방법도 몇가지 살펴보자.
    • mmap 기술은 앞서 언급한 최적화 구현에 유용하다.
      로컬 디스크에 쓰는 동시에, 최근 데이터는 메모리에 자동으로 캐시할 수 있다.
      mmap은 디스크 파일을 메모리 배열에 대응시킨다. 운영체제는 파일의 특정 부분을 메모리에 캐시해서 읽기/쓰기 연산 속도를 높인다. 추가만 가능한 파일에 이루어지는 연산의 경우 필요한 모든 데이터는 거의 항상 메모리에 있으므로 실행 속도를 높일 수 있다.

파일 기반 상태

  • 이전 설계안에서 상태(잔액) 정보를 관계형 디비에 저장했다.
  • 프로덕션 환경에서 일반적으로 네트워크를 통해서만 접근 가능한 독립형 서버에서 디비를 실행한다. 그러나 명령, 이벤트 저장소 최적화 방안과 마찬가지로 상태 정보도 로컬 디스크에 저장할 수 있다.
  • 좀 더 구체적으로 이야기하면, 파일 기반 로컬 관계형 디비 SQLite를 사용하거나, 로컬 파일 기반 키-값 저장소 RocksDB 를 사용할 수 있다.
  • 본 설계안에선 RocksDB를 사용할 터인데, 쓰기 작업에 최적화된 자료구조 LMS(Log-Structured Merge-Tree)를 사용하기 때문이다. 최근 데이터는 캐시해서 읽기 성능을 높인다.

스냅숏 ⭐️

  • 모든 것이 파일 기반일 때 재현 프로세스의 속도를 높일 방법을 생각해보자.
  • 재현성이란 개념을 처음 소개했을 때, 재현성 확보를 위해 사용한 방법은 상태 기계로 하여금 이벤트를 항상 처음부터 다시 읽도록 하는 것이었다.
  • 그대신, 주기적으로 상태기계를 멈추고 현재 상태를 파일에 저장한다면, 시간을 절약할 수 있을 것이다! 이 파일이 스냅숏이다.
  • 스냅숏은 과거 특정 시점의 상태로, 변경 불가하다. 스냅숏을 저장하고나면 상태 기계는 더이상 최초 이벤트에서 시작할 필요가 없다. 스냅숏을 읽어 어느 시점에 만들어졌는지 확인한 다음, 그 시점부터 이벤트 처리를 시작하면 된다.
  • 스냅숏은 거대한 바이너리 파일이며, 일반적으로 HDFS(Hadoop Distributed File System)같은 객체 저장소에 저장한다.

지원자 : 명령 목록, 이벤트 목록, 상태, 스냅숏 모두 파일에 저장되도록 이벤트 소싱 아키텍처를 변경할 수 있습니다. 이벤트 소싱 아키텍처는 애초에 이벤트 목록을 선형적으로 처리하므로 HDD, 운영체제 캐시와 궁합이 잘 맞습니다.

면접관 : 로컬 파일 기반 솔루션의 성능은 원격 카프카나 디비에 저장된 데이터를 액세스하는 시스템보단 좋다고 할 수 있습니다. 하지만 로컬 디스크에 데이터를 저장하는 서버는 더이상 무상태 서버가 아닌데다, 단일 장애 지점이 된다는 문제가 있죠. 안정성은 어떻게 개선할 수 있을까요?

신뢰할 수 있는 고성능 이벤트 소싱

시스템의 어느 부분에 신뢰성 보장이 필요할까?

신뢰성 분석

  • 개념적으로 보자면 서버 노드가 하는 일은 데이터, 연산 이라는 두가지 개념에 관계되어 있다.
  • 그러나 데이터 내구성이 보장되는 한, 계산 결과는 코드를 다른 노드에서 돌리면 복구할 수 있다.
  • 즉,데이터 신뢰성이 훨씬 중요하다.
  • 데이터가 손실되면 계산 결과도 복원할 방법이 없다. 그러므로 시스템 신뢰성 문제는 대부분 데이터 신뢰성 문제다.
  • 지금 설계하는 시스템에는 4가지 유형의 데이터가 있다.
    1. 파일 기반 명령
    2. 파일 기반 이벤트
    3. 파일 기반 상태
    4. 상태 스냅숏

각각의 신뢰성 보장 방법을 살펴보자.

상태, 스냅숏

  • 상태와 스냅숏은 이벤트 목록을 재생하면 언제든 다시 만들수 있다.
  • 그러니 상태, 스냅숏의 안정성을 향상시키려면 이벤트 목록의 신뢰성만 보장하면 된다.

명령어

  • 이제 명령어를 살펴보자. 이벤트는 명령어에서 만들어지니 명령의 신뢰성만 강력하게 보장하면 충분하지 않나? 생각할수도 있다. 맞는말 같지만 아니다.
  • 이벤트 생성은 결정론적 과정이 아니며, 난수/외부 입출력 등 무작위적 요소가 포함될 수 있으므로 명령의 신뢰성 만으로는 이벤트 재현성을 보장할 수 없다.

이벤트 ✅

  • 이벤트는 상태(잔액)에 변화를 가져오는 과거의 사실이다.
  • 이벤트는 불변이며 상태 재구성에 사용할 수 있다.
  • 따라서 높은 신뢰성을 보장할 유일한 데이터는 "이벤트"다.

합의; 이벤트를 여러 노드에 복제 - 래프트 알고리즘

  • 높은 안정성을 제공하려면 이벤트 목록을 여러 노드에 복제해야 한다.
  • 복제 과정
    1. 데이터 손실 없음.
    2. 로그 파일 내 데이터의 상대적 순서는 모든 노드에 동일.
  • 이 목표를 달성하는 데 합의 기반 복제 방안이 적합하다.
  • 이 알고리즘은 모든 노드가 동일한 이벤트 목록에 합의하도록 보장한다.
  • 예시: "래프트 알고리즘"
    • 노드의 절반 이상이 온라인 상태면, 그 모두에 보관된 추가 전용(append-only) 리스트는 같은 데이터를 가진다.
    • 예를 들어, 5개 노드가 있을 때 래프트 알고리즘을 사용해서 데이터를 동기화하면 최소 3개 노드만 온라인 상태면 전체 시스템은 정상 동작한다.
    • 래프트 알고리즘에서 노드는 3가지 역할을 가질 수 있다.
      1. 리더
      2. 후보
      3. 팔로워
    • 래프트 알고리즘에서는 최대 하나의 노드만 클러스터의 리더가 되며 나머지 노드는 팔로워가 된다.
    • 리더는 외부 명령을 수신하고 클러스터 노드간 데이터를 안정적으로 복제하는 역할을 담당한다.
    • 래프트 알고리즘을 사용하면 과반수 노드가 작동하는 한 시스템은 안정적이다.

고신뢰성 솔루션

  • 복제 매커니즘을 활용하면 파일 기반 이벤트 소싱 아키텍처에서 단일 장애 지점(SPOF) 문제를 없앨 수 있다.
  • 래프트 노드 그룹
    • 3개의 이벤트 소싱 노드가 있다. 이 노드들은 래프트 알고리즘을 사용해서 이벤트 목록을 안정적으로 동기화한다.
    • 리더는 외부 사용자로부터 들어오는 명령 요청을 받아서 이벤트로 변환하고, 로컬 이벤트 목록에 추가한다.
    • 래프트 알고리즘은 새로운 이벤트를 모든 팔로워에 복제한다.
    • 팔로워를 포함한 모든 노드가 이벤트 목록을 처리하고 상태를 업데이트 한다. 래프트 알고리즘은 리더와 팔로워가 동일한 이벤트 목록을 갖도록 하며, 이벤트 소싱은 동일한 이벤트 목록에서 항상 동일한 상태가 만들어지도록 한다.
    • 안정적인 시스템은 장애를 원활하게 처리해야 하므로, 노드 장애가 어떻게 처리되는지 살펴보자.

노드 장애 처리 과정

  • 리더에 장애가 발생하면, 래프트 알고리즘은 나머지 정상 노드 중 새 리더를 선출한다.
  • 새 리더는 외부 사용자로부터 오는 명령을 수신할 책임을 진다.
  • 한 노드가 다운되어도 클러스터는 계속 서비스를 제공할 수 있다.
  • 유의할 것은 리더 장애가 명령 목록이 이벤트로 변경되기 전 발생할 수 있다는 것이다.
  • 그런 경우엔 클라이언트는 timeout 또는 오류 응답을 받는다. 따라서 클라이언트는 새로 선출된 리더에게 같은 명령을 다시 보내야 한다.
  • 팔로워 장애는 처리하기가 훨씬 쉬운데, 팔로워에 장애가 생기면 해당 팔로워로 전송된 요청은 실패한다.
  • 래프트는 죽은 노드가 다시 시작되거나 새로운 노드로 대체될 때까지 기한 없이 재시도하며 해당 장애를 처리한다.

지원자: 래프트 합의 알고리즘을 사용해서 여러 노드에 이벤트 목록을 복제하였습니다. 명령 수신과 복제는 리더가 담당합니다.

면접관: 네, 시스템 안정성과 결함 내성이 향상되었네요. 하지만 백만 TPS를 처리하려면 서버 한대로는 충분치 않은데, 어떻게 하면 확장성을 높일 수 있을까요?

분산 이벤트 소싱

  • 앞 절에서 안정적인 고성능 이벤트 소싱 아키텍처를 구현하는 방법을 설명했다. 이 아키텍처는 신뢰성 문제는 해결하지만 다른 문제가 있다.
  1. 전자 지갑 업데이트 결과는 즉시 받고 싶다.
    하지만 CQRS 시스템에선 요청/응답 흐름이 느릴 수 있다. 클라이언트가 디지털 지갑의 업데이트 시점을 정확히 알 수 없어서 주기적 폴링에 의존해야 할 수 있다.
  2. 단일 래프트 그룹의 용량은 제한이 있다. 일정 규모 이상에선 데이터를 샤딩하고 분산 트랜잭션을 구현해야 한다.

풀 vs 푸시

  • 풀 모델에서는 외부 사용자가 읽기 전용 상태 기계에서 주기적으로 실행 상태를 읽는다.
  • 이 모델은 실시간이 아니며, 읽는 주기를 너무 짧게 설정하면 지갑 서비스에 과부하가 걸릴 수 있다.
  • 이 단순한 풀 모델은 외부 사용자와 이벤트 소싱 노드 사이에 리버스 프록시를 추가하면 개선할 수 있다.
  • 외부 사용자는 리버스 프록시에 명령을 보내고,
    리버스 프록시는 명령을 이벤트 소싱 노드로 전달하는 한편
    주기적으로 실행 상태를 질의한다.
  • 이렇게 해도 여전히 통신이 실시간으로 이루어지지는 않지만, 클라이언트의 로직이 단순해진다.
  • 그런데, 이렇게 리버스 프록시를 두고 나면 읽기 전용 상태 기계를 수정해서 응답 속도를 높일 수 있다. 읽기 전용 상태 기계가 자기만의 특별한 로직을 가질 수 있다고 했다.
  • 가령 읽기 전용 상태 기계로 하여금 이벤트를 수신하자마자 실행 상태를 리버스 프록시에 푸시한다고 해보자.
  • 그렇게 하면 사용자에게 실시간으로 응답이 이루어지는 느낌을 줄 수 있다.

최종 설계 (분산 트랜잭션)

  • 모든 이벤트 소싱 노드 그룹이 동기적 실행 모델을 채택하면 TC/C나 Saga 같은 분산 트랜잭션 솔루션을 재사용할 수 있다.
  • 키의 해시값을 2로 나눠서 데이터가 위치할 파티션을 정한다고 가정해보자.
  • 이 최종 분산 이벤트 소싱 아키텍처에서 이체는 어떻게 이루어지는지 보자. 이해하기 쉽도록 Saga 분산 트랜잭션 모델을 사용해서 롤백없이 정상 실행이 이루어지는 경로만 설명하겠다.
  • 송금 연산에는 2개의 분산 연산이 필요하다
    A:-1달러, C:+1달러.
  • Saga 조정자는 다음과 같이 실행을 조율한다.
  1. 사용자A가 Saga 조정자에게 분산 트랜잭션을 보낸다.
    두개의 연산이 들어있다. (A:-1, C:+1)
  2. Saga 조정자는 단계별 상태 테이블에 레코드를 생성해서 트랜잭션 상태를 추적한다.
  3. Saga 조정자는 작업 순서를 검토한 후, A:-1 먼저 처리하기로 결정한다. 조정자는 A:-1 명령을 계정A 정보가 들어있는 파티션1로 보낸다.
  4. 파티션1의 래프트 리더는 A:-1 명령을 수신하고, 명령 목록에 저장한다. 그런 다음 명령의 유효성을 검사한다. 유효하다면 이벤트로 변환한다. 래프트 합의 알고리즘은 여러 노드 사이 데이터를 동기화하기 위한 것이다. 동기화가 완료되면 이벤트가 실행된다. (A계정에서 1달러 차감)
  5. 이벤트 동기화가 완료되면 파티션1의 이벤트 소싱 프레임워크가 CQRS를 사용해서 데이터 읽기 경로로 동기화한다. 읽기 경로는 상태 및 실행 상태를 재구성한다.
  6. 파티션1의 읽기 경로는 이벤트 소싱 프레임워크를 호출한 Saga 조정자에 상태를 푸시한다.
  7. 사가 조정자는 파티션1에서 성공 상태를 수신한다.
  8. 사가 조정자는 단계별 상태 테이블에 파티션1 작업이 성공했음을 나타내는 레코드를 생성한다.
  9. 첫번째 작업이 성공했으므로 사가 조정자는 두번째 작업 C:+1 을 실행한다. 조정자는 계정C정보가 포함된 파티션2에 C:+1명령을 보낸다.
  10. 파티션2의 래프트 리더가 C:+1 명령을 수신해서 명령 목록에 저장한다. 유효한 명령으로 확인되면 이벤트로 변환하고 래프트 합의 알고리즘이 여러노드에 데이터를 동기화한다. 동기화가 끝나면 해당 이벤트가 실행된다. (C계정에 1달러 추가)
  11. 이벤트가 동기화되면 파티션2의 이벤트 소싱 프레임워크는 CQRS를 사용해서 데이터를 읽기 경로로 동기화한다. 읽기 경로는 상태 및 실행 상태를 재구성한다.
  12. 파티션2의 읽기 경로는 이벤트 소싱 프레임워크를 호출한 사가 조정자에 상태를 푸시한다.
  13. 사가 조정자는 파티션2로부터 성공 상태를 받는다.
  14. 사가 조정자는 단계별 상태 테이블에 파티션2의 작업이 성공했음을 나타내는 레코드를 생성한다.
  15. 이때 모든 작업이 성공하고 분산 트랜잭션이 완료된다. 사가조정자는 호출잘에게 결과를 응답한다.

4단계: 마무리

  • 1번째 설계안 : 레디스 같은 인메모리 키-값 저장소를 사용하는 솔루션.
    → 데이터 내구성이 없다는 문제점.
  • 2번째 설계안 : 인메모리 캐시를 트랜잭션 디비로 바꿨다. 여러 노드에 걸친 분산 트랜잭션을 지원하기 위한 2PC, TC/C, Saga 같은 다양한 트랜잭션 프로토콜을 살펴봤다.
    → 트랜잭션 기반 솔루션의 가장 큰 문제는 데이터 감사가 어렵다는 것이다...
  • 3번째 설계안 : 이벤트 소싱 방안
    첫 구현안은 외부 디비와 큐를 사용하는 것 → 성능이 좋지 않음.
    두번째 구현안은 명령,이벤트,상태 데이터를 로컬 파일 시스템에 저장하도록 하여 성능을 개선하는 방안.
  • 4번째 설계안 : 그러나 데이터를 한곳에 두면 SPOF 가 되는 문제가 있다. 시스템 안정성을 높이기 위해 래프트 합의 알고리즘을 사용해 이벤트 목록을 여러 노드로 복제하는 방안을 도입한다.
  • 마지막 설계안 : 개선한 사항은 이벤트 소싱에 CQRS 개념을 도입한다. 아울러 외부 사용자에게 비동기 이벤트 소싱 프레임워크를 동기식 프레임워크로 제공하기 위해 역방향 프록시를 추가했다. TC/C 또는 Saga 프로토콜을 사용해서 여러 노드에 명령 실행을 조율하는 방법도 소개했다.

2025.0203
2025.0211

profile
Fail Fast, Fail Often

0개의 댓글