12.전자 지갑

대규모 시스템 설계 기초 2nd 12장을 요약한 내용입니다.

결제 플랫폼은 일반적으로 고객에게 전자 지갑 서비스를 제공하여 고객으로 하여금 지갑에 돈을 넣어 두고 필요할 때 사용할 수 있도록 한다.

전자 지갑은 결제 기능뿐만 아니라 페이팔은 같은 플랫폼의 다른 사용자 지갑으로 직접 송금을 지원한다.

  • 전자 지갑 간 이체는 은행 간 이체보다 빠르며, 일반적으로 추가 수수료를 부과하지 않느다.

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

설계할 전자 지갑

  • 전자 지갑 간 이체

  • 1,000,000TPS

  • 99.99%의 안정성

  • 트랜잭션

  • 재현성

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

API 설계

API

API
기능

POST /v1/wallet/balance_transfer

한 지갑에서 다른 지갑으로 자금 이체

요청 인자

필드
설명
자료형

from_account

자금을 인출할 계좌

string

to_account

자금을 이체할 계좌

string

amount

이체할 금액

string

currency

통화 단위

string(ISO 4217)

transaction_id

중복 제거에 사용할 ID

uuid

응답 본문 사례:

{
    "status": "success"
    "transaction_id": "01589980-2434-33dx-1532-1423gb420530"
}

인메모리 샤딩

모든 사용자 계정의 잔액을 유지하는 지갑 애플리케이션은 <사용자, 잔액> 관계를 나타내기 좋은 자료구조로 해시 테이블(Map, Key-value 저장소)을 사용한다.

인메모리 저장소로 인기 있는 선택지는 레디스.

  • 그러나 레디스 노드 한 대로 100만 TPS 처리는 벅차다.

  • 클러스터를 구성하고 사용자 계정을 모든 노드에 균등하게 분산시켜야 한다.

  • 이 절차를 파티셔닝 또는 샤딩이라고 한다.

키-값 데이터를 n개의 파티션에 고르게 분배하려면 키의 해시 값을 계산하고, 이를 파티션의 수 n으로 나누는 것이 한 가지 방법

  • 그 결과로 얻은 나머지 값이 데이터를 저장할 파티션 번호

String accountID = "A";
Int partitionNumber = 7;
Int myPartition = accountID.hashCode() % partitionNumber;

모든 레디스 노드의 파티션 수 및 주소는 한군데 저장해 두는데, 높은 가용성을 보장하는 설정 정보 전문 저장소 주키퍼를 이 용도로 사용하면 좋다.

지갑 서비스의 중요한 역할

  1. 이체 명령의 수신

  2. 이체 명령의 유효성 검증

  3. 명령이 유효한 것으로 확인되면 이체에 관계된 두 계정의 잔액 갱신. (이 두 계정은 서로 다른 레디스 노드에 있을 수 있음)

메모리 기반 솔루션

분산 트랜잭션

데이터베이스 샤딩

서로 다른 두 개 저장소 노드를 갱신하는 연산을 원자적으로 수행하기 위한 방법

1단계. 각 레디스 노드를 트랜잭션을 지원하는 관계형 데이터베이스 노드로 교체하는 것

  • A, B, C의 잔액 정보가 레디스 노드가 아닌 3개의 관계형 데이터베이스 노드로 분산

1️⃣ 분산 트랜잭션: 2단계 커밋(2PC)

분산 시스템에서 한 트랜잭션에는 여러 노드의 프로세스가 관여할 수 있다.

  • 분산 트랜잭션은 이들 프로세스를 원자적인 하나의 트랜잭션으로 묶는 방안

  • 저수준 방안

    • 데이터베이스 자체에 의존하는 방안

    • 일반적으로 사용되는 2단계 커밋 알고리즘

    • 저수준 방안인 이유는, 준비 단계 실행을 위해 데이터베이스 스랜잭션 실행 방식을 변경해야 하기 때문

    • 다른 노드의 메시지를 기다리는 동안 락이 오랫동안 잠긴 상태로 남고, 조정자가 SPOF(Single-Point-Of-Failure, 단일 장애 지점)이 될 수 있다.

2️⃣ 분산 트랜잭션: TC/C

