12장 전자 지갑
- 결제 플랫폼은 보통 고객에게 전자 지갑 서비스를 제공하여 돈을 필요할 때 사용할 수 있도록 한다.
- 결제 기능 뿐 아니라 다른 사용자에게로의 송금도 지원한다. (ex. 페이팔)
- 이러한 이체는 은행보다 빠르며 추가 수수료를 부과하지 않는게 보통이다.
1단계: 문제 이해 및 설계 범위 확정
- 기능 요구사항
- 비기능 요구사항
- 1,000,000TPS
- 99.99% 안정성
- 정확성에 대한 요건은 데이터베이스의 트랜잭션 보증으로 충분하다고 간주
- 재현성을 갖춘 시스템
- 데이터 일관성이 깨졌을 경우 그 차이가 왜 발생했는지 추적하기 위해 처음부터 데이터를 재생하여 언제든지 과거 잔액을 재구성할 수 있어야 한다.
- 개략적 추정
- 오늘날 사용되는 관계형 DB는 초당 수천 건 트랜잭션을 지원할 수 있다.
- 한 노드가 1,000TPS를 지원할 수 있다고 가정하고 1번의 이체에서 두 번의 연산(인출, 입금)이 필요하기에 1백만 건 TPS 처리를 위해선 2백만 TPS를 지원하는 2000개 노드가 필요하다.
- 이번 설계 목표 중 하나는 단일 노드가 처리할 수 있는 트랜잭션 수를 늘리는 것이다.
2단계: 개략적 설계안 제시 및 동의 구하기
API 설계
- POST
/v1/wallet/balance_transper
// request
{
"from_account": "...", // 인출할 계좌
"to_account": "...", // 이체할 계좌
"amount": "...", // 이체 금액
"currency": "...", // 통화 단위
"transaction_id": "..." // 중복 제거에 사용할 ID
}
인메모리 샤딩
- <사용자, 잔액> 관계를 나타내기에 좋은 자료 구조는 키-값 저장소이기에 레디스는 좋은 선택이다.
- 다만 레디스 한 대로 100만 TPS는 벅차기에 클러스터를 구성하고 사용자 계정을 모든 노드에 균등히 분배해야 한다. (샤딩)
- 키의 해시 값을 계산해 파티션 수 n으로 나눠 파티션 번호를 도출
- 모든 레디스 노드의 파티션 수와 주소는 주키퍼를 사용하여 관리
- 지갑 서비스는 이체 명령 처리를 담당하는 서비스로 다음 플로우를 가진다.
- 이체 명령 수신
- 이체 명령 유효성 검증
- 두 계정의 잔액 갱신
graph LR
A(이체 명령) -->|이체 요청| B[지갑서비스]
B -->|파티션 조회| C[(주키퍼)]
C -->|노드 정보 반환| B
B <-->|-1$| D[레디스_A_잔액]
B <-->|+1$| E[레디스_B_잔액]
- 위 설계에선 아직 정확성 요구사항을 충족하지 못한다.
- 각 레디스 노드에 보내는 요청이 모두 원자적으로 성공하리라는 보장이 없다.
분산 트랜잭션: 2단계 커밋
- 서로 다른 두 노드를 원자적으로 수행하려면 일단 각 레디스 노드를 관계형 데잍터베이스로 교체하는 방법이 있다.
- 그럼에도 샤딩된 두 DB 노드를 원자적으로 변경하는 것은 간단하지 않은데 이를 해결하기 위해 2단계 커밋(2PC)을 사용할 수 있다.
- 조정자(지갑 서비스)는 A 데이터베이스와 C 데이터베이스에 정상적으로 쓰기 작업을 수행하는데 그 결과로 각 데이터에는 락이 걸린다.
- 애플리케이션이 트랜잭션을 커밋하려할 때 조정자는 모든 데이터베이스에 트랜잭션 준비 요청을 한다.
- 두 번째 단계에서 조정자는 모든 데이터베이스의 응답을 받아 다음 절차를 수행한다.
- 모든 DB가 ok 응답을 하면 조정자는 모든 데이터베이스에 커밋을 요청
- 어느 한 DB라도 no를 응답하면 모든 DB에 트랜잭션 중단을 요청
sequenceDiagram
participant 조정자(지갑서비스)
participant A_DB
participant C_DB
조정자(지갑서비스)->>A_DB: Write Data (Lock)
조정자(지갑서비스)->>C_DB: Write Data (Lock)
조정자(지갑서비스)->>A_DB: Prepare
조정자(지갑서비스)->>C_DB: Prepare
alt All DBs respond OK
A_DB->>조정자(지갑서비스): OK
C_DB->>조정자(지갑서비스): OK
조정자(지갑서비스)->>A_DB: Commit
조정자(지갑서비스)->>C_DB: Commit
else Any DB responds NO
A_DB->>조정자(지갑서비스): NO (or timeout)
C_DB->>조정자(지갑서비스): NO (or timeout)
조정자(지갑서비스)->>A_DB: Rollback
조정자(지갑서비스)->>C_DB: Rollback
end
- 2PC는 저수준 방안으로 데이터베이스 자체에 의존한다.
- 준비 단계를 실행하려면 데이터베이스 트랜잭션 실행 방식을 변경해야 한다.
- 모든 DB가X/Open XA 표준을 만족하는 데이터베이스여야 함
- 2PC의 문제점
- 오래 동안 락을 잡기에 성능이 좋지 않다.
- 조정자가 SPOF가 된다.
분산 트랜잭션: TC/C
- TC/C (Try-Confirm/Cancel)는 두 단계로 구성된 보상 트랜잭션이다.
- 1) 조정자는 모든 DB에 트랜잭션에 필요한 자원 예약 요청
- 2) 조정자는 모든 DB로부터 회신을 받는다.
- 모두 yes 응답이면 조정자는 모든 DB에 ‘시도-확정(Try-Confirm) 절차를 시도한다.
- 어느 하나라도 no를 응답하면 모든 DB에 ‘시도-취소(Try-Cancel)’ 절차를 시도한다.
- ex) 계좌 A에서 계좌 C로 1달러를 이체하는 예제
단계 |
A |
C |
1(시도) |
- 1$ |
아무것도 하지 않음 |
2(확정 절차) |
아무것도 하지 않음 |
+ 1$ |
2(취소 절차) |
+ 1$ |
아무것도 하지 않음 |
분산 트랜잭션: 사가
- 사가(Saga)는 MSA에선 사실상 표준으로 사용되는 분산 트랜잭션 솔루션 중 하나다.
- 모든 연산이 순서대로 정렬되고 각 연산은 자기 DB에서 독립 트랜잭션으로 실행된다.
- 연산은 첫 번째부터 마지막까지 순서대로 실행된다.
- 연산이 실패하면 전체 프로세스는 실패한 연산부터 처음 연산까지 역순으로 보상 트랜잭션을 실행해 롤백한다.
- 즉 n개 연산을 하려면 보상 트랜잭션까지 총 2n개 연산을 준비해야 한다.
- 연산 실행 순서 조율 방법
- 분산 조율(Choreography)
- 사가 분산 트랜잭션에 관련된 모든 서비스가 다른 서비스의 이벤트를 구독하여 작업을 수행
- 서비스가 서로 비동기식으로 통신
- 모든 서비스는 다른 서비스의 이벤트의 결과로 어떤 작업을 수행할지 정하기 위해 내부적으로 상태 기계를 유지해야 한다.
- 중앙 집중형 조율(Orchestration): 하나의 조정자가 모든 서비스가 올바른 순서대로 작업하도록 조율
- TC/C vs 사가
|
TC/C |
사가 |
보상 트랜잭션 실행 |
취소 단계 |
롤백 단계 |
중앙 조정 |
예 |
예(중앙 집중형에서만) |
작업 실행 순서 |
임의 |
선형 |
병렬 실행 가능성 |
예 |
아니요(선형적) |
일시적으로 일관성 깨진 상태 허용 |
예 |
예 |
구현 계층 |
애플리케이션 |
애플리케이션 |
- TC/C와 사가는 지연 시간 요구사항에 따라 선택할 수 있다.
- 지연 시간 요구사항이 없거나 서비스 수가 매우 적다면 아무거나 사용해도 된다.
- 지연 시간에 민감하고 많은 서비스/운영이 관계된 시스템이라면 TC/C가 더 낫다.
이벤트 소싱
- 이벤트 소싱에는 네 가지 중요한 용어가 있다.
- 명령(command)
- 이벤트(event)
- 상태(state)
- 상태 기계(state machine)
- 명령
- 외부에서 전달된 의도가 명확한 요청
- 이벤트 소싱에서 순서는 중요하기에 명령은 일반적으로 FIFO(First-In-First-Out) 큐에 저장된다.
- 이벤트
- 명령은 의도가 명확하지만 사실(fact)은 아니기에 유효하지 않을 수 있다.
- 명령 이행 전에는 반드시 명령의 유효성을 검사해야 하고 검사를 통과한 명령은 반드시 이행(fullfill)되어야 한다.
- 명령 이행 결과를 이벤트라 부른다.
- 이벤트는 검증된 사실로 이미 실행이 끝난 상태다. (ex. A에서 C로 $1 송금되었음)
- 명령엔 무작위성이나 I/O가 포함될 수 있지만 이벤트는 결정론적이며 과거에 실제로 있었던 일이다.
- 이벤트 생성 프로세스에는 두 가지 특성이 존재한다.
- 한 명령으로 여러 이벤트가 만들어질 수 있다.
- 이벤트 생성 과정에 무작위성이 개입될 수 있어 같은 명령에 항상 동일한 이벤트가 만들어진다는 보장은 없다.
- 이벤트 순서 또한 명령 순서를 따라야하기에 FIFO 큐에 저장한다.
- 상태
- 이벤트가 적용될 때 변경되는 내용이다.
- ex) 지갑 시스템에서 계정의 잔액이 상태다
- 상태 기계
- 상태 기계는 이벤트 소싱 프로세스를 구동시키는데 크게 두 가지 기능이 있다.
- 명령의 유효성을 검사하고 이벤트를 생성한다.
- 이벤트를 적용하여 상태를 갱신한다.
- 상태 기계는 결정론적으로 동작해야 하기에 무작위성을 내포할 수 없고 상태에 반영하는 것 또한 항상 같은 결과를 보장해야 한다.
graph TD
A(Command) --> B([State Machine])
B -->|1 읽기| C[(State)]
B -->|2 유효성 검사| D[Event]
D --> E([State Machine])
E -->|3 적용| C
- 이벤트 소싱이 갖는 가장 큰 장점은 재현성을 갖는다는 것이다.
- 이벤트를 처음부터 다시 재생하면 과거 잔액 상태를 얼마든지 재구성할 수 있다.
- 상태 기계 로직은 결정론적이므로 이벤트를 재생한 결과는 언제나 동일하다.
- 재현성을 갖추기에 다음 까다로운 질문에 쉽게 답할 수 있다.
- 특정 시점 계정 잔액을 알 수 있나요? → 특정 시점까지 이벤트를 재생하면 된다.
- 과거 및 현재 계정 잔액이 정확한지는 어떻게 알 수 있나요? → 이벤트 이력에서 계정 잔액을 다시 계산해 보면 된다.
- 코드 변경 후에도 시스템 로직이 올바른지 어떻게 증명할 수 있나요? → 새로운 코드에 동일한 이벤트 이력 입력을 주고 같은 결과가 나오는지 확인
- 감사 기능 시스템이어야 하는 필요 때문에 지갑 서비스로 이벤트 소싱이 채택되는 경우가 많다.
- 명령-질의 책임 분리(CQRS)
- 이벤트 소싱 프레임워크 외부의 클라이언트가 상태(즉, 잔액)를 알게 하기 위해 모든 이벤트를 외부에 보낸다.
- 이벤트를 수신하는 외부 주체는 직접 상태를 재구축하는데 이를 CQRS라 한다.
- 읽기 전용 상태 기계는 이벤트 큐에서 다양한 상태 표현을 도출한다.
- 읽기 전용 상태 기계는 실제 상태 보다 어느 정도 뒤쳐질 수 있으나 결국에는 같아진다. (eventual consistency)
3단계: 상세 설계
고성능 이벤트 소싱
- 기존의 이벤트 소싱은 성능 이슈가 있을 수 있다.
- 한 번에 하나의 이벤트만 처리
- 여러 외부 이벤트와의 통신 (카프카 등)
- 파일 기반의 명령 및 이벤트 목록
- 명령과 이벤트를 카프카 같은 원격 저장소가 아닌 로컬 디스크에 저장하는 방안이 있다.
- 추가 연산만 가능한 자료 구조에 저장한다면 OS는 순차 연산을 빠르게 실행할 수 있기에 일반적으로 매우 빠르다.
- 최근 명령과 이벤트를 메모리에 캐시하는 방안도 있다.
- mmap 기술은 이러한 최적화 구현에 유용하다.
- 로컬 디스크에 쓰는 동시에 최근 데이터 자동 캐시 가능
- 디스크 파일을 메모리 배열에 대응 시킨다.
- 추가만 가능한 파일에 이루어지는 연산의 경우 필요한 데이터는 거의 항상 메모리에 있으므로 빠르다.
- 파일 기반 상태
- 잔액 정보 같은 상태 정보도 원격 RDB가 아닌 로컬 디스크에 저장할 수 있다.
- 로컬 파일 기반 키-값 저장소인 RocksDB를 사용하는 방안이 있다.
- 쓰기 작업에 최적화된 LSM(Log-Structured Merge-tree) 자료 구조를 사용
- 최근 데이터는 캐시 지원
- 스냅숏
- 스냅숏을 저장하고 나면 상태 기계는 최초 이벤트에서 시작할 필요가 없다.
- 금융 애플리케이션은 00:00에 스냅숏을 찍는 일이 많은데 그래야 당일 발생한 거래를 확인할 수 있기 때문이다.
- 스냅숏을 통해 읽기 전용 상태 기계는 해당 데이터가 포함된 스냅숏 하나만 로드하면 된다.
- 스냅숏은 거대한 이진 파일이며 일반적으로 HDFS(Hadoop Distributed File System)과 같은 객체 저장소에 저장한다.
신뢰할 수 있는 고성능 이벤트 소싱
- 이벤트 소싱에선 네 가지 유형의 데이터가 존재한다.
- 파일 기반 명령
- 파일 기반 이벤트
- 파일 기반 상태
- 상태 스냅숏
- 이 중 높은 신뢰성을 보장할 유일한 데이터는 바로 이벤트다.
- 상태와 스냅숏은 이벤트 목록을 재생하면 언제든 다시 만들 수 있다.
- 이벤트는 외부 요인에 따라 무작위적 요소가 포함될 수 있기에 명령이 신뢰적이어도 이벤트 재현성을 보장할 수 없다.
- 이벤트는 상태에 변화를 가져오는 과거의 사실로 불변이다.
- 이벤트 목록의 높은 안정성을 제공하기 위해 합의 기반 복제를 사용해야 한다.
- 이벤트 목록은 여러 노드에 복제될 필요가 있다.
- 합의 기반 복제는 모든 노드가 동일한 이벤트 목록에 합의하도록 보장한다.
- 합의 기반 복제에선 과반수 노드가 동작하는 한 시스템이 안정적임을 보장한다.
- 대표적으로 래프트 알고리즘이 존재한다.
- 합의 기반 복제를 통한 고신뢰성 솔루션
- 리더 노드는 외부 사용자로부터 명령 요청을 받아 이벤트로 변환하고 로컬 이벤트 목록에 추가
- 래프트 알고리즘을 통해 이벤트를 모든 팔로워에 복제
- 팔로워를 포함한 모든 노드가 이벤트 목록을 처리하고 상태를 업데이트
- 만약 리더에 장애가 발생하면 나머지 노드 중 리더를 선출하여 서비스를 지속적으로 제공한다.
- 팔로워 장애 발생 시에는 새로운 노드로 대체될 때까지 래프트는 기한 없는 재시도를 통해 장애를 처리한다.
풀 모델 vs 푸시 모델
- CQRS 시스템에선 요청/응답 흐름이 느릴 수 있다.
- 클라이언트가 상태의 업데이트 시점을 정확히 알 수 없어 주기적으로 폴링해야 하기 때문
- 풀 모델
- 외부 사용자가 읽기 전용 상태 기계에서 주기적으로 실행 상태를 질의
- 읽는 주기가 너무 짧으면 과부하가 걸릴 수도 있다.
- 풀 모델은 클라이언트 로직이 복잡해지는데 (명령 요청, 주기적인 풀 요청) 리버스 프록시를 통해 이를 완화시킬 수 있다.
- 리버스 프록시에 주기적인 풀 요청 로직을 두고 클라이언트는 단순히 질의만 수행
- 푸시 모델
- 리버스 프록시를 둔다고 하면 읽기 전용 상태 기계를 수정하여 응답 속도를 실시간에 가깝게 만들 수 있다.
- 읽기 전용 상태 기계가 이벤트를 수신하자마자 실행 상태를 리버스 프록시에 푸시하면 된다.
graph LR
A(Client)
B[명령]
C([상태 머신])
D[[이벤트]]
E([상태 머신])
F[(상태)]
G([읽기 전용 상태 머신])
H[(읽기 모델)]
I(리버스 프록시)
A -->|명령| I
I -->|응답| A
I --> B --> C --> D --> E --> F
D --> G --> H
H -->|최신 상태 실시간 푸시| I
분산 트랜잭션
- 단일 래프트 그룹 용량은 제한되어 있어 일정 규모 이상에선 데이터를 샤딩하고 분산 트랜잭션을 구현해야 한다.
- 모든 이벤트 소싱 노드 그룹이 동기적 실행 모델을 채택하면 TC/C나 사가 같은 솔루션을 재사용할 수 있다.
- 사가 분산 트랜잭션 모델 사용 한다고 했을 때 롤백 없이 정상 실행되는 시나리오를 살펴보면 다음과 같다.
- 사용자 A가 사가 조정자에게 분산 트랜잭션을 보낸다. (A:-$1과 C:+$1)
- 사가 조정자는 단계별 상태 테이블에 레코드를 생성하여 트랜잭션 상태를 추적
- 사가 조정자는 작업 순서를 검토한 후 A:-$1를 먼저 처리하기로 결정하고 해당 명령을 계정 A 정보가 들어있는 파티션 1로 보낸다.
- 파티션 1의 래프트 리더는 A:-$1 명령 수신 후 다음 작업을 수행한다.
- 명령 목록에 저장
- 명령 유효성 검사
- 명령을 이벤트로 변환
- 여러 노드에 데이터를 동기화 후 이벤트가 실행된다.
- 이벤트 동기화되면 파티션 1의 이벤트 소싱 프레임워크가 CQRS를 사용하여 읽기 경로를 동기화한다.
- 파티션 1의 읽기 경로는 이벤트 소싱 프레임워크를 호출한 사가 조정자에 상태를 푸시한다.
- 사가 조정자는 파티션 1에서 성공 상태를 수신한다.
- 사가 조정자는 단계별 상태 테이블에 파티션 1이 성공했음을 나타내는 레코드를 저장한다.
- 첫 작업이 성공했기에 C+$1 명령을 실행하기 위해 조정자는 파티션 2에 해당 명령을 보낸다.
- 파티션 2에도 똑같은 작업이 진행된다.
- 사가 조정자가 파티션 2로부터 성공 상태를 받고 작업이 성공했음을 레코드로 생성하여 저장한다.
- 모든 작업이 완료되어 분산 트랜잭션이 끝나고 호출자에 결과를 응답한다.