대규모 시스템 설계 기초 2 (12장)
전자 지갑
전자 지갑을 통한 송금 시스템을 설계해 보자.
인메모리 샤딩
지갑 애플리케이션은 모든 사용자 계정의 잔액을 유지하는데, 이를 가장 쉽게 나타낼 수 있는 자료 구조는 키-값 저장소다. 레디스 노드 클러스터를 구성해서 사용자의 데이터를 저장할 수 있을 것이다.
클러스터를 구성하는 경우, 높은 가용성을 보장하는 전문 저장소 주키퍼를 레디스 노드의 파티션 수 및 주소 저장소로 사용하자.
이렇게 구현한 경우 무상태 서비스로 구현이 가능하다. 문제는, 이체 요청에 대해서 매번 2개의 레디스 노드를 업데이트 해야 한다는 것이다. 이 2개의 업데이트를 하나의 원자적 트랜잭션으로 실행할 수 없다는 것이 이 방법의 단점이다.
분산 트랜잭션
데이터베이스 샤딩
서로 다른 2개의 저장소 노드를 갱신하기 위해서는, 각 레디스 노드를 RDB 로 교체해야 한다. 문제는 한 이체 명령이 서로 다른 두 DB 의 2 계정의 데이터를 업데이트해야 한다는 것이다.
분산 트랜잭션:2단계 커밋
분산 트랜잭션을 구현할 수 있는 저수준 방법으로 2단계 커밋(2PC) 이 있다.
- 조정자(지갑 서비스)는 여러 데이터베이스에 읽기 및 쓰기 작업을 수행한다. 데이터베이스 A,C 중 업데이트를 하고자 하는 레코드에 락을 건다.
- 트랜잭션을 커밋하려 할 때, 조정자는 모든 DB 에 트랜잭션 준비를 요청한다.
- 모든 DB 에서 응답이 오면, 다음 절차를 수행한다.
- 모든 db 가 ‘예’ 인 경우, 트랜잭션 커밋을 요청한다.
- 하나라도 ‘아니요’ 를 응답하면 모든 db 에 트랜잭션 중단을 요청한다.
이 방법에는 다른 노드의 메시지를 기다리는 동안, 락이 오랫동안 잠겨있다는 것이다. 따라서 성능상 좋지 못하다. 또한, 조정자가 SPOF 가 될 수 있다는 단점이 있다.
분산 트랜잭션: TC/C
TC/C(시도-확정/취소, Try-Confirm/Cancel)은 2 단계로 구성된 보상 트랜잭션 방식이다.
- 조정자는 모든 db 에 트랜잭션에 필요한 자원 예약을 요청한다.
- 조정자는 db 로부터 회신을 받는다.
- 모든 응답이 ‘예’ 인 경우, db 에 작업 확인을 요청한다(Try-Confirm)
- 어느 하나라도 ‘아니오’ 인 경우, 작업 취소를 요청한다(Try-Cancel)
2PC 의 두 단계는 하나의 트랜잭션의지만, TC/C 에서는 각 단계가 별도 트랜잭션으로 작동한다. 계좌 A 에서 계좌 C 로 1달러를 이체하는 상황을 가정해보자.
1단계
- 조정자는 계정 A 가 포함된 DB 에 A 의 잔액을 1달러 감소시키는 트랜잭션을 시작한다.
- 조정자는 C 가 포함된 DB 에 아무 작업도 하지 않는다.(NOP 명령을 보냄)
2단계
모두 예인 경우, C 의 계정에 1$ 를 추가하는 트랜잭션을 시작한다.
취소인 경우, C 에는 NOP 명령을 보내고, A 에는 1 달러를 다시 추가한다.
2pc 와 TC/C 의 비교
표로 비교하면 다음과 같다.
2pc | TC/C | |
---|---|---|
1단계 | db 의 로컬 트랜잭션은 아직 완료되지 않음 | 모든 로컬 트랜잭션이 커밋되거나 취소된 상태로 종료 |
2단계:성공 | 모든 로컬 트랜잭션을 커밋 | 필요한 경우 새 로컬 트랜잭션 실행 |
2단계:실패 | 모든 로컬 트랜잭션을 취소 | 이미 커밋된 트랜잭션의 실행 결과를 되돌림(undo) |
TC/C 는 보상 기반 분산 트랜잭션이라고도 부른다. 실행 취소 절차를 비즈니스 로직으로 구현하고, 특정 DB 에 구애받지 않는다.
단계별 상태 테이블
TC/C 실행 도중에 조정자가 다시 시작되면 어떻게 될까. 메모리에 들고 있던 과거 작업 기록이 사라질 것이다. 해결책은 간단하다. TC/C 의 진행 상황, 즉 각 단계 상태 정보를 트랜잭션 데이터베이스에 저장하면 된다.
- 분산 트랜잭션의 ID 와 내용
- 각 데이터베이스에 대한 시도 단계 (not sent yet, has been sent, response received)
- 두 번째 단계의 이름(Confirm, Cancel)
- 두 번째 단계의 상태
- 순서가 어긋났음을 나타내는 플래그
단계별 상태 테이블은 돈을 인출할 계정이 있는 db 에 두자.
잘못된 순서로 실행된 경우
TC/C 에는 실행 순서가 어긋날 수 있다는 문제가 있다. 계정 A 에 대한 작업이 실패하여, A,C 모두에 취소 명령을 전송한다고 가정해보자. C 입장에서 시도 명령 전에 취소 명령부터 받으면, 취소 명령이 무시될 수 있다.
이를 해결하기 위해서는, 취소 명령을 저장해둬야 한다. 다만 아직 수행하지 않았다는 플래그를 설정해두면 된다. 시도 명령이 도착하면, 먼저 도착한 취소 명령이 있는지 확인하고, 있다면 취소 처리를 하면 된다.
분산 트랜잭션: 사가
Saga 는 마이크로서비스 아키텍쳐에서 표준이되는 분산 트랜잭션 솔루션이다.
- 모든 연산은 순서대로 정려ㅛㄹ된다. 각 연산은 자기 DB 에 독립 트랜잭션으로 실행된다.
- 연산은 첫 번째부터, 마지막까지 순서대로 실행된다. 한 연산이 완료되면 다음 연산이 개시된다.
- 연산이 실패하면 전체 프로세스는 실패한 연산부터 맨 처음 연산까지 역순으로 보상 트랜잭션을 통해 롤백된다.
연산 실행 순서는 분산 조율 방식과 중앙 집중형 조율 방식이 있다.
분산 조율 방식에서는 트랜잭션과 관련된 모든 서비스가 다른 서비스의 이벤트를 구독하여 작업하는 방식이다. 그러나 설계가 어렵다. 중앙 집중형 조율을 말 그대로 하나의 조정자를 두는 방식이다.
TC/C vs 사가
사가 | TC/C | |
---|---|---|
보상 트랜잭션 실행 | 취소 단계에서 | 롤백 단계에서 |
중앙 조정 | 예(중앙 집중형 조율 모드에서만) | 예 |
작업 실행 순서 | 임의 | 선형 |
병렬 실행 가능성 | 예 | 아니요(선형 실행) |
일시적으로 일관되지 않은 상태 허용 | 예 | 예 |
구현 계층 | 애플리케이션 | 애플리케이션 |
이벤트 소싱
외부 감사를 받는 경우, 특정 시점의 잔액을 재현하거나, 과거의 이력이 정확한지 확인해야 할 수 있다.
정의
이벤트 소싱에는 4가지 용어가 있다.
명령
명령은 외부에서 전달된 의도가 명확한 요청이다.
이벤트
이벤트는 명령이 수행된 결과이다.
상태
상태는 이벤트가 적용될 때 변경되는 내용이다.
상태 기계
상태 기계는 이벤트 소싱 프로세스를 구동한다. 이벤트를 생성하며, 적용하여 상태를 갱신한다.
지갑 서비스 예시
지갑 서비스에서 명령은 이체 요청이다. 상태 기계는 5 단계로 동작한다.
- 명령 대기열에서 이체 명령을 읽는다.
- 데이터베이스에서 잔액 상태를 읽는다.
- 명령의 유효성을 검사한다. 유효하면 계정별로 이벤트를 생성한다.
- 다음 이벤트를 읽는다.
- 데이터베이스의 잔액을 갱신하여 이벤트 적용을 마친다.
재현성
이벤트 소싱은 이벤트를 재생함으로서 과거 상태를 재현할 수 있다.
명령-질의 책임 분리(CQRS)
클라이언트가 잔액을 알려면 어떻게 해야 할까? 상태 이력 DB 의 읽기 전용 사본을 확인하면 될 것이다. 이벤트 소싱의 경우에는, 이벤트를 외부에서 수신해서 상태를 재구축하게끔할 수 있다.
이렇게 재구축된 상태를 클라이언트에서 질의함으로서 결과를 가져갈 수 있을 것이다. 즉, 상태 기록을 담당하는 상태 기계와, 읽기 전용 상태 기계가 분리된 것이다.
고성능 이벤트 소싱
성능을 높일 수 있는 최적화를 할 수 있을 것이다.
파일 기반의 명령 및 이벤트 목록
명령과 이벤트를 카프카 같은 원격 저장소가 아닌 로컬 디스크에 저장하는 방안을 생각해볼 수 있다. 최근 명령과 이벤트를 메모리에 캐시할 수도 있을 것이다. mmap 기술을 사용하면, 로컬 디스크를 쓰는 동시에 최근 데이터를 메모리에 캐시할 수 있다.
파일 기반 상태
상태 정보도 롴컬 디스크에 저장할 수 있을 것이다. 파일 기반 로컬 RDB 인 SQLite 나, RocksDB 를 사용할 수 있을 것이다.
스냅숏
모든 것이 파일 기반일 때 재현 프로세스의 속도를 높일 수 있다. 상태 기계로 하여금 이벤트를 항상 처음부터 읽게 함으로서, 어느 시점의 상태를 재현할 수 있을 것이다.
그 대신, 주기적으로 상태 기계를 멈추고 현재 상태를 파일에 저장한다면 시간을 절약할 수 있을 것이다. 이 파일을 스냅숏이라고 부른다. 스냅숏은 과거 특정 시점의 상태로, 변경이 불가능ㅎ라다. 스냅숏 시점부터 이벤트 처리를 시작함으로서 재현 시간을 줄일 수 있을 것이다.
스냅숏은 일반적으로 HDFS 와 같은 객체 저장소에 저장한다.
고성능 이벤트 소싱과 신뢰성
높은 안정성을 제공하기 위해서는 이벤트 목록을 여러 노드에 복제해야 한다. 이를 위해서 합의 기반 복제 방안이 적합하다.
레프트 알고리즘을 사용하면 노드의 절반 이상이 온라인 상태면 그 모두에 보관된 추가 전용 리스트는 같은 데이터를 가진다. 복제 메커니즘을 통해서 파일 기반 이벤트 소싱 아키텍쳐에서, 이벤트를 동기화할 수 있다.
분산 이벤트 소싱
전자 지갑 업데이트 결과를 클라이언트가 즉시 받고 싶더라도, CQRS 시스템에서는 업데이트 시점을 정확히 알 수가 없다.
풀 vs 푸시
외부 사용자가 읽기 전용 상태 기계에서 주기적으로 실행 상태를 읽는 풀 방식을 생각해보자. 역방향 프록시를 추가하면 성능을 개선할 수 있다. 외부 사용자는 역방향 프록시에 명령을 보내고, 프록시는 명령을 이벤트 소싱 노드로 전달하고, 주기적으로 최신 상태를 질의한다.
역방향 프락시를 두면, 읽기 전용 상태 기계에서 이벤트를 수신하자마자 실행 상태를 역방향 프락시로 푸시할 수 있다. 그러면 사용자는 최신화된 응답을 받아볼 수 있을 것이다.
최종 분산 이벤트 소싱 아키텍쳐
- 사용자 A 가 사가 조정자에게 분산 트랜잭션을 보낸다. 두 개의 연산이 있다. a-=1 과 b+=1 이다.
- 사가 조정다는 단계별 상태 테이블에 레코드를 생성하여, 트랜잭션 상태를 추적한다.
- 사가 조정자는 작업 순서를 검토하여 a-=1 을 먼저 처리한다. 조정자는 a-=1 명령을 계정 A 정보가 들어있는 파티션 1로 보낸다.
- 파티션 1의 래프트 리더는 a-=1 명령을 수신하고, 명령 목록에 저장한다. 명령이 유효하다면 이벤트로 변환한다. 이벤트는 여러 노드 사이에 동기화되고, 이벤트는 실행된다.
- 파티션 1의 이벤트 소싱 프레임워크가 CQRS 를 사용하여 데이터를 읽기 경로로 동기화한다.
- 파티션 1의 읽기 경로는 이벤트 소싱 프레임워크를 호출한 사가 조정자에게 상태를 푸시한다.
- 사가 조정자는 파티션 1에서 성공 상태를 수신한다.
- 사가 조정자는 단계별 상태 테이블에 파티션 1의 작업이 성공했음을 나타내는 레코드를 생성한다.
- 사가 조정자는 두 번째 작업을 실행한다. 파티션 2 에 c+=1 명령을 보낸다.
- 파티션 2의 래프트 리더가 명령을 수신하여 명령 목록에 저장한다. 명령이 유효하다면 이벤트로 변환한다.
- 이벤트는 동기화되고 파티션 2의 이벤트 소싱 프레임워크는 CQRS 를 사용하여 데이터를 읽기 경로로 동기화한다.
- 파티션 2의 읽기 경로는 이벤트 소싱 프레임워크를 호출한 사가 조정자에 상태를 푸시한다.
- 사가 조정자는 파티션 2로부터 성공 상태를 받는다.
- 사가 조정자는 단계별 상태 테이블에 파티션 2의 작업이 성공했음을 나타내는 레코드를 생성한다.
- 모든 작업 이 성공하고 분산 트랜잭션이 완료된다. 호출자에게 결과를 응답한다.