TC/C(시도-확정/취소, Try-Confirm/Cancel)는 두 단계로 구성된 보상 트랜잭션

  1. 조정자는 모든 DB에 트랜잭션에 필요한 자원 예약을 요청

  2. 조정자는 모든 DB로부터 회신을 받음

    • 모두 '예'라고 응답하면 조정자는 모든 DB에 작업 확인을 요청. 이것이 '시도-확정(Try-Confirm)' 절차

    • 어느 하나라도 '아니오'라고 응답하면 조정자는 모든 DB에 작업 취소를 요청하며, 이것이 '시도-취소(Try-Cancel)' 절차

✅ TC/C 사례

계좌 A에서 계좌 C로 1달러 이체한다는 가정

  • TC/C의 조정자: 지갑 서비스

  • 계정 A의 잔액: 1달러

  • 계정 C의 잔액: 0달러

첫 번째 단계: 시도

  • 시도 단계에서는 조정자 역할을 하는 지갑 서비스가 두 개의 트랜잭션 명령을 두 데이터베이스로 전송

    • (1) 조정자는 계정 A가 포함된 DB에 A의 잔액을 1달러 감소시키는 트랜잭션을 시작

    • (2) 조정자는 계정 C가 포함된 DB에 아무 작업도 하지 않음.

두 번째 단계: 확정

  • 두 DB가 모두 예라고 응답하면 지갑 서비스는 확정 단계를 시작

    • 계정 A의 잔액은 이미 첫 번째 단계에서 갱신

    • 환인 단계에서 지갑 서비스는 계정 C의 잔액에 $1를 추가

두 번째 단계: 취소

  • 첫 번째 시도 단계가 실패하면, 시도 단계에서 계정 C의 잔액은 업데이트하지 않았으므로, 계정 C의 DB에는 NOP 명령을 전송하기만 하면 된다.

2PC(2단계 커밋) vs TC/C

  • 2PC: 두 번째 단계가 시작될 때 모든 로컬 트랜잭션이 완료되지 않은 상태

    • 미완성된 트랜잭션을 중단하거나 커밋하여 끝냄

  • TC/C: 두 번째 단계가 시작될 때 모든 로컬 트랜잭션이 완료된 상태

    • 오류가 발생했을 때 이전 트랜잭션 결과를 상쇄하는 새로운 트랜잭션 실행

    • 보상 기반 분산 트랜잭션이라고 부름

    • undo 절차를 비즈니스 로직으로 구현하므로 구수준 해법

첫 번째 단계
두 번째 단계: 성공
두 번째 단계: 실패

2PC

로컬 트랜잭션은 아직 완료되지 않은 상태

모든 로컬 트랜잭션을 커밋

모든 로컬 트랜잭션을 취소

TC/C

모든 로컬 트랜잭션이 커밋되거나 취소된 상태로 종료

필요한 경우 새 로컬 트랜잭션 실행

이미 커밋된 트랜잭션의 실행 결과를 undo

단계별 상태 테이블

TC/C 실행 도중 지갑 서비스가 다시 시작될 경우

  • TC/C 진행 상황, 특히 각 단계 상태 정보를 트랜잭션 DB에 저장하자.

  • 이 상태 정보가 포함해야 할 최소 정보

    • 분산 트랜잭션의 ID와 내용

    • 각 DB에 대한 'Try' 단계의 상태.

      • not sent yet

      • has been sent

      • response received

    • 두 번째 단계의 이름(시도 단계의 결과를 사용하여 계산)

      • Confirm

      • Cancel

    • 두 번째 단계의 상태

    • 순서가 어긋났음을 나타내는 플래그

단계 상태 테이블은 일반적으로 돈을 인출할 지갑의 계정이 있는 DB에 둔다.

불균형 상태

분산 트랜잭션 실행 도중에는 항상 데이터 불일치가 발생

  • DB와 같은 하위 시스템에서 불일치를 수정하는 경우 그 사실을 알 필요는 없지만, 그렇지 않다면(TC/C 같은 메커니즘 사용 시) 우리가 직접 처리해야 한다.

불균형 상태의 사례

유효한 연산 순서

시도 단계에서 할 수 있는 일은 세 가지

  • 첫 번째: 올바른 방법

  • 두 번째:

    • 계정 C 연산은 성공했지만, 계정 A에서 실패한다면 지갑 서비스는 취소 단계를 실행.

    • 하지만, 취소 단계 실행 전 누군가 C 계정에서 $1를 이미 이체했다면 남는 돈이 없게 되므로, 분산 트랜잭션의 트랜잭션 보증을 위반

  • 세 번째:

    • $1를 A 계좌에서 차감하고 동시에 C에 추가하면 많은 문제가 발생

    • C 계좌에서는 $1이 추가되었지만, A에서 금액 차감 연산이 실패할 경우

