결제 플랫폼은 일반적으로 고객게에 전자 지갑 서비스를 제공하여 지갑에 돈을 넣어두고 필요할 때 사용할 수 있도록 한다.
- 결제 기능
- 다른 사용자의 지갑으로 직접 송금
- 은행 간 이체보다 빠르며, 일반적으로 추가 수수료를 부과하지 않는다.
전자 지갑 애플리케이션의 백엔드를 설계해본다.
1단계: 문제 이해 및 설계 범위 확정
- 전자 지갑 간 이체
- 10,000,000 TPS
- 99.99% 안정성
- 트랜잭션
- 재현성
개략적 추정
TPS를 거론한다는 것은 트랜잭션 기반 데이터베이스를 사용한다는 것을 뜻한다.
일반적인 데이터센터 노드에서 실행되는 관계형 데이터베이스는 초당 수천 건의 트랜잭션을 지원할 수 있다.
- 설계안에서 사용할 데이터베이스 노드는 1,000 TPS를 지원할 수 있다고 가정
- 1백만 TPS를 지원하려면 1,000개의 데이터베이스 노드가 필요함
이체 명령을 실행하려면 한 계좌에서 인출, 다른 계좌에서 입금을 실행해야하므로, 실제로는 2백만 TPS를 지원해야 하기 때문에, 2,000개의 데이터베이스 노드가 필요하다.
하드웨어가 같아고 가정할 때, 한 노드가 초당 처리할 수 있는 트랜잭션 수가 많을수록 필요한 총 노드 수는 줄어들어 하드웨어 비용은 낮아진다.
노드당 TPS | 노드 수 |
---|---|
100 | 20,000 |
1,000 | 2,000 |
10,000 | 200 |
따라서 단일 노드가 처리할 수 있는 트랜잭션 수를 최대한 늘려 관리해야할 노드 수를 줄인다.
2단계: 개략적 설계안 제시 및 동의 구하기
- API 설계
- 세가지 개략적 설계안
- 간단한 메모리 기반 솔루션
- 데이터베이스 기반 분산 트랜잭션 솔루션
- 재현성을 갖춘 이벤트 소싱(event sourcing) 솔루션
API 설계
RESTful 규약을 따르는 하나의 API만 필요하다.
API | 기능 |
---|---|
POST /v1/wallet/balance_transfer | 한 지갑에서 다른 지급으로 자금 이체 |
요청 인자
필드 | 설명 | 자료형 |
---|---|---|
from_account | 인출 계좌 | string |
to_account | 이체 계좌 | string |
amount | 금액 | string |
currency | 통화 단위 | string |
transaction_id | 중복 제거 ID | uuid |
유의할 부분은 amount
필드의 자료형이 string
이라는 점인데(11장 참고), 실제로는 float
이나 double
을 대부분의 프로그래밍 언어와 데이터베이스가 지원하기 때문에 택하는 경우도 많다.
- 정밀도를 잃을 위험을 인지하고 사용한다면 적절한 선택일 수 있다.
인메모리 샤딩
지갑 애플리케이션은 모든 사용자 계정의 잔액을 유지한다.
사용자와 잔액의 관계를 나타내기 좋은 자료 구조는 키-값 자료구조이고, 인메모리 키-값 저장소는 레디스가 가장 인기있다.
그러나 레디스 노드 한대로 100만 TPS는 벅차기 때문에 클러스터를 구성하고 사용자 계정을 모든 노드에 균등하게 분산시켜야 한다.
- 파티셔닝 또는 샤딩
키-값 데이터를 n개 파티션에 고르게 분배하려면 키의 해시 값을 계산하고 이를 파티션의 수 n으로 나누는 방법을 고려할 수 있다.
|
|
모든 레디스 노드의 파티션 수 및 주소는 한 군데 저장해 둔다.
- 높은 가용성을 보장하는 설정 정보 전문 저장소 **주키퍼(ZooKeeper)**를 이 용도로 쓰면 좋다.
이 방안의 마지막 구성 요소는 이체 명령 처리를 담당하는 서비스로(지갑 서비스, wallet service) 다음과 같은 역할을 담당한다.
- 이체 명령 수신
- 이체 명령의 유효성 검증
- 명령이 유효한 것으로 확인되면 이체에 관계된 두 계정의 잔액 갱신
- 두 계정은 다른 레디스 노드에 있을 수 있음
이 서비스는 무상태 서비스이므로 수평적 규모 확장에 유리하다.
이 설계는 작동은 하지만 정확성 요구사항을 충족하지 못한다.
- 두 개의 레디스 노드를 업데이트 하는데, 그 두 연산이 모두 성공한다는 보장을 할 수 없다.
따라서 두 업데이트 연산이 하나의 원자적 트랜잭션(atomic transaction)으로 실행되어야한다.
분산 트랜잭션
데이터베이스 샤딩
서로 다른 두 개 저장소 노드를 갱신하는 연산을 원자적으로 수행하려면 아래와 같은 방법들을 고려할 수 있다.
레디스 노드를 트랜잭션을 지원하는 관계형 데이터베이스 노드로 교체
클라이언트의 잔액 정보가 레디스 노드가 아닌 3개의 관계형 데이터베이스 노드로 분산된다.
트랜잭션 데이터베이스를 사용해도 위와 같은 방식이라면 문제의 일부반 해결할 수 있다.
- 한 이체 명령이 서로 다른 두 데이터베이스 서버에 있는 계정 두 개를 업데이트해야 할 가능성이 아주 높은데, 이 두 작업이 정확히 동시에 처리된다는 보장이 없다.
분산 시스템에서 한 트랜잭션에서는 여러 노드의 프로세스가 관여할 수 있다.
분산 트랜잭션은 이들 프로세스를 원자적인 하나의 트랜잭션으로 묶는 방안인데, 구현법으로 저수준 방안과 고수준 방안을 고려할 수 있다.
2단계 커밋
데이터베이스 자체에 의존하는 방안인 저수준 방안에서 가장 일반적으로 사용되는 알고리즘이다.
- 조정자는 정상적으로 여러 데이터베이스에 읽기 및 쓰기 작업을 수행한다.
- 데이터베이스 A, C에는 락이 걸린다.
- 애플리케이션에서 트랜잭션을 커밋하려 할 때 조정자는 모든 데이터베이스에 트랜잭션 준비를 요청한다.
- 조정자는 모든 데이터베이스의 등답을 받아 다음 절차를 수행한다.
- 모든 데이터베이스가 ‘예’라고 응답하면 모든 데이터베이스에 해당 트랜잭션 커밋을 요청한다.
- 한 데이터베이스라도 ‘아니오’라고 응답하면 모든 데이터베이스에 트랜잭션 중단을 요청한다.
준비 단계를 실행하려면 데이터베이스 트랜잭션 실행 방식을 변경해야 하기 때문에 저수준 방안으로 여겨진다.
- 2PC의 가장 큰 문제점은 다른 노드의 메시지를 기다리는 동안 락이 오랫동안 잠긴 상태로 남을 수 있어 성능이 좋지 않을 수 있다.
- 조정자가 SPOF가 될 수 있다.
- 모든 데이터베이스가 X/Open XA 표준을 만족해야한다.
분산 트랜잭션: TC/C
TC/C(시도-확정/취소, Try-Confirm/Cancel)는 두 단계로 구성된 보상 트랜잭션이다.
- 조정자는 모든 데이터베이스에 트랜잭션에 필요한 자원 예약을 요청한다.
- 조정자는 모든 데이터베이스로부터 회신을 받는다.
- 모두 ‘예’ 라고 응답하면 모든 데이터베이스에 작업 확인을 요청한다.(시도-확정, Try-Confirm)
- 하나라도 ‘아니오’ 라고 응답하면 모든 데이터베이스에 작업 취소를 요청한다. (시도-취소, Try-Cancel)
2PC의 두 단계는 한 트랜잭션이지만, TC/C에서는 각 단계가 별도 트랜잭션이라는 점을 유의하자.
TC/C 사례
계좌 A 에서 계좌 C로 1달러를 이체한다고 가정한다.
단계 | 실행연산 | A | C |
---|---|---|---|
1 | 시도 | 잔액 변경: -$1 | 아무것도 하지 않음 |
2 | 확인 | 아무것도 하지 않음 | 잔액 변경: +$1 |
취소 | 잔액 변경: +$1 | 아무것도 하지 않음 |
지갑 서비스가 TC/C의 조정자로고 가정하고, 분산 트랜잭션이 시작될 때 계정 A의 잔액은 1달러이고 계정 C의 잔액은 0달러이다.
첫 번째 단계: 시도
시도 단계에서는 조정자 역할을 하는 지갑 서비스가 두 개의 트랜잭션 명령을 두 데이터베이스로 전송한다.
- 계정 A가 포함된 데이터베이스에 잔액을 1달러 감소시키는 트랜잭션을 시작한다.
- 계정 C가 포함된 데이터베이스에는 아무 작업도 하지 않는다.
- 데이터베이스에 NOP(No Operation) 명령을 보내며, 항상 성공한다는 응답을 보낸다.
두 번째 단계: 확정
두 데이터베이스가 모두 예라고 응답하면 지갑 서비스는 확정 단계를 시작한다.
계정 A의 잔액은 이미 첫 번째 단계에서 갱신되었으므로 잔액을 변경할 필요가 없으나, 계정 C에서 1달러를 받지 못했으므로 확인 단계에서 실행되어야한다.
세 번째 단계: 취소
첫번째 시도 단계에서 C 계정이 불법 계정이라던가하는 이유로 실패한다면 분산 트랜잭션을 취소하고 관련된 자원을 반환해야한다.
시도 단계의 트랜잭션에서 계정 A의 잔액은 이미 바뀌었기 때문에 또 다른 트랜잭션을 시작하여 계정 A에 다시 1달라를 추가해야한다.
- 시도 단계에서 계정 C의 잔액은 업데이트하지 않았으므로, 계정 C의 데이터베이스에는 NOP 명령만 보내면 된다.
2PC와 TC/C 비교
2PC와 TC/C 간에는 많은 유사점이 있지만 차이점도 있다.
첫 번째 단계 | 두 번째 단계: 성공 | 두 번째 단계: 실패 | |
---|---|---|---|
2PC | 로컬 트랜잭션은 아지 완료되지 않은 상태 | 모든 로컬 트랜잭션을 커밋 | 모든 로컬 트랜잭션을 취소 |
TC/C | 모든 로컬 트랜잭션이 커밋되거나 취소된 상태로 종료 | 필요한 경우 새 로컬 트랜잭션 실행 | 이미 커밋된 트랜잭션의 실행 결과를 되돌림 |
TC/C는 보상 기반 분산 트랜잭션(distributed transaction by compensation)이라고도 부른다.
- 실행 취소 절차를 비즈니스 로직으로 구현하므로 고수준 해법이다.
장점은 데이터베이스가 트랜잭션을 지원한다면 동작하므로 데이터베이스에 구애받지 않는다.
하지만 애플리케이션 계층의 비즈니스 로직에서 세부 사항을 관리하고 분산 트랜잭션의 복잡성을 관리해야한다.
단계별 상태 테이블
TC/C 실행 도중 지갑 서비스가 다시 시작된다면 과거 모든 작업 기록이 사라질 수 있으며, 이로인해 어떻게 복구해야 할지 알 수 없게 된다.
해결책은 간단한데, TC/C의 진행 상황, 특히 각 단계 상태 정보를 트랜잭션 데이터베이스에 저장하면 된다.
이를 위해 상태 정보는 최소한 다음 내용을 포함해야한다.
- 분산 트랜잭션의 ID와 내용
- 각 데이터베이스에 대한 시도(Try) 단계의 상태
not sent yet
,has been sent
,response received
- 두 번째 단계의 이름
Confirm
,Cancel
- 두 번째 단계의 상태
- 순서가 어긋났음을 나타내는 플래그
단계별 상태 테이블(phase status table)은 일반적으로 돈을 인출할 지갑의 계정이 있는 데이터베이스에 둔다.
불균형 상태
모든 것이 순조롭게 진행된다고 가정하면 시도 단계가 끝났을 때 계정 A에서 1달러가 차감되고, 계정 C는 변화가 없다.
이는 계좌의 총 잔고가 TC/C를 시작 시점보다 작은 값임을 의미하며, 거래 후에도 잔액 총합은 동일해야 한다는 회계 기본 원칙을 위반한다.
다행스럽게도 트랜잭션 보증(transactional guarantee)은 TC/C 방안에서도 여전히 유효하다.
TC/C는 여러 개의 독립적인 로컬 트랜잭션으로 구성된다.
- TC/C의 실행 주체는 애플리케이션이며, 애플리케이션은 이런 독립적 로컬 트랜잭션이 만드는 중간 결과를 볼 수 있다.
- 데이터베이스 트랜잭션이나 2PC 같은 분산 트랜잭션의 경우 실행 주체는 데이터베이스이며 애플리케이션은 중간 실행 결과를 알 수 없다.
분산 트랜잭션 실행 도중에는 항상 데이터의 불일치가 발생한다.
데이터베이스와 같은 하위 시스템에서 불일치를 수정하는 경우에는 그 사실을 알 필요는 없지만, TC/C 같은 메커니즘을 사용하는 경우에는 직접 처리해야한다.
유효한 연산 순서
시도 단계에서 할 수 있는 일은 세 가지다.
선택지 | A | B |
---|---|---|
선택 1 | -$1 | NOP |
선택 2 | NOP | +$1 |
선택 3 | -$1 | +$1 |
두 번째 선택지의 경우, 계정 C의 연산은 성공하였으나 계정 A에서 실패한 경우(NOP) 지갑 서비스는 취소 단계를 실행해야 한다.
- 취소 단계 실행 전 누군가 C 게정에서 1달러를 이체하였다면, C에서 1달러를 차감하려 할 때 남은 잔액이 없기때문에 분산 트랜잭션의 트랜잭션 보증을 위반하게된다.
세 번째 선택지의 경우, 1달러를 A에서 차감하고 동시에 C에 추가하면 많은 문제가 발생할 수 있다.
- C 계좌에서 1달러를 추가하였으나 A에서 1달러를 차감하는 연산을 실패한다면 유효하지 않다.
따라서 선택지 1만 올바른 방법이다.
잘못된 순서로 실행된 경우
TC/C에는 실행 순서가 어긋날 수 있다는 문제가 있다.
시도 단계에서 A에 대한 작업이 실패하여 지갑 서비스에 실패를 반환한 다음 취소 단계로 진입하여 A와 C 모두에 취소 명령을 전송하는 과정에 계정 C를 관리하는 데이터베이스의 네트워크 문제로 시도 명령 전 취소 명령부터 받게 되었다고 가정한다.
- 그 시점에서 취소할 것이 없는 상태
순서가 바뀌어 도착하는 명령도 처리할 수 있도록 하려면 기존 로직을 다음과 같이 수정하면 된다.
- 취소 명령이 먼저 도착하면 데이터베이스에 아직 상응하는 시도 명령을 못 보았음을 나타내는 플래그를 참으로 설정하여 저장해둔다.
- 시도 명령이 도착하면 항상 먼저 도착한 취소 명령이 있었는지 확인한다.
- 있다면 바로 실패를 반환한다.
단계별 상태 테이블 절에서 테이블에 순서가 어긋난 경우를 처리하기 위한 플래그를 마련했던 것은 이를 위해서이다.
분산 트랜잭션: 사가
선형적 명령 수행
사가는 유명한 분산 트랜잭션 솔루션 가운데 하나로 MSA 에서는 사실상 표준이다.
- 모든 연산은 순서대로 정렬된다.
- 각 연산은 자기 데이터베이스에 독립적인 트랜잭션으로 실행된다.
- 연산은 첫 번째부터 마지막 순서대로 실행된다.
- 한 연산이 완료되면 다음 연산이 개시된다.
- 연산이 실패하면 전체 프로세스는 실패한 연산부터 맨 처음 연산까지 역순으로 보상 트랜잭션을 통해 롤백된다.
- n개의 연산을 실행하는 분산 트랜잭션은 보상 트랜잭션을 위한 n개의 연산까지 총 2n개의 연산을 준비해야한다.
오류가 발생하면 이체는 롤백되고 클라이언트는 오류 메시지를 받는다.
유효한 연산 순서에서 언급했듯 입금 전 인출부터 해야하는데, 연산 실행 순서 조율은 2가지 방법을 활용할 수 있다.
- 분산 조율(Choreography, 안무)
- 사가 분산 트랜잭션에 관련된 모든 서비스가 다른 서비스의 이벤트를 구독하여 작업을 수행하는 방식
- 완전히 탈 중앙화된 조율 방식
- 중앙 집중형 조율(Orchestration)
- 하나의 조정자(coordinator)가 모든 서비스가 올바른 순서로 작업을 실행하도록 조율
어떤 방식을 활용할지는 사업상의 필요와 목표에 따라 정한다.
분산 조율 방식은 서비스가 서로 비동기식으로 통신하므로 모든 서버스는 다른 서비스가 발생시킨 이벤트 결과로 어떤 작업을 수행할지 정하기 위해 내부적으로 상태 기계를 유지해야한다.
- 서비스가 많으면 관리가 어려워질 수 있다.
일반적으로 복잡한 상황을 잘 처리하는 중앙 집중형 조율 방식이 선호된다.
TC/C vs 사가
모두 애플리케이션 수준 분산 트랜잭션이지만 차이점도 존재한다.
TC/C | 사가 | |
---|---|---|
보상 트랜잭션 실행 | 취소 단계에서 | 롤백 단계에서 |
중앙 조정 | 예 | 예(중앙 집중형 조율 모드) |
작업 실행 순서 | 임의 | 선형 |
병렬 실행 가능성 | 예 | 아니오(선형적 실행) |
일시적으로 일관되지 않은 상태 허용 | 예 | 예 |
구현 계층: 애플리케이션 또는 데이터베이스 | 애플리케이션 | 애플리케이션 |
실무에서는 지연 시간(latency) 요구사항에 따라 둘 가운데 하나를 선택하면 된다.
- 지연 시간 요구사항이 없거나 앞서 살펴본 송금 사례처럼 서비스 수가 매우 적다면 아무거나 쓴다.
- MSA에서 흔히 하는데로 하고싶다면 사가를 선택
- 지연 시간에 민감하고 많은 서비스/운영이 관계된 시스템이라면 TC/C가 더 낫다.
이벤트 소싱
분산 트랜잭션 방안도 제대로 작동하지 않는 경우가 있을 수 있다.
- 애플리케이션 수준에서 잘못 된 작업을 입력(입력된 금액 자체가 잘못됨)
문제의 근본 원인을 역추적하고 모든 계정에서 발생하는 연산을 감사할 방법이 있으면 좋다.
전자 지갑 서비스 제공 업체도 외부 감사를 받을 수 있다.
- 특정 시점의 계정 잔액은?
- 과거 및 현재 계정 잔액이 정확한지?
- 코드 변경 후에도 시스템 로직이 올바른지 검증 방법은?
이러한 질문에 체계적으로 답할 수 있는 설계 철학 중 하나는 도메인 주도 설계(Domain-Driven Design, DDD)에서 개발된 기법인 이벤트 소싱이다.
정의
이벤트 소싱에는 네가지 중요한 용어가 있다.
명령
명령은 외부에서 전달된, 의도가 명확한 요청이다.
- ex) A에서 C로 1달러 이체
이벤트 소싱에서 순서는 아주 중요하므로 명령은 일반적으로 FIFO 큐에 저장된다.
이벤트
명령은 의도가 명확하지만 사실(fact)는 아니기 때문에 유효하지 않을 수도 있다.
유효하지 않은 명령은 실행할 수 없다.
- ex) 이체 후 잔액이 음수가 된다면 이체는 실패한다.
작업 이행 전에는 반드시 명령의 유효성을 검사해야 한다. 그리고 검사를 통과한 명령은 반드시 이행(fulfill)되어야 한다.
이렇게 이행된 결과를 이벤트라고 부른다.
- 이벤트는 검증된 사실로 실행이 끝난 상태이다.
- 이벤트에 대해 이야기 할 때는 과거 시제를 사용한다.
- 명령에는 무작위성(randomness)이나 I/O가 포함될 수 있지만 이벤트는 결정론적(deterministic)이다.
- 이벤트는 과거에 실제로 있었던 일이다.
이벤트 생성 프로세스에는 두 가지 중요한 특성이 있다.
- 하나의 명령으로 여러 이벤트가 만들어질 수 있다.(0 이상)
- 이벤트 생성 과정에는 무작위성이 개입될 수 있어 같은 명령에 항상 동일한 이벤트들이 만들어진다는 보장이 없다.
- 이벤트 생성 과정에는 외부 I/O 또는 난수가 개입될 수 있다.
이벤트 순서는 명령 순서를 따라야하므로 이벤트도 FIFO 큐에 저장한다.
상태
상태는 이벤트가 적용될 때 변경되는 내용이다.
지갑 시스템에서 상태는 모든 클라이언트 계정의 잔액으로, 맵 자료 구조로 표현할 수 있다.
- 키는 계정 이름 또는 ID
- 값은 계정 잔액
관계형 데이터베이스도 키-값 저장소로 볼 수 있다.
- 키는 PK, 값은 레코드
상태 기계
상태 기계는 이벤트 소싱 프로세스를 구동한다.
- 명령의 유효성을 검사하고 이벤트를 생성
- 이벤트를 적용하여 상태를 갱신
이벤트 소싱을 위한 상태 기계는 결정론적으로 동작해야 하므로 무작위성을 내포할 수 없다.
- I/O를 통해 외부에서 무작위적 데이터를 읽거나 난수를 사용하는 것은 허용되지 않는다.
- 이벤트를 상태에 반영하는 것 또한 항상 같은 결과를 보장해야한다.
명령을 이벤트로 변환하고 이벤트르 적용하는 두 가지 기능을 지원해야 하므로 명령 유효성 검사를 위한 상태 기계 하나와 이벤트 적용을 위한 상태 기계 하나를 둔다.
여기에 시간을 하나의 차원으로 추가하면 동적 관점으로도 표현할 수 있다.
명령을 수신하고 처리하는 과정을 계속 반복하는 시스템이다.
지갑 서비스 예시
지갑 서비스의 경우 명령은 이체 요청일 것이다.
명령은 FIFO 큐에 기록하며, 큐로는 카프카를 널리 사용한다.
- 상태, 즉 계정 잔액은 관계형 데이터베이스에 있다고 가정
상태 기계는 명령 큐에 들어간 순서대로 확인한다.
명령 하나를 읽을 때마다 계정에 충분한 잔액이 있는지 확인하며, 충분하다면 상태 기계는 각 계정에 대한 이벤트를 만든다.
- 명령이
A -> $1 -> C
라면A: -$1
,C: +$1
두 이벤트를 만든다.
- 명령 대기열에서 명령을 읽는다.
- 데이터베이스에서 잔액 상태를 읽는다.
- 명령의 유효성을 검사하고 유효하면 계정별로 이벤트를 생성한다.
- 다음 이벤트를 읽는다.
- 데이터베이스의 잔액을 갱신하여 이벤트 적용을 마친다.
재현성
이벤트 소싱이 다른 아키텍처에 비해 갖는 가장 중요한 장점은 재현성(reproducibility)이다.
분산 트랜잭션 방안의 경우 지갑 서비스는 갱신한 계정 잔액(상태)을 데이터베이스에 저장하며, 특정 시점의 잔액이 얼마인지만 보여준다.
- 계정 잔액이 변경된 이유는 알기 어렵다.
- 한번 업데이트가 이루어지고 나면 과거 잔액이 얼마였는지는 알 수 없다.
하지만 이벤트를 처음부터 다시 재생하면 과거 잔액 상태는 언제든 재구성할 수 있다.
이벤트 리스트는 불변이고(과거의 발생한 이벤트 이력을 변경할 수 없다) 상태 기계 로직은 결정론적이므로 이벤트 이력을 재생하여 만들어낸 상태는 언제나 동일하다.
재현성을 갖추면 감사관이 던지는 까다로운 질문에 쉽게 답할 수 있다.
- 특정 시점의 계정 잔액을 알 수 있는가?
- 시작부터 계정 잔액을 알고싶은 시점까지 이벤트를 재생한다.
- 과거 및 현재 계정 잔액이 정확한지 알 수 있는가?
- 이벤트 이력에서 계정 잔액을 다시 계산해 보면 잔액이 정확한지 확인할 수 있다.
- 코드 변경 후에도 시스템 로직이 올바른지 증명할 수 있는가?
- 새로운 코드에 동일한 이벤트 이력을 입력으로 주고 같은 결과가 나오는지 확인한다.
명령-질의 책임 분리(CQRS)
지금까지 효과적인 계좌 이체가 가능한 지갑 서비스를 설계했다.
하지만 클라이언트는 여전히 계정 잔액을 알 수 없으므로, 이벤트 소싱 프레임워크 외부의 클라이언트가 상태(잔액)을 알도록 할 방법이 필요하다.
직관적인 해결책 하나는 상태 이력 데이터베이스의 읽기 전용 사본을 생성한 후 외부와 공유하는 것인데 이벤트 소싱은 이와는 조금 다른 해결책을 제시한다.
이벤트 소싱은 상태, 즉 계정 잔액을 공개하는 대신 모든 이벤트를 외부에 보낸다.
따라서 이벤트를 수신하는 외부 주체가 직접 상태를 재구축할 수 있다.
이런 설계 철학을 명령-질의 책임 분리(Command-Query Responsibility Separation, CQRS)라고 부른다.
CQRS에서는 상태 기록을 담당하는 상태 기계는 하나고, 읽기 전용 상태 기계는 여러 개 있을 수 있다.
읽기 전용 상태 기계는 상태 뷰를 만들고, 이 뷰는 질의에 이용된다.
읽기 전용 상태 기계는 이벤트 큐에서 다양한 상태 표현을 도출할 수 있다.
- 클라이언트의 잔액 질의 요청을 처리하기 위해 별도 데이터베이스에 상태를 기록한다.
- 이중 청구 등의 문제를 쉽게 조사할 수 있도록 하기 위해 특정 기간 동안의 상태를 복원할 수도 있다.
읽기 전용 상태 기계는 실제 상태에 어느 정도 뒤처질 수 있으나 결국에는 같아지므로 결과적 일관성 모델을 따른다고 할 수 있다.
3단계: 상세 설계
높은 성능과 안정성 및 확장성을 달성하기 위한 기술에 대해 자세히 살펴본다.
고성능 이벤트 소싱
카프카를 명령 및 이벤트 저장소로, 데이터베이스를 상태 저장소로 사용했는데 가능한 최적화 방안을 살펴본다.
파일 기반 명령 및 이벤트 목록
로컬 디스크에 저장하는 방안
명령과 이벤트를 카프카 같은 원격 저장소가 아닌 로컬 디스크에 저장하는 방안을 생각해 볼 수 있다.
- 네트워크를 통한 전송 시간을 피할 수 있다.
- 이벤트 목록은 추가 연산만 가능한 자료 구조에 저장한다.
추가는 순차적 쓰기 연산으로 일반적으로 매우 빠르다.
- 운영체제는 보통 순차적 읽기 및 쓰기 연산에 최적화 되어있어 HDD에서도 잘 작동한다.
- 경우에 따라서는 무작위 메모리 접근보다 빠르게 실행될 수 있다.
최근 명령과 이벤트를 메모리에 캐시하는 방안
명령과 이벤트는 지속성 저장소에 보관된 이후에 처리되므로, 메모리에 캐시해 놓으면 로컬 디스크에서 다시 로드하지 않아도 된다.
mmap 기술은 앞서 언급한 최적화 구현에 유용하다.
- 로컬 디스크에 쓰는 동시에 최근 데이터는 메모리에 자동으로 캐시할 수 있다.
- 디스크 파일을 메모리 배열에 대응시킨다.
운영체제는 파일의 특정 부분을 메모리에 캐시하여 읽기 및 쓰기 연산을 높히므로, 추가만 가능한 파일에 이루어지는 연산의 경우 필요한 모든 데이터는 거의 항상 메모리에 있으므로 실행 속도를 높힐 수 있다.
파일 기반 상태
이번 설계안에서는 상태, 즉 잔액 정보를 관계형 데이터베이스에 저장했는데, 프로덕션 환경에서는 일반적으로 네트워크를 통해서만 접근 가능한 독립형 서버에서 데이터베이스를 실행한다.
그러나 명령 및 이벤트 저장소 최적화 방안과 마찬가지로, 상태 정보도 로컬 디스크에 저장할 수 있다.
- 파일 기반 로컬 관계형 데이터베이스 SQLite를 사용한다.
- 로컬 파일 기반 키-값 저장소 RocksDB를 사용한다.
본 설계안에서는 쓰기 작업에 최적화된 자료 구조인 LSM(Log-Structured Merge-tree)를 사용하는 RocksDB를 사용한다.
스냅숏
모든 것이 파일 기반일 때 재현 프로세스의 속도를 높힐 방법을 고민해보자.
재현성 확보를 위해 사용한 방법은 상태 기계로 하여금 이벤트를 항상 처음부터 다시 읽도록 하는 것 이었는데, 그 대신 주기적으로 상태 기계를 멈추고 현재 상태를 파일에 저장한다면 시간을 절약할 수 있다. 이 파일을 스냅숏 이라고 한다.
- 스냅숏은 과거 특정 시점의 상태로 변경 불가능하다.
- 스냅숏을 저장하고나면 상태 기계는 더 이상 최초 이벤트에서 시작할 필요가 없어진다.
- 어느 시점에 만들어졌는지 확인 후 그 시점부터 이벤트 처리를 시작한다.
지갑 서비스 같은 금융 애플리케이션은 00:00에 스냅숏을 찍는 일이 많다.
- 그래야만 재무팀이 당일 발생한 모든 거래를 확인할 수 있다.
스냅숏을 사용하면 읽기 전용 상태 기계는 해당 데이터가 포함된 스냅숏 하나만 로드하면 된다.
일반적으로 스냅숏은 거대한 이진 파일이며, 일반적으로는 HDFS(Hadoop Distributed File System)과 같은 객체 저장소에 저장한다.
모든 것이 파일 기반일 때 시스템은 컴퓨터 하드웨어의 I/O 처리량을 그 한계까지 최대로 활용할 수 있다.
신뢰할 수 있는 이벤트 소싱
로컬 파일 기반의 솔루션 성능은 원격 카프카나 데이터베이스에 저장된 데이터를 액세스하는 시스템보다는 좋다고 할 수 있으나, 로컬 디스크에 데이터를 저장하는 서버는 더 이상 무상태 서버가 아니며, 단일 장애 지점이 될 수 있다.
신뢰성 분석
개념적으로 서버 노드가 하는 일은 데이터와 연산이라는 두 가지 개념에 관계되어 있다.
그러나 데이터 내구성이 보장되는 한, 계산 결과는 코드를 다른 노드에서 돌리면 복구할 수 있다.
- 데이터 신뢰성이 훨씬 중요하다.
- 데이터가 손실되면 계산 결과를 복원할 방법이 없기 때문
이러한 이유로 시스템 신뢰성 문제는 대부분 데이터 신뢰성 문제이다.
- 파일 기반 명령
- 이벤트는 명령을 통해 만들어지니 명령의 신뢰성만 강력하게 보장하면 충분할 것 같지만 아니다.
- 이벤트 생성은 결정론적 과정이 아니며, 난수나 외부 입축력 등의 무작위적 요소가 포함될 수 있다.
- 명령의 신뢰성 만으로는 이벤트의 재현성을 보장할 수 없다.
- 파일 기반 이벤트
- 상태(잔액)에 변화를 가져오는 과저의 사실이다.
- 이벤트는 불변이며 상태 재구성에 사용할 수 있다.
- 파일 기반 상태
- 이벤트 목록을 재생하면 언제든 다시 만들 수 있다.(이벤트 목록의 신뢰성을 보장하면 따라온다.)
- 상태 스냅숏
- 이벤트 목록을 재생하면 언제든 다시 만들 수 있다.(이벤트 목록의 신뢰성을 보장하면 따라온다.)
따라서 높은 신뢰성을 보장할 유일한 데이터는 이벤트 뿐이다.
합의
높은 안정성을 제공하려면 이벤트 목록을 여러 노드에 복제해야 하며 다음을 보장해야한다.
- 데이터 손실 없음
- 로그 파일 내 데이터의 상대적 순서는 모든 노드에 동일
이러한 목표를 달성하는 데는 합의 기반 복제(consensus-based replication) 방안이 적합하다.
- 모든 노드가 동일한 이벤트 목록에 합의하도록 보장한다.
래프트 알고리즘을 사용하면 노드의 절반 이상이 온라인 상태일 때 그 모두에 보관된 추가 전용 리스트는 같은 데이터를 가진다.
래프트 알고리즘에서 노드는 세 가지 역할을 가질 수 있다.
- 리더
- 후보
- 팔로어
최대 하나의 노드만 클러스터의 리더가 되고 나머지 노드는 팔로어가 된다.
리더는 외부 명령을 수신하고 클러스터 노드 간에 데이터를 안정적으로 복제하는 역할을 담당한다.
고신뢰성 솔루션
복제 매커니즘을 활용하면 파일 기반 이벤트 소싱 아키텍처에서 단일 장애 지점 문제를 없앨 수 있다.
리더는 외부 사용자로 부터 들어오는 명령 요청을 받아 이벤트로 변환하고 로컬 이벤트 목록에 추가한다.
- 래프트 알고리즘은 새로운 이벤트를 모든 팔로워에 복제한다.
팔로어를 포함한 모든 노드가 이벤트 목록을 처리하고 상태를 업데이트한다.
래프트 알고리즘은 리더와 팔로어가 동일한 이벤트 목록을 갖도록 하며, 이벤트 소싱은 동일한 이벤트 목록에서 항상 동일한 상태가 만드러지도록 한다.
리더에 장애가 발생하면 래프트 알고리즘은 나머지 정상 노드 중에서 새 리더를 선출한다.
유의할 것은 리더 장애가 명령 목록이 이벤트로 변환되기 전에 발생 하는 경우이다.
- 클라이언트는 시간 초과 또는 오류 응답을 받고 새로 선출된 리더에게 같은 명령을 다시 보내야한다.
팔로어에 장애가 생기면 래프트는 죽은 노드가 다시 시작되거나 새로운 노드로 대체될 때 까지 기한없이 재시도하여 장애를 처리한다.
분산 이벤트 소싱
100만 TPS를 처리하려면 서버 한 대로는 충분하지 않다.
지금까지 설계한 아키텍처는 신뢰성 문제는 해결하지만 다른 문제가 있다.
- 전자 지갑 업데이트 결과는 즉시 받고 싶다.
- CQRS 시스템에서는 요청/응답 흐름이 느릴 수 있다.
- 클라이언트가 지갑의 업데이트 시점을 정확히 알 수 없어 주기적 폴링에 의존해야 할 수 있다.
- 단일 래프트 그룹의 용량은 제한되어 있다.
- 일정 규모 이상에서는 데이터를 샤딩하고 분산 트랜잭션을 구현해야한다.
풀 vs 푸시
풀 모델에서는 외부 사용자가 읽기 전용 상태 기계에서 주기적으로 실행 상태를 읽는다.
모델은 실시간이 아니므로 읽는 주기를 너무 짧게 설정하면 지갑 서비스에 과부하가 걸릴 수도 있다.
외부 사용자와 이벤트 소싱 사이에 역방향 프락시를 추가하면 개선될 수 있다.
외부 사용자는 역방향 프락시에 명령을 보내고, 역방향 프락시는 명령을 이벤트 소싱 노드로 전달하는 한편 주기적으로 실행 상태를 질의한다.
- 여전히 통신이 실시간으로 이루어지지는 앖지만 클라이언트의 로직은 단순해진다.
역방향 프락시르 두고 나면 읽기 전용 상태 기계를 수정하여 응답 속도를 높힐 수 있다.
일기 전용 상태 기계로 하여금 이벤트를 수신하자마자 실행 상태를 역방향 프락시에 푸시하도록 하면, 사용자에게 실시간으로 응답이 이루어지는 느낌을 줄 수 있다.
분산 트랜잭션
모든 이벤트 소싱 노드 그룹이 동기적 실행 모델을 채택하면 TC/C나 사가 같은 분산 트랜잭션 솔루션을 재사용할 수 있다.
- 키의 해시 값을 2로 나누어 데이터가 위치할 파티션을 정한다고 가정
송금에는 2개의 분산 연산(A: -$1
, C: +$1
)이 필요하다.
- A가 사가 조정자에게 분산 트랜잭션을 보낸다.(
A: -$1
,C: +$1
) - 사가 조정자는 단계별 상태 테이블에 레코드를 생성하여 트랜잭션 상태를 추적한다.
- 사가 조정자는 작업 순서를 검토한 후
A: -$1
를 먼저 처리하기로 결정하고A: -$1
명령을 계정 정보 A가 있는 파티션 1로 보낸다. - 파티션 1의 래프트 리더는
A: -$1
명령을 수신하고 명령 목록에 저장한 후, 명령의 유효성을 검사하고 이벤트로 변환한다.- 래프트 합의 알고리즘을 통해 동기화가 완료되면 이벤트가 실행된다.
- 이벤트가 동기화도면 파이션 1의 이벤트 소싱 프레임워크가 CQRS를 사용하여 데이터를 읽기 경로로 동기화한다. 읽기 경로는 상태 및 실행 상태를 재구성한다.
- 파티션 1의 읽기 경로는 이벤트 소싱 프레임워크를 호출한 사가 조정자에게 상태를 푸시한다.
- 사가 조정자는 파티션 1에서 성공 상태를 수신한다.
- 사가 조정자는 단계별 상태 테이블에 파티션 1의 작업이 성공했음을 나타내는 레코드를 생성한다.
- 첫 번째 작업이 성공했으므로 사가 조정자는 두 번째 작업인
C: +$1
를 실행한다. 조정자는 계정 C의 정보가 포함된 파티션 2에 명령을 보낸다. - 파티션 2의 래프트 리더가
C: +$1
명령을 수신하여 명령 목록에 저장한다. 유효한 명령이면 이벤트로 변환되고 데이터를 동기화한다. 동기화가 끝나면 해당 이벤트가 실행된다. - 이벤트가 동기화되면 파티션 2의 이벤트 소싱 프레임워크는 CQRS를 사용하여 데이터 읽기 경로로 동기화한다. 읽기 경로는 상태 및 실행 상태를 재구성ㅎ나다.
- 파티션 2읽기 경로는 이벤트 소싱 프레임위크를 호출한 사가 조장자에 상태를 푸시한다.
- 사가 조정자는 파티션 2로부터 성공 상태를 받는다.
- 사가 조정자는 단계별 상태 테이블에 파티션 2의 작업이 성공했음을 나타내는 레코드를 생성한다.
- 모든 작업이 성공하고 분산 트랜잭션이 완료되며 사가 조정자는 호출자에게 결과를 응답한다.
4단계: 마무리
이번 장에서는 초당 100만 건 이상 결제 명령을 처리할 수 있는 지갑 서비스를 설계해 보았다.
- 레디스 같은 인베모리 키-값 저장소를 사용하는 솔루션
- 데이터가 내구성이 없는 문제점
- 인메모리 캐시를 트랜잭션 데이터베이스로 바꿈
- 여러 노드에 걸친 분산 트랜잭션을 지원하기 위한 2PC, TC/C, 사가와 같은 트랜잭션 프로토콜
- 데이터 감사가 어렵다는 문제점
- 이벤트 소싱 방안
- 외부 데이터베이스와 큐를 사용
- 성능이 떨어짐
- 명령 이벤트, 상태 데이터를 로컬 파일 시스템에 저장하도록 개선
- 데이터를 한 곳에 두면 SPOF가 되는 문제가 있음
- 래프트 합의 알고리즘을 사용하여 이벤트 목록을 여러 노드에 복제
- 외부 데이터베이스와 큐를 사용
- 이벤트 소싱에 CQRS 개념 도입
- 외부 사용자에게 비동기 이벤트 소싱 프레임워크를 동기식처럼 제공하기 위한 역방향 프락시 추가
- TC/C, 사가 프로토콜을 사용하여 어러 노드에 명령 실행을 조율하는 방법