잘못된 순서로 실행된 경우

TC/C에는 실행 순서가 어긋날 수 있는 문제가 존재

  • A 계정에서 C 계정으로 1달러를 이체할 경우.

  • 시도 단계에서 계정 A에 대한 작업이 실패하여 지갑 서비스에 실패를 반환한 다음, 취소 단계로 진입하여 계정 A와 계정 C 모두에 취소 명령을 전송하는 과정

  • 이때, 계정 C를 관리하는 DB에 네트워크 문제로 시도 명령 전에 취소 명령부터 수신했을 경우, 그 시점에는 취소할 것이 없는 상태

  • 순서가 바뀌어 도착하는 명령도 처리할 수 있도록 하려면 기존 로직을 다음과 같이 수정하자.

    • 취소 명령이 먼저 도착하면 DB에 아직 상응하는 시도 명령을 못 보았음을 나타내는 플래그를 참으로 설정하여 저장

    • 시도 명령이 도착하면 항상 먼저 도착한 취소 명령이 있었는지 확인. 있었으면 바로 실패를 반환

분산 트랜잭션: 사가

선형적 명령 수행

사가(Saga)는 유명한 분산 트랜잭션 솔루션 가운데 하나로 MSA에서는 사실상 표준

Saga의 개념

  • 모든 연산은 순서대로 정렬

    • 각 연산은 자기 DB에 독립 트랜잭션으로 실행

  • 연산은 첫 번째부터 마지막까지 순서대로 실행

    • 한 연산이 완료되면 다음 연산이 개시

  • 연산이 실패하면 전체 프로세스는 실패한 연산부터 맨 처음 연산까지 역순으로 보상 트랜잭션을 통해 롤백

사가 작업 흐름

연산 실행 순서 조율

  • 분산 조율(Choreography, 안무)

    • MSA에서 사가 분산 트랜잭션에 관련된 모든 서비스가 다른 서비스의 이벤트를 구독하여 작업을 수행하는 방식

    • 완전히 탈 중앙화된 조율 방식

  • 중앙 집중형 조율(Orchestration)

    • 하나의 조정자가 모든 서비스가 올바른 순서로 작업을 실행하도록 조율

조율 방식은 사업상의 필요와 목표에 따라 선정

  • 분산 조율 방식은 서비스가 서로 비동기식으로 통신하므로, 모든 서비스는 다른 서비스가 발생시킨 이벤트의 결과로 어떤 작업을 수행할지 정하기 위해 내부적으로 상태 기계를 유지해야 한다.

  • 서비스가 많으면 관리가 어려워질 수 있는 부분

  • 일반적으로 중앙 집중형 조율 방식을 선호하는데, 복잡한 상황을 잘 처리하기 떄문

TC/C vs Saga

TC/C, Saga 모두 애플리케이션 수준 분산 트랜잭션

TC/C
Saga

보상 트랜잭션 실행

취소 단계에서

롤백 단계에서

중앙 조정

예(중앙 집중형 조율 모드에서만)

작업 실행 순서

임의

선형

병렬 실행 가능성

임의

선형

일시적으로 일관되지 않은 상태 허용

구현 계층:애플리케이션 또는 데이터베이스

애플리케이션

애플리케이션

지연 시간(latency) 요구사항에 따라 권장되는 방안이 다르다.

  • 지연 시간 요구사항이 없거나, 송금 사례처럼 서비스 수가 매우 적다면 아무거나 사용 가능

  • MSA에서 흔히 하는 대로 하고 싶다면 Gaga

  • 지연 시간에 민감하고 많은 서비스/운영이 관계된 시스템이라면 TC/C 권장

이벤트 소싱

배경

외부 감사에서 던질 수 있는 까다로운 질문들에 체계적으로 답할 수 있는 설계 철학 중 하나는 도메인 주도 설계에서 개발된 기법인 이벤트 소싱

  • 특정 시점의 계정 잔액을 알 수 있나?

  • 과거 및 현재 계쩡 잔액이 정확한지 어떻게 알 수 있나?

  • 코드 변경 후에도 시스템 로직이 올바른지는 어떻게 검증하나?

정의

이벤트 소싱에서 중요한 네 가지 용어

  • 명령(command)

  • 이벤트(event)

  • 상태(state)

  • 상태 기계(state machine)

명령

명령은 외부에서 전달된, 의도가 명확한 요청

  • ex. 고객 A에서 C로 $1를 이체하라는 요청

  • 이벤트 소싱에서 순서는 아주 중요한데, 명령은 일반적으로 FIFO(First-In-First-Out) 큐에 저장

이벤트

명령은 의도가 명확하지만 사실은 아니므로 유효하지 않을 수 있다.

  • 유효하지 않은 명령은 실행할 수 없다. 가령 이체 후 잔액이 음수가 된다면 이체는 실패

  • 작업 이행 전에는 반드시 명령의 유효성을 검사해야 한다. 그리고 검사를 통과한 명령은 반드시 이행(fullfil)되어야 한다.

  • 명령 이행 결과를 이벤트라고 부른다.

명령과 이벤트 사이의 두 가지 중요한 차이점

  • 이벤트는 검증된 사실로 실행이 끝난 상태

    • 이벤트에 대해 이야기할 때 과거 시제를 사용

    • "A에서 C로 $1 송금 완료"

  • 명령에는 무작위성이나 I/O가 포함될 수 있지만, 이벤트는 결정론적

    • 이벤트는 과거에 실제로 있던 일

이벤트 생성 프로세스의 두 가지 중요한 특성

  • 하나의 명령으로 여러 이벤트가 만들어질 수 있음

  • 이벤트 생성 과정에는 무작위성이 개입될 수 있어서, 같은 명령에 항상 동일한 이벤트들이 만들어진다는 보장이 없음

    • 이벤트 생성 과정에는 외부 I/O 또는 난수가 개입될 수 있음

상태

상태는 이벤트가 적용될 때 변경되는 내용

  • 지갑 시스템에서 상태는 모든 클라이언트 계정의 잔액으로, Map 자료 구조로 표현

  • key: 계정 이름 또는 ID, value: 계정 잔액

상태 기계

상태 기계는 이벤트 소싱 프로세스를 구동

  • 명령의 유효성을 검사하고 이벤트를 생성

  • 이벤트를 적용하여 상태를 갱신

이벤트 소싱을 위한 상태 기계는 결정론적으로 동작해야 한다.

  • 이벤트를 상태에 반영하는 것 또한 항상 같은 결과를 보장해야 한다.

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

  • 명령을 이벤트로 변환하고 이벤트를 적용하는 두 가지 기능을 지원

  • 명령 유효성 검사를 위한 상태 기계와 이벤트 적용을 위한 상태 기계

여기에 시간을 하나의 차원으로 추가하면 명령을 수신하고 처리하는 과정이 반복되는 동적 관점으로도 표현

지갑 서비스 예시

명령(이체 요청)은 FIFO 큐에 기록하며, 큐로는 카프카를 널리 사용

  • 상태 기계는 명령을 큐에 들어간 순서대로 확인

  • 명령이 "A -> $1 -> C"라면 상태 기계는 "A: -$1", "C: +$1"의 두 이벤트를 생성

상태 기계가 다섯 단계로 동작하는 과정

  • (1) 명령 대기열에서 명령 읽기

  • (2) 데이터베이스에서 잔액 상태 읽기

  • (3) 명령의 유효성 검사 (유효하면 계정별로 이벤트를 생성)

  • (4) 다음 이벤트 읽기

  • (5) 데이터베이스 잔액을 갱신하여 이벤트 적용을 종료

재현성

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

  • 데이터베이스는 특정 시점의 잔액이 얼마인지만 보여주지만, 이벤트는 처음부터 다시 재생하면 과거 잔액 상태는 언제든 재구성 가능

이벤트를 재생하여 지갑 서비스 상태를 재현하는 과정의 사례

재현성을 통해 아래 질문에 쉽게 답할 수 있게 된다.

  • 특정 시점의 계정 잔액을 알 수 있나?

    • 시작부터 계정 잔액을 알고 싶은 시점까지 이벤트를 재생

  • 과거 및 현재 계쩡 잔액이 정확한지 어떻게 알 수 있나?

    • 이벤트 이력에서 계정 잔액을 다시 계산해 보면 잔액이 정확한지 확인 가능

  • 코드 변경 후에도 시스템 로직이 올바른지는 어떻게 검증하나?

    • 새로운 코드에 동일한 이벤트 이력을 입력으로 주고 같은 결과가 나오는지 확인

감사 기능 시스템이어야 한다는 요건으로 이벤트 소싱이 지갑 서비스 구현의 실질적인 솔루션으로 채택되는 경우가 많음.

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

이벤트 소싱은 상태, 즉 계정 잔액을 공개하는 대신 모든 이벤트를 외부에 보낸다. 따라서 이벤트를 수신하는 외부 주체가 직접 상태를 재구축할 수 있따.

  • 이런 설계 철학을 명령-질의 책임 분리(Command-Query Responsibility Separation, CQRS)

CQRS에서는 상태 기록을 담당하는 상태 기계는 하나고, 읽기 전용 상태 기계는 여러 개 있을 수 있다.

  • 읽기 전용 상태 기계는 상태 뷰를 만들고, 이 뷰는 질의에 이용된다.

읽기 전용 상태 기계는 이벤트 큐에서 다양한 상태 표현을 도출할 수 있다.

  • 클라이언트의 잔액 질의 요청을 처리하기 위해 별도 데이터베이스에 상태를 기록하는 등의 작업을 수행

  • 이중 청구 등의 문제를 쉽게 조사할 수 있도록 하기 위해 특정한 기간 동안 상태 복원 가능

  • 이렇게 복원된 상태 정보는 재무 기록과 대조할 검사 기록으로 활용 가능

읽기 전용 상태 기계는 실제 상태에 어느 정도 뒤쳐질 수 있으나 결국에는 같아진다.

  • 따라서 결과적 일관성 모델을 따른다 할 수 있다.

전형적인 CQRS 아키텍처

3단계: 상세 설계

고성능 이벤트 소싱

카프카: 명령 및 이벤트 저장소 데이터베이스: 상태 저장소

가능한 몇 가지 최적화 방안들

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

명령과 이벤트를 카프카 같은 원격 저장소가 아닌 로컬 디스크에 저장하는 방안

  • 네트워크를 통한 전송 시간을 피할 수 있음

  • 메모리에 캐싱해서 로컬 디스크에서 다시 로드하지 않는 방안도 있음

이벤트 목록은 추가 연산만 가능한 자료 구조를 사용

  • 추가는 순차적 쓰기 연산으로, 일반적으로 매우 빠른 속도

운영체제는 보통 순차적 읽기 및 쓰기 연산에 엄청나게 최적화

  • HDD에서도 잘 작동

순차적 디스크 접근은 경우에 따라 무작위 메모리 접근보다 빠르게 실행

mmap

  • mmap 기술은 최적화 구현에 유용

  • 로컬 디스크에 쓰는 동시에, 최근 데이터는 메모리에 자동으로 캐시

  • 파일 기반 명령 및 이벤트 저장소

  • 디스크 파일을 메모리 배열에 대응

  • 운영체제는 파일의 특정 부분을 메모리에 캐시하여 읽기 및 쓰기 연산의 속도를 높임

파일 기반 상태

파일 기반 로컬 관계형 데이터베이스 SQLite를 사용하거나, 로컬 파일 기반 키-값 저장소 RocksDB를 사용하여 상태 정보를 로컬 디스크에 저장할 수 있다.

  • RocksDB는 쓰기 작업에 최적화된 자료구조 LSM(Log-Structured Merge-tree)를 사용

  • 최근 데이터는 캐시하여 읽기 성능을 향상

명령, 이벤트 및 상태 저장에 파일 기반 솔루션을 적용한 아키텍처

스냅숏

스냅숏은 과거의 특정 시점의 상태로 변경이 불가능

  • 스냅숏을 저장하고 나면 상태 기계는 더 이상 최초 이벤트에서 시작할 필요가 없음

  • 스냅숏을 읽고 어느 시점에 만들어졌는지 확인한 다음, 그 시점부터 이벤트 처리를 시작

  • 스냅숏은 거대한 이진 파일이며, 일반적으로 HFDS(Hadoop Distributed File System)과 같은 객체 저장소에 저장

파일 기반 이벤트 소싱 아키텍처

  • 모든 것이 파일이 기반일 때 시스템은 컴퓨터 하드웨어의 I/O 처리량을 그 한계까지 최대로 활용 가능

4단계: 마무리

요약

Last updated