분산 시스템에서 "메시지를 빠짐없이 전달했다"는 것만으로는 아무것도 보장되지 않고 유실이 0%여도 노드들의 상태는 갈라진다.
상태 발산이 왜 일어나는지부터 전체 순서 브로드캐스트(TOB)가 왜 등장했고 상태 기계 복제(SMR)와 어떤 관계인지, 그리고 이 구조가 실무에서 어떤 대가를 요구하는지 정리한다.
1. 문제점: 메시지를 전부 받아도 상태가 달라짐
가격 시스템을 예로 들어보자. 상품 A의 가격을 두 주체가 거의 동시에 바꾼다.
- MD가 정가를 10,000원으로 수정하고 프로모션 엔진이 9,000원으로 내린다. 복제본은 두 개고 메시지 유실은 단 한 건도 없다.
(네트워크 지연이 경로마다 다름)
MD ──► E1: SET price=10000 ──┬──────────────► Replica 1
└──────┐
Promo ──► E2: SET price=9000 ──┬──────┼───────► Replica 1
└──────┴───────► Replica 2 (E2가 먼저 도착)
┌──────────────────────────────────────────────┐
│ Replica 1 적용 순서: E1 → E2 최종값 9000 │
│ Replica 2 적용 순서: E2 → E1 최종값 10000 │
└──────────────────────────────────────────────┘
↑
상태 발산 (divergence)
두 복제본 모두 모든 메시지를 받았고 "전달은 신뢰성 있게 보장된다"는 조건을 완벽히 만족했는데도 최종 상태가 다르다.
그래서 필요한 보장이 하나 더 있다. 모든 노드에 동일한 순서로 전달해야 한다.
2. 타임스탬프로는 상태 보장이 안 되는 이유
가장 먼저 떠오르는 해법은 각 메시지에 발생 시각을 찍어 보내고 각 노드가 타임스탬프 순으로 정렬해서 적용하는 것이다.
E1(t=100), E2(t=105)이니 두 노드 모두 E1→E2로 적용하게 될 것 같다.
하지만 시계가 완벽히 동기화되어 있다고 가정해도 이 방식은 실패한다.
노드 N의 정렬 버퍼 (시계 완벽 동기화, 유실 0%)
t=현재 ─────────────────────────────────────────────►
[ts=100 도착] [ts=105 도착] ... 그 다음?
▲
"지금 ts=105을 적용해도 되는가?"
→ `ts=102` 메시지가 아직 네트워크 안에 떠 있을 수 있다.
비동기 네트워크에는 지연 상한이 없으므로 알 방법이 없다.
선택지 두 개뿐:
(A) 영원히 기다린다 → 활성(liveness) 사망
(B) 적당히 기다리고 적용 → 늦게 온 ts=102가 순서 위반. 안전성 사망
타임스탬프는 "이 메시지가 언제 생겼는지"만 알려주고 "그 앞에 무엇이 더 있는지"는 알려주지 않는다. 유실이 없어도 지연만으로 같은 문제가 생긴다.
해결방안은 빈틈없는 연속적인 정수 시퀀스다. 연속된 번호를 쓰면 5번을 받았는데 4번이 없다 = 구멍(gap)을 탐지할 수 있고 그러면 4번이 올 때까지 기다리면 된다.
이렇게 만들어진 물건이 바로 로그(log)다. PostgreSQL WAL의 LSN(LogSequenceNumber), MySQL binlog 포지션, Kafka 파티션 오프셋, Raft 로그 인덱스가 전부 같다.
index: 1 2 3 4 5
┌────────┬────────┬────────┬────────┬────────┐
│ E1 │ E2 │ E7 │ E3 │ E9 │ ← append only
└────────┴────────┴────────┴────────┴────────┘
▲
모든 노드가 이 로그를 같은 순서로 읽는다
전체 순서 브로드캐스트(Total Order Broadcast): 노드 간 메시지 교환 프로토콜로서 다음 두 속성을 항상 만족한다.
- 신뢰성 있는 전달(reliable delivery): 메시지가 유실되지 않는다. 한 노드에 전달되면 모든 노드에 전달된다.
- 전체 순서가 정해진 전달(totally ordered delivery): 메시지가 모든 노드에 같은 순서로 전달된다.
두 속성은 노드가 죽거나 네트워크가 끊겨도 유지되어야 한다. 복구되면 재전송해서 결국 모두가 같은 순서를 보게 만들어야 한다.
3. 시퀀스 발행기의 딜레마 - TOB가 곧 합의다
그럼 시퀀스 번호를 누가 발급할까. 답은 "발급기 노드를 하나 정하고 모든 쓰기가 거기를 통과하게 한다"다. 그런데 이 순간 문제가 원점으로 돌아간다.
- 발급기가 죽으면 전체가 멈춘다 (활성 상실)
- 죽은 줄 알았던 발급기가 GC나 네트워크 분단에서 깨어나 번호를 중복 발급한다 (스플릿 브레인)
이 문제를 풀기위한 도구가 있는데 바로 에포크 번호 + 정족수다.
epoch 1: 리더 A ──► 번호 1,2,3 발급 ... A가 GC로 멈춤
│
나머지 노드들: "A 응답 없음" → 투표 → 리더 B 선출, epoch 2
│
epoch 2: 리더 B ──► 번호 4,5,6 발급
│
A가 깨어남: "나 아직 리더인데?" → 번호 4 발급 시도
▼
팔로워: "너 epoch 1이잖아. 우린 epoch 2를 봤다." → 거부
"누가 리더인가"를 정하는 일 자체가, 모든 노드가 같은 결정에 같은 순서로 도달해야 하는 문제다. 전체 순서를 만들려면 리더가 필요한데, 리더를 정하려면 전체 순서 합의가 필요하다.
전체 순서 브로드캐스트 ≡ 합의(consensus).
둘은 서로를 구현할 수 있는 등가 문제다. 그래서 TOB를 제대로 구현한 시스템은 예외 없이 합의 알고리즘(Raft, Paxos, Zab)을 쓴다.
- Raft와 Zab은 "에포크당 리더는 최대 1명"을 정족수 투표로 보장해 이 순환을 끊는다. 무한 재선출 가능성(FLP)을 감수하는 대신 안전성만은 절대 깨지 않는 방향의 타협이다.
4. 상태 기계 복제 - 세 가지 전제
TOB로 "모든 노드가 같은 순서로 읽는 로그"를 확보했다. 이제 각 노드가 그 로그를 순서대로 적용하면 같은 상태가 된다. 그런데 이 주장은 그냥 참이 아니고 숨은 전제 위에서만 참이다.
Replica 1, 2, 3 은 로그 1~100번까지 적용해서 상태 S₁₀₀ 에 있다.
트래픽이 늘어서 Replica 4 를 새로 띄운다.
Replica 4: (빈 상태) ─ 101번 로그 ─► ???
Replica 1: S₁₀₀ ─ 101번 로그 ─► S₁₀₁
Replica 4도 101번부터 같은 로그를 같은 순서로 결정적으로 적용하는데도 같은 상태에 도달하지 못한다. 초기 상태가 다르기 때문이다.
상태 기계 복제(State Machine Replication)
- 서비스를 결정적 상태 기계로 모델링하고, 모든 복제본이 동일한 초기 상태에서 출발해 동일한 순서로 전달된 동일한 명령 시퀀스를 적용하면, 모든 복제본은 동일한 상태 궤적을 따라간다. (즉, 각 복제본 노드들의 상태가 발산하지 않는다)
전제 3개: ① 결정성 ② 동일 초기 상태 ③ 동일 순서 입력
그리고 ③을 제공하는 것이 바로 TOB(=합의)다.
두 개념의 관계는 이렇게 정리된다.
┌──────────────────────────────────────────────────────────┐
│ TOB = "입력을 어떤 순서로 줄 것인가"를 푸는 합의 계층 │
│ SMR = "그 순서를 받아 상태를 만드는 실행 모델" │
│ │
│ TOB 없는 SMR → 상태 발산 (전제 ③ 붕괴) │
│ SMR 없는 TOB → 순서만 있고 쓸 데가 없음 (그냥 로그) │
└──────────────────────────────────────────────────────────┘
전제 ②(동일 초기 상태)는 실제 시스템에서 전부 같은 패턴으로 해결한다. 스냅샷 + 오프셋부터 따라잡기다.
| 시스템 | 스냅샷(초기 상태) | 이후 따라잡기 |
|---|---|---|
| PostgreSQL 스트리밍 복제 | pg_basebackup으로 데이터 디렉터리 물리 복사 |
복사 시작 LSN부터 WAL 스트리밍 |
| MySQL 복제 | 덤프 또는 클론 플러그인 | 덤프 시점의 binlog 파일:포지션부터 재생 |
| Raft (etcd) | InstallSnapshot RPC로 상태 머신 스냅샷 전송 |
lastIncludedIndex + 1부터 로그 복제 |
| Kafka Streams | changelog 토픽에서 RocksDB 스토어 복원 | 복원 완료 오프셋부터 스트림 소비 |
스냅샷과 로그 오프셋은 원자적으로 짝지어져야 한다. "LSN 500 시점의 스냅샷"임을 알아야 501부터 재생할 수 있다. 오프셋을 모르는 스냅샷은 쓸 수 없고 어긋나면 이벤트를 두 번 적용하거나 빠뜨린다. 그래서 pg_basebackup은 백업 시작 LSN을 backup_label에 기록하고 Raft 스냅샷은 lastIncludedIndex/Term을 반드시 함께 저장한다.
5. 결정성이 실제로 깨지는 방식
전제 ①(결정성)이 실무에서 가장 자주 깨진다. 아래는 실제 애플리케이션상에서 결정성을 깨트리는 후보 목록이다.
| 유형 | 구체 사례 | 왜 깨지는가 |
|---|---|---|
| 시간 | LocalDateTime.now(), System.currentTimeMillis(), Instant.now() |
노드마다, 재생 시점마다 값이 다름 |
| 난수/식별자 | UUID.randomUUID(), Random, DB AUTO_INCREMENT |
호출마다 다름 |
| 외부 I/O | REST 호출, 다른 DB 조회, S3 read | 응답이 시점 의존 + 실패/타임아웃 자체가 비결정적 |
| 순회 순서 | hashCode()를 오버라이드하지 않은 객체를 HashMap 키로 사용 |
identity hash → 실행마다 순회 순서가 다름 |
| 병렬 처리 | parallelStream(), 멀티스레드 집계, 코루틴 async 결과 병합 |
완료 순서 비결정적. 특히 double 누적은 결합 순서에 결과가 의존 |
| 환경 의존 | 기본 Locale, TimeZone, Charset, String.format |
노드 설정이 다르면 다른 문자열 생성 |
| 코드 버전 | 로직 수정 후 배포하고 옛 로그를 재생 | 같은 입력에 다른 출력 |
참고로 HashMap은 무조건 비결정적인 게 아니다. 같은 JVM에서 같은 삽입 순서이고 키가 String/Integer처럼 명세된 hashCode를 가지면 순회 순서도 재현된다(String.hashCode()는 JLS에 값이 명시되어 있다).
진짜 위험한 건 객체를 키로 쓰면서 hashCode를 오버라이드하지 않은 경우다. 안전하게 가려면 LinkedHashMap, TreeMap, 또는 정렬 후 순회다.
무엇보다 코드 버전이 가장 위험하다. SMR 시스템에서 로직 배포는 스키마 마이그레이션과 같은 위험을 갖는다. 어제 배포한 코드로 3개월 전 로그를 재생하면 원래 상태와 다른 상태가 나온다. 그래서 로직 버전 관리를 위해 Temporal은 Workflow.getVersion() / patched API를 제공한다.
비결정적 값을 금지하지 않고 결정성을 지키는 방법
현실 로직은 시간과 식별자를 안 쓸 수 없다. 해법은 하나다.
비결정적 결정은 상태 기계 안에서 하지 않는다. 리더가 먼저 값을 확정해서 로그 엔트리에 박아 넣고, 상태 기계는 로그에 적힌 값만 읽는 순수 함수가 된다.
[잘못된 설계 — 명령만 로그에 기록]
로그 엔트리: { cmd: "CREATE_PAYMENT", amount: 10000 }
├─► Replica 1: id=UUID_a, at=10:00:00.123 ← 각자 생성
├─► Replica 2: id=UUID_b, at=10:00:00.157 ← 값이 다름
└─► Replica 3: id=UUID_c, at=10:00:00.201 ← 발산
[올바른 설계 — 리더가 값을 확정해서 로그에 포함]
로그 엔트리: { cmd: "CREATE_PAYMENT", amount: 10000, id: "UUID_x", at: "10:00:00.123" }
├─► Replica 1 ─┐
├─► Replica 2 ─┼─► 로그에 적힌 값만 사용 → 동일 상태
└─► Replica 3 ─┘
3개월 뒤 재생해도 로그에 값이 남아있으므로 같은 상태 재현 됨.
실제 구현체가 전부 이걸 한다.
- Temporal:
Workflow.currentTimeMillis()는 실제 시계가 아니라 이벤트 히스토리에 기록된 시각을 돌려준다. 비결정적 코드가 꼭 필요하면SideEffect로 감싸 결과를 히스토리에 기록하고 재생 시에는 실행하지 않고 기록된 값을 읽는다. - Kafka Streams: wall clock 대신 레코드 타임스탬프 기반 윈도우 계산을 권고한다.
- MySQL 복제: STATEMENT 포맷은 SQL 문 자체를 전송하므로
INSERT ... VALUES (NOW(), UUID())가 소스와 레플리카에서 다른 값을 만든다. MySQL은 이런 함수를 unsafe로 분류해 경고를 낸다.
8.0의 기본값은 ROW이고 8.0.34에서binlog_format자체가 deprecated되어 향후 ROW만 남는다.
6. 로그의 층위 - 무엇을 로그에 담을 것인가
MySQL ROW 포맷은 SQL 문 대신 변경된 행의 before/after 이미지를 보낸다. 즉 명령이 아니라 결과를 보낸다. 결정성 문제가 사라지지만 공짜가 아니다.
UPDATE products SET price = price * 0.9 WHERE category = 'FASHION';
-- 영향 행: 1,000,000
STATEMENT → binlog에 SQL 한 줄 (수백 바이트)
ROW → binlog에 100만 행의 before/after 이미지 (수백 MB~GB)
성능은 오히려 뒤집히는 부분이 있다. 적용은 ROW가 빠른 경우가 많다. STATEMENT는 레플리카가 같은 쿼리를 다시 실행하므로 소스에서 한 full scan을 레플리카도 반복하는 반면 ROW는 PK로 해당 행만 찾아 바꾼다.
단, PK 없는 테이블 + ROW 포맷이면 행 변경마다 full table scan이 발생해 복제 랙이 폭발한다. 보통 "왜 갑자기 레플리카가 못 따라오지?"의 흔한 원인이다.
그런데 가장 큰 손실은 아키텍처 쪽이다. 로그를 다른 용도로 재해석하는 능력을 잃는다.
| 층위 | 로그에 담기는 것 | 결정성 | 재해석 가능성 | 사례 |
|---|---|---|---|---|
| 문장/명령 기반 | SQL, 명령 객체 | 취약 | 높음 | MySQL STATEMENT, 이벤트 소싱 커맨드 |
| 논리적 행 기반 | 행 before/after | 안전 | 중간 | MySQL ROW binlog, PG logical decoding |
| 물리적 페이지 기반 | 디스크 페이지 바이트 변경 | 안전 | 거의 없음 | PostgreSQL 물리 WAL |
물리적 WAL (PostgreSQL 스트리밍 복제)
"블록 42의 오프셋 128부터 이 바이트로 바꿔라"
└─► 같은 PG 메이저 버전, 같은 아키텍처만 해석 가능
→ Redis 캐시나 검색엔진에 먹일 수 없다
→ 메이저 버전 업그레이드 시 물리 복제로 무중단이 안 되는 이유
논리적 복제 (logical decoding, pgoutput / Debezium)
"products 테이블 id=99 행의 price가 10000 → 9000으로 변경됨"
├─► 다른 PG 버전으로 복제 (무중단 업그레이드)
├─► Kafka로 흘려서 검색엔진 색인 갱신
└─► Redis 캐시 무효화 트리거
그래서 PostgreSQL은 물리 WAL만으로 충분해 보이는데도 논리 복제를 별도로 만들었다. 상태 기계를 여러 종류로 갈아끼우려면 로그가 엔진 중립적인 의미 단위여야 한다.
7. 확장성의 벽
단일 리더 처리량
TOB의 가장 근본적인 한계다.
전역 시퀀서가 하나 있는 구조
모든 프로듀서
▼
┌─────────────┐
│ 시퀀서 노드 │ ← 모든 메시지가 여기를 통과
└─────────────┘
┌────────┼────────┐
▼ ▼ ▼
브로커1 브로커2 브로커3 ← 100대로 늘려도
처리량 상한 = 시퀀서 노드 1대의 CPU/디스크/NIC 한계
전체 순서 브로드캐스트는 단일 리더를 통과하므로 처리량이 단일 노드의 처리량으로 제한된다. 노드를 추가해도 처리량이 늘지 않는다. 순서를 정하는 일은 본질적으로 직렬 작업이라 병렬화할 수 없다.
정족수 크기 - 노드를 늘리면 오히려 느려진다
2f+1 노드로 f대 장애 허용
3대 → 정족수 2 → 1대 장애 허용
4대 → 정족수 3 → 1대 장애 허용 ← 3대와 내결함성 동일한데 지연만 늘어남
5대 → 정족수 3 → 2대 장애 허용
홀수를 쓰는 이유는 짝수가 "불가능"해서가 아니다. 4대는 3대보다 더 안전하지 않으면서 더 느려서 이득이 없을 뿐이다. 리더 선출의 split vote는 Raft가 랜덤 선거 타임아웃으로 해결하므로 클러스터가 멈추지 않는다. 선거를 한 번 더 시도하면 된다.
느려지는 주된 원인은 정족수 크기보다 리더의 fan-out이다.
3노드: 리더 ──► 팔로워 2대에 전송, 1대 응답 대기
= 발신 대역폭 ×2, "가장 빠른 1대"를 기다림
5노드: 리더 ──► 팔로워 4대에 전송, 2대 응답 대기
= 발신 대역폭 ×4, "두 번째로 빠른 대상"을 기다림
→ 느린 노드/GC/네트워크 스파이크에 더 많이 노출
→ p99 tail latency 악화
병목은 리더 한 대의 네트워크와 디스크 fsync다. etcd는 이 때문에 5대를 권장 상한으로 둔다. 합의 클러스터에 노드를 추가하는 것은 처리량 투자가 아니라 내결함성 보험료다.
그래서 etcd/ZooKeeper는 메타데이터 저장소로 포지셔닝한다. 대용량 데이터 저장소가 아니다. 리더 선출, 락, 설정, 서비스 디스커버리처럼 "양은 적지만 순서와 선형성이 절대적으로 필요한 것"을 담당한다.
Kafka의 선택 - 데이터 평면과 제어 평면 분리
Kafka는 TOB가 아니다. 파티션 내부에서만 전체 순서를 보장한다. 그런데 흥미롭게도 같은 시스템 안에서 두 가지 선택을 동시에 한다.
┌─ 제어 평면(메타데이터) ────────────────────────────────┐
│ KRaft 컨트롤러 = 단일 Raft 그룹, __cluster_metadata │
│ 토픽 생성, 파티션 리더 변경, ISR 변경 ... │
│ → 전역 TOB. 브로커 자체가 메타데이터 로그의 SMR │
│ → 처리량은 낮아도 됨 (초당 수천 건) │
└───────────────────────────────────────────────────┘
┌─ 데이터 평면(메시지) ─────────────────────────────────┐
│ 파티션별 리더-팔로워 복제, ISR + High Watermark │
│ → 파티션 단위 TOB만. 전역 순서 없음 │
│ → 처리량이 목적 (초당 수백만 건) │
└──────────────────────────────────────────────────┘
"순서가 필요한 소량"과 "처리량이 필요한 대량"을 분리해 다른 보장을 준다. 이 트레이드오프에 대한 가장 실용적인 답이다.
그리고 "파티션은 TOB를 만족한다"는 조건부임을 알아야 한다.
acks=1 → 리더가 로컬 로그에 쓴 즉시 응답
리더가 복제 전에 죽으면 그 메시지는 영구 소실
→ "한 노드에 전달되면 모든 노드에 전달된다" 위반으로 실패! ✗
acks=all + min.insync.replicas=2 + replication.factor=3 ✓
unclean.leader.election.enable=true
→ ISR(InSyncReplica) 밖의 뒤처진 팔로워가 리더가 됨
= 커밋된 로그가 잘려나감 → 안전성 붕괴로 실패! ✗
- 즉 Acks=all 이고 replication factor가 3개일 때 min.insync.replica가 2개일 때 파티션 내 TOB를 보장한다.
- ACKS=ALL 이라고해서 모든 레플리카에 대한 동기 전파를 기다리는게 아니라 현재 Min ISR(In-Sync Replicas) 개수의 응답만 동기적으로 기다리는것이다.
프로듀서 쪽에도 같은 함정이 있다. 재시도가 순서를 뒤집는다.
프로듀서 ── [M1] ──► 전송 (실패, 재시도 대기)
── [M2] ──► 전송 (성공)
── [M1] ──► 재시도 성공
로그 기록 순서: M2, M1 ← 보낸 순서와 반대
Kafka는 프로듀서 시퀀스 번호로 막는다. enable.idempotence=true면 (PID, 파티션, 시퀀스번호)를 붙여 보내고 브로커가 기대하는 다음 시퀀스가 아니면 거부한다. 즉, "빈틈없는 정수 시퀀스로 구멍을 탐지한다"가 프로듀서-브로커 구간에서 쓰인다.
참고로 Kafka 3.0부터 enable.idempotence=true와 acks=all이 기본값이며 max.in.flight.requests.per.connection ≤ 5와 retries > 0이 전제 조건이다.
브로커로 최대 한번에 보낼 수 있는 요청수가 5개 이하인 이유는 브로커가 중복과 순서를 검증하기 위해서 각 파티션 별로 최근 요청 수 최대 5개까지만 기억(추적)하기 때문이다.
파티션에 대한 TOB는 어떻게 보장되는지 정리

파티션은 단일 리더 파티션으로 OFFSET은 자동 보장되며 브로커에 분산되어 Replica가 저장된다. 이때 파티션에 TOB를 보장하기 위해서는 조건이 선행되어야 한다.
여기서 파티션의 TOB를 보장한다는것은 "한 파티션의 레플리카 집합(리더 1 + 팔로워 N)"이 하나의 복제 로그 시스템으로서 TOB를 보장함을 말한다.
-> TOB가 성립하는 그룹의 경계 == 한 파티션의 레플리카 집합(리더 1 + 팔로워 N)
| TOB 요건 | 무엇이 보장하나 |
| 전체 순서 (모두가 같은 순서로 봄) | 설정이 아니라 구조 - 단일 리더가 offset 발급, 팔로워/컨슈머는 그 순서대로만 읽음 |
| 신뢰성 있는 전달 (커밋 = 영속) | acks=all + min.insync.replicas=2 + replication.factor=3 |
| 커밋된 순서가 나중에 안 뒤집힘 | unclean.leader.election.enable=false - ISR 밖 레플리카가 리더가 되면 커밋된 로그 꼬리가 잘려나가므로 |
| 프로듀서 재시도로 인한 순서 역전 방지 | enable.idempotence=true max.in.flight.requests.per.connection ≤ 5 |
Multi-Raft - 확장성을 되찾는 방법
Google Spanner는 Raft를 쓰면서도 페타바이트급으로 확장한다. "클러스터에 Raft 그룹이 하나"라는 가정을 깨면 된다.
키 범위 [a-f) → Raft 그룹 1: 리더=Node1, 팔로워=Node2,Node3
키 범위 [f-m) → Raft 그룹 2: 리더=Node2, 팔로워=Node3,Node1
키 범위 [m-z) → Raft 그룹 3: 리더=Node3, 팔로워=Node1,Node2
▲
리더 역할이 노드들에 분산 → 노드 추가 시 쓰기 처리량 선형 증가 ✓
잃는 것: 그룹 간 전체 순서 = 크로스 파티션 원자성/직렬성
각 Raft 그룹 하나는 SMR을 구현한것이고, 이를 위한 매커니즘이 TOB인거다.
근데 이제 멀티 Raft에서 각 Raft 간 크로스 파티션 트랜잭션은 어떻게 하는가이다.
-> 전역 TOB를 포기한 대신 필요한 트랜잭션에만 2PC 비용을 따로 지불한다.
데이터 배치(place, 포지셔닝)가 성능 설계의 핵심이 된다.
- Spanner의 테이블 인터리빙(Table Interleaving, 부모-자식 행을 같은 split에 물리적으로 모음)이 "2PC를 안 타게 만드는" 기법이다. - 주문과 재고를 같은 range에 두면 단일 Raft 그룹 트랜잭션이고 흩어지면 2PC를 탄다.
제어부와 데이터부 정리

단일 Raft 그룹 내 트랜잭션 → 합의 1라운드. 빠름 ✓
여러 Raft 그룹에 걸친 트랜잭션 → 2PC를 Raft 그룹들 위에 올림
= 코디네이터 + 참여자마다 합의 → 지연 수 배
Spanner: TrueTime(GPS+원자시계)으로 외부 일관성 확보
| 메타데이터 (제어부) | 메시지 (데이터부) | |
| 양 | 초당 수 건~수십 건 | 초당 수십만~수백만 건 |
| 순서 요구 | 전원이 완전히 같은 순서를 봐야 함 | 같은 키(같은 파티션) 안에서만 |
| 채택 방식 | KRaft 단일 Raft 그룹 = 전역 TOB | 파티션별 독립 로그 = 부분 순서 |
| 단일 시퀀서 병목 | 양이 적어 문제 없음 | 치명적이라 포기 |
즉, Kafka는 파티션 하나하나가 독립적인 TOB 시스템(단일 리더 복제 로그)이고, 이런 소형 TOB 시스템 수천 개를 병렬로 돌려 처리량을 얻되, 그 시스템들 간의 조정(리더 선출, 멤버십)은 KRaft라는 단 하나의 전역 TOB 시스템에 위임하는 시스템인것이다.
전체 지형을 한 줄로 보면 이렇다.
순서 보장 범위 ◄───────────────────────────────────► 확장성
전역 TOB 파티션별 TOB 인과적 순서만 순서 없음
(etcd, ZK) (Kafka, Multi-Raft) (버전벡터, CRDT) (최종 일관성)
확장 불가 선형 확장 충돌 병합 필요 제약 불가
선형성 ✓ 파티션 내 선형성 ✓ 선형성 ✗ 선형성 ✗
8. 어디까지 순서가 필요한가
전역 순서가 정말 필요한 경우는 드물다. 대개 필요한 것은 엔티티 단위 순서다.
"상품 99의 가격 이벤트끼리 순서" — 필요함 ✓ → 파티션 키 = 상품 ID
"상품 99와 상품 12345의 상호 순서" — 필요 없음 ✗
이벤트가 소량인데 거기에 전역 순서가 정말 필요하다면 선택지는 넷이다.
| 방법 | 방식 | 대가 |
|---|---|---|
| 파티션 1개 토픽 | 순서 범위 = 토픽 전체 | 처리량이 단일 브로커 한계. 소량 제어/스키마 이벤트에는 실제로 쓰임 |
| 외부 시퀀서 | etcd/ZooKeeper로 번호 발급 | 발급기 병목 + 왕복 지연 |
| Lamport 타임스탬프 사후 정렬 | 논리적 시각으로 정렬 | TOB이 안 됨. 2절의 문제가 재발 |
| 순서 요구 자체를 없앰 | CRDT, 멱등 연산, 버전 조건부 쓰기 | 도메인 설계 난이도 상승 |
Lamport 타임스탬프는 전체 순서를 만들지만 TOB는 아니다. 두 이벤트에 순서를 매길 수는 있어도 특정 시점에 "이 번호 이전 메시지를 전부 봤다"를 확정할 수 없어서 지금 적용해도 되는지 판단할 수 없다. (LSN의 필요성 = 합의 필요성 = TOB 필요성)
순서를 요구하는 진짜 원인은 조건부 거부다
"재고는 절대 음수가 되면 안 된다"는 제약을 생각해보자. 흔한 설명은 "이벤트가 앞선 이벤트에 의존하기 때문"이라고 하는데, 이건 정확하지 않다. 순수한 증감 연산은 순서와 무관하다.
잔고 1000, 이벤트: +1000, -2000
순서 A: 1000 → 2000 → 0
순서 B: 1000 → -1000 → 0 ← 최종값은 같다
그래서 순수 카운터는 CRDT로 만들 수 있고 순서 없이도 수렴한다. Riak 카운터, Cassandra counter 컬럼이 이렇게 동작한다.
문제(Problem)는 제약을 검증해서 조건부로 거부하는 순간 발생한다.
잔고 1000, 제약: 잔고 ≥ 0
순서 A: +1000 → 2000
-2000 → 검증 통과 → 0 최종 0
순서 B: -2000 → 검증 실패 → 거부 ✗
+1000 → 2000 최종 2000
▲
"어떤 연산을 거부했는가"가 순서에 따라 달라진다
→ 발산은 산술이 아니라 조건부 거부에서 온다
즉 순서를 요구하는 진짜 원인은 read-modify-write 구조다. 이게 곧 선형적 CAS다.
- CAS는 RMW라는 추상적인 목표를 달성하기 위해 사용하는 가장 핵심적인 하드웨어 기반의 수단
- 분산 시스템은 단일 하드웨어 장치에 의존할 수 없으므로 별도 선형적 CAS 도구 필요
제약 검증(유일성, 잔고 ≥ 0, 좌석 중복 예약 방지) = 선형적 CAS = TOB = 합의. 이 네 개는 같은 문제다. 이런 제약이 있는 도메인에서는 합의 비용을 피할 수 없다.
합의 비용을 부분적으로 회피하는 기법 - Escrow (에스크로)
"단일 파티션으로 처리"하면 그 상품의 처리량이 단일 리더에 묶인다. 타임세일이나 선착순 이벤트에서는 그게 바로 병목이다.
[순진한 방식] 재고 100개 = 단일 카운터 = 단일 합의 지점
10만 요청 ──► 하나의 리더 ──► 순차 처리. 병목 ✗
[Escrow / 재고 예약 분배]
재고 100개를 10개 샤드에 10개씩 미리 배분
shard0: 10 shard1: 10 ... shard9: 10
요청은 해시로 분산 → 각 샤드가 로컬에서 독립 결정 → 합의 없이 병렬 ✓
샤드 재고 소진 시에만 다른 샤드에서 빌려오기(재조정) → 이때만 합의
대가: "전체 재고가 3개 남았는데 내 샤드는 0" → 거짓 품절(false negative)
정확한 잔량 조회에는 전체 샤드 합산 필요
같은 원리로 Redis DECR이 널리 쓰인다. 단일 스레드 이벤트 루프가 사실상 시퀀서 역할을 하므로 원자적 감소가 공짜로 된다.
다만 Redis Cluster는 노드 간 TOB가 없고 복제도 비동기라 페일오버 시 커밋된 감소가 유실될 수 있다.
정확성이 돈과 직결되면 최종 정산은 RDB로 받쳐야 한다.
9. 실무 적용 (1) - 캐시를 또 하나의 복제본으로 보기
RDB의 가격 데이터와 Redis 캐시를 일관되게 유지하는 문제를 SMR 관점의 언어로 다시 정의해보자.
애플리케이션이 두 번의 각자 쓰기로 처리하면 순서 역전과 유실이 모두 가능하고 이종 저장소이므로 2PC도 현실적이지 않다.
Redis를 "또 하나의 상태 기계"로 보고 하나의 로그를 진실의 원천으로 삼으면 문제가 사라진다.
## 방안 1: CDC
[A] CDC — DB의 로그를 직접 원천으로
RDB commit ──► binlog / WAL ──► Debezium ──► Kafka ──► Redis 반영
▲
DB가 이미 원자적으로 쓴 로그.
애플리케이션이 발행을 잊거나 죽을 여지가 없음
## 방안 2: Outbox 패턴
[B] Transactional Outbox — 이벤트를 같은 트랜잭션에
BEGIN
UPDATE products SET price = 9000 WHERE id = 99;
INSERT INTO outbox (event) VALUES ('PRICE_CHANGED id=99 price=9000');
COMMIT ← 원자적으로 함께 커밋
└─► 폴러/CDC가 outbox를 읽어 Kafka로 ──► Redis 반영
주의할 점은 "커밋 직후 애플리케이션이 이벤트를 발행"하는 방식으로는 dual write 문제가 그대로 남는다는 것이다.
커밋은 됐는데 발행 전에 프로세스가 죽으면 이벤트가 사라진다.
그리고 파티션 키가 순서를 가른다. 같은 상품에 대해서는 같은 파티셔너로 가도록 해야 컨슈머 처리에서 순서 역전이 발생하지 않는다.
파티션 키를 랜덤/라운드로빈으로 두면:
상품 99의 E1(10000) → partition 0
상품 99의 E2(9000) → partition 2
→ 서로 다른 컨슈머가 병렬 처리 → 순서 역전 부활 ✗
파티션 키 = 상품 ID:
상품 99의 모든 이벤트 → 항상 같은 파티션 → 순서 보장 ✓
마지막으로 재처리(replay)가 남는다. 장애 복구나 캐시 재구축을 위해 오래된 오프셋부터 다시 소비하면 과거 값이 최신 값을 덮어쓴다.
순서 문제가 아니라 멱등성 문제이고 해법은 조건부 쓰기다.
-- Redis Lua: 저장된 버전보다 큰 경우만 반영 (CAS)
local cur = redis.call('HGET', KEYS[1], 'ver')
if (not cur) or (tonumber(ARGV[2]) > tonumber(cur)) then
redis.call('HSET', KEYS[1], 'price', ARGV[1], 'ver', ARGV[2])
return 1
end
return 0
버전으로는 binlog LSN, Kafka 오프셋, DB의 버전 컬럼을 쓴다. 로그의 오프셋을 상태(스냅샷)와 함께 저장한다.
10. 실무 적용 (2) - 배치의 결정성
등급 산정 배치처럼 "재실행해야 하는" 시스템은 사실상 SMR이다. 같은 입력으로 다시 돌렸을 때 같은 결과가 나오는가가 곧 결정성 전제다. Spring Batch + Athena 환경을 기준으로 깨지는 지점을 정리한다.
1. 데이터 소스가 불변이라는 착각
8/1 실행: SELECT ... WHERE dt = '2026-07-31' → 1,000,000행 → A등급 12,345명
│ 이후: 지연 도착 주문 파일 추가, 정산 보정 ETL 재처리, 환불 백필
▼
8/10 재실행: 같은 쿼리 → 1,000,240행 → A등급 12,352명 ← 결과가 다르다
S3는 파티션에 파일을 나중에 추가할 수 있고 Athena는 조회 시점의 파일 목록을 읽는다. WHERE dt = ...는 논리적 범위만 고정하고 물리적 입력은 고정하지 않는다.
-- Iceberg면 스냅샷 ID로 입력을 못 박는다 (Athena engine v3)
SELECT ... FROM orders FOR VERSION AS OF 949530903748831860
WHERE dt = '2026-07-31';
-- 또는 시점 고정
SELECT ... FROM orders FOR TIMESTAMP AS OF TIMESTAMP '2026-08-01 00:00:00 UTC'
Iceberg를 쓰지 않는다면, 과거 데이터를 고정하기 위해 두 가지 방법 중 하나를 써야한다.
- 쿼리 실행 당시의 파일 목록(메니페스트)을 백업해 두거나, 그 시점의 데이터를 조회해 별도의 스냅샷 테이블로 복사해 두는 방식(CTAS, Create Table As Select)이다.
2. 기준일이 실행 시각에서 유래
// 결정성 파괴 — 재실행하면 다른 기간을 산정한다
LocalDate baseDate = LocalDate.now().minusDays(1);
// 결정적 — JobParameters로 주입. 같은 파라미터 = 같은 결과
@Bean
@StepScope
public ItemReader<Order> reader(@Value("#{jobParameters['baseDate']}") String baseDate) { ... }
Spring Batch의 JobParameters는 불변이고 JobInstance의 식별자다. 기준일을 파라미터로 받으면 그게 곧 결정성 설계다. 타임존도 함께 봐야 한다. Athena는 기본 UTC, JVM 기본 타임존은 서버 설정에 의존하므로 ZoneId.of("Asia/Seoul")을 명시하지 않으면 날짜 경계가 노드마다 갈린다.
3. 컷라인 동점자 - 가장 찾기 어려운 지점
등급 산정은 보통 상대 기준이다.
-- 위험: 동점자가 컷라인에 걸리면 매 실행마다 다른 사람이 뽑힌다
SELECT user_id, ROW_NUMBER() OVER (ORDER BY amount DESC) AS rank FROM ...
-- amount=50000 인 사용자 100명, 컷라인이 rank 12,345
-- → 분산 실행이므로 동점 내 순서가 실행마다 다름
-- 안전: 유니크 키로 tie-break를 못 박는다
ROW_NUMBER() OVER (ORDER BY amount DESC, user_id ASC)
분산 정렬은 안정 정렬이 아니다. 랭킹 로직에 동점 처리 규칙이 명시되지 않았다면 그건 정의상 비결정적이다.
4. 부동소수점 집계
SUM(price * quantity)에서 price가 DOUBLE이면 분산 집계의 부분합 결합 순서가 실행마다 달라진다. double 덧셈은 결합법칙이 성립하지 않으므로 결과가 미세하게 흔들리고 그 값이 등급 임계값 경계에 있으면 등급이 뒤집힌다. 금액은 DECIMAL을 쓴다.
5. 변경 가능한 참조 데이터
8/1 산정 당시 임계값: VIP = 100만원
8/5 운영자가 120만원으로 변경
8/10 재산정 → 완전히 다른 결과. "버그인가 정책 변경인가" 구분 불가
해법은 산정에 사용한 기준을 결과와 함께 버전으로 남기는 것이다.
- grade_calculation_run(run_id, base_date, input_snapshot_id, threshold_version, code_version) 같은 실행 메타 레코드가 있으면 결과 차이의 원인이 입력인지 기준인지 코드인지 즉시 분리된다.
6. 병렬 파티션의 공유 상태
안전: 각 파티션이 독립적으로 자기 구간만 계산 (순수 함수)
위험: ExecutionContext나 static 카운터에 누적, "선착순 N명" 로직
→ 파티션 완료 순서에 결과가 의존
랭킹이나 컷라인은 파티션 안에서 정하지 말고 SQL 한 번으로 전역 결정해야 한다.
7. 출력 멱등성
-- 재실행하면 이력이 중복 적재됨
INSERT INTO member_grade_history (user_id, grade, base_ym) VALUES ...
-- 자연키에 유니크 제약 + UPSERT
UNIQUE (user_id, base_ym)
INSERT ... ON CONFLICT (user_id, base_ym) DO UPDATE SET grade = EXCLUDED.grade
재생 가능한 시스템은 출력도 멱등해야 한다.
정리
| SMR 전제 | 배치에서의 대응 |
|---|---|
| 동일 초기 상태 | 입력 스냅샷 고정 (Iceberg 스냅샷 ID / CTAS 스냅샷) |
| 동일 순서 입력 | 기준일, 기준 버전을 JobParameters로 못 박기 |
| 결정성 | 안정 정렬 tie-break, DECIMAL, 타임존 명시, 파티션 간 공유 상태 제거 |
| (추가) 재생 안전 | 출력 멱등성 + 실행 메타 기록(입력 버전, 기준 버전, 코드 버전) |
여기까지 확보하면 실무에서 "장애가 나면 그냥 다시 돌리면 된다"가 성립한다. 결정성이 없으면 재실행이 또 다른 사고가 되므로 수동 보정에 매달려야 한다.
11. 쓰기 순서를 잡아도 선형성 읽기는 공짜가 아니다
TOB로 쓰기 순서를 다 잡았다고 읽기가 자동으로 선형적이 되지는 않는다.
팔로워에서 읽으면 복제 지연만큼 과거를 본다.
해법은 둘이다.
- 읽기를 로그에 통과시키기. Raft 논문의 원래 방식이다.
읽기도 로그 엔트리로 만들어 TOB에 넣고 자기 엔트리가 커밋되어 도착했을 때 읽으면 그 앞의 모든 쓰기가 확정됐음이 보장된다.
단순하지만 읽기마다 로그 append + 리더 fsync + 정족수 왕복을 지불한다. - ReadIndex 방식.
읽기 요청 시점의 최신 로그 위치를 기록한 뒤, 과반수 노드 확인으로 리더 권한을 검증하고 해당 위치까지 데이터가 반영되면 반환하는 디스크 쓰기 없는 선형성 읽기 메커니즘으로 etcd가 실제로 쓰는 방식이다.
팔로워 ── "지금 커밋 인덱스 뭐야?" ──► 리더
│
리더는 먼저 정족수에 하트비트를 보내
"내가 아직 리더인가"를 확인해야 한다
▲
이걸 생략하면: 이미 밀려난 stale leader가
옛 인덱스를 알려줘서 선형성이 깨진다
│
│
팔로워 ◄──── commitIndex = 500 ─────┘
팔로워: 현재 커밋 인덱스를 확인했다. 즉, 쓰기가 어디까지 Replica에 쓰기가 완료되었는지 알 수 있다.
따라서, appliedIndex ≥ 500 이 될 때까지 대기 후 → 읽기 수행
절약되는 것: 디스크 쓰기(로그 append + fsync)
남는 것: 네트워크 왕복 (리더한테 커밋 인덱스 묻기, 리더의 내가 아직 리더인가 묻기)
- 커밋 인덱스는 과반수(Quorum) 확인을 거쳐 현재 안전하게 커밋된 최신 인덱스이다.
- 기다려야 하는 것은
appliedIndex다. 로그에 기록만 되고 상태 기계에 적용되기 전이면 읽어도 옛 값이 나온다.
더 공격적인 최적화 방안.
- Lease read: 리더가 "선거 타임아웃 동안은 내가 리더다"라고 시간 리스로 가정하고 왕복 없이 읽는다. 빠르지만 시계 정확성에 안전성을 의존하므로 "신뢰할 수 없는 시계" 문제가 발생할 수 있다.
선형성과 read-your-writes 용어 정리
- 선형성과 read-your-writes는 다르다.
| 보장 | 의미 | 달성 방법 |
|---|---|---|
| 선형성 | 모든 클라이언트가 항상 최신 값을 본다 (단일 사본 착각) |
리더 읽기, ReadIndex, 읽기를 로그에 통과 |
| read-your-writes | 내가 쓴 것은 내가 반드시 본다 (남의 쓰기는 몰라도 됨) |
클라이언트가 자기 쓰기의 LSN/오프셋을 들고 다니며 그 이상 적용된 복제본으로 라우팅 |
| 단조 읽기 | 시간이 역행하지 않는다 | 같은 복제본에 고정(sticky routing) |
선형성이 read-your-writes보다 훨씬 비싸다. 실무에서 "복제 지연 때문에 방금 수정한 게 안 보인다"는 민원은 대부분 세션 보장 문제이고 선형성까지 갈 필요가 없다.
캐시를 읽는 경우도 마찬가지다. 전역 최신값이 필요한지, 아니면 "가격을 바꾼 담당자 본인에게만 즉시 반영"이면 되는지를 구분하면 비용이 크게 달라진다.
12. 흐름 요약
[문제] 유실이 없어도 적용 순서가 다르면 상태가 발산한다
│
▼
[타임스탬프 정렬로는 안 됨] "앞선 메시지를 다 봤는가"를 확정할 수 없다
│
▼
[TOB] 신뢰성 있는 전달 + 모든 노드에 동일 순서 전달
= 빈틈없는 시퀀스 = 로그.
구멍 탐지와 재전송 대기가 가능해진다
│
▼
[TOB ≡ 합의] 시퀀스 발급기를 안전하게 유지하는 일 자체가 합의 문제
→ 에포크 + 정족수 (Raft, Paxos, Zab)
│
▼
[SMR] 동일 초기 상태 + 동일 순서 입력 + 결정성 → 동일 상태
TOB는 순서 계층, SMR은 실행 계층
│
▼
[효용] 선형성과 제약 검증(유일성, 잔고 ≥ 0)이 가능해진다
│
▼
[대가] 단일 리더 처리량 한계.
노드 추가는 내결함성 보험료
→ Kafka는 파티션 단위로, Google Spanner는 Range별 Raft로 범위를 좁혀 확장
→ 실무에서 필요한 것은 대개 전역 순서가 아니라 엔티티 단위 순서
13. 면접 Q&A
Q. 전체 순서 브로드캐스트와 상태 기계 복제가 뭐고 왜 필요한가요?
복제 시스템에서 노드들이 같은 메시지를 다 받았는데도 적용 순서가 다르면 상태가 발산합니다.
메시지 유실이 없어도 그렇습니다.
TOB는 여기에 두 가지를 보장하는 프로토콜입니다. 신뢰성 있는 전달, 그리고 모든 노드에 동일한 순서로 전달. 결과물은 사실상 순서가 확정된 로그이고 오프셋이 빈틈없는 정수라서 구멍을 탐지하고 재전송을 기다릴 수 있습니다. 타임스탬프 정렬로는 "이보다 앞선 메시지를 전부 봤다"를 확정할 수 없어서 안 됩니다.
SMR은 그 로그를 소비하는 실행 모델입니다. 동일한 초기 상태에서 출발해 동일한 순서의 명령을 결정적으로 적용하면 모든 복제본이 같은 상태에 도달합니다. 전제는 1. 결정성 2. 동일 초기 상태 3. 동일 순서이며 그중 "동일 순서"를 제공하는 게 TOB입니다.
즉 TOB는 순서를 정하는 계층, SMR은 그 순서로 상태를 만드는 계층입니다.
이게 있어야 선형성과 제약 검증이 가능합니다. 유일성 보장, 잔고 음수 금지, 좌석 중복 예약 방지 같은 read-modify-write는 전체 순서 없이 성립하지 않습니다. 다만 TOB는 단일 리더를 통과하므로 처리량이 단일 노드에 묶입니다. 노드를 늘리면 내결함성만 오르고 쓰기 처리량은 오히려 떨어집니다.
그래서 Kafka는 파티션 단위로, Spanner는 Range별 Raft 그룹으로 순서 보장 범위를 좁혀 확장성을 얻었습니다. 실무에서는 전역 순서가 필요한 경우가 드물고 대개 엔티티 단위 순서로 충분합니다.
Q. Kafka는 전체 순서 브로드캐스트입니까?
아닙니다. 파티션 내부에서만 전체 순서를 보장하고 파티션 간 순서는 없습니다. 파티션에서도 설정 조건부 보장입니다.
acks=1이면 리더가 복제 전에 죽을 때 메시지가 영구 소실되어 신뢰성 있는 전달이 깨집니다. unclean.leader.election.enable=true면 커밋된 로그가 잘려나갑니다. 프로듀서 쪽에서도 재시도가 순서를 뒤집을 수 있어서 enable.idempotence가 필요합니다. 따라서, ACKS=ALL, min.insync.replica = 2, replication.factor = 3 에서 파티션 내 TOB가 보장됩니다.
전역 순서를 두지 않은 이유는 처리량입니다. 전역 순서는 정의상 단일 시퀀스 발급기를 요구하므로 브로커를 늘려도 처리량이 오르지 않습니다.
다만 Kafka는 같은 시스템 안에서 두 방식을 둘다 사용합니다. 메타데이터는 KRaft 단일 Raft 그룹으로 전역 TOB를 쓰고(양이 적으므로) 메시지는 한 파티션 레플리카 단위에서만 순서를 씁니다.
Q. etcd 클러스터를 3대에서 5대로 늘리면 쓰기 처리량이 어떻게 됩니까?
늘지 않고 오히려 떨어집니다. 정족수가 2에서 3으로 커지고 더 중요하게는 리더의 fan-out이 2배에서 4배가 됩니다.
리더 한 대의 네트워크와 디스크 fsync가 병목이고 응답을 기다리는 대상이 늘어나 느린 노드나 GC에 더 많이 노출되므로 p99가 나빠집니다. 얻는 것은 내결함성입니다. 1대 장애 허용에서 2대 장애 허용이 됩니다. 합의 클러스터에 노드를 추가하는 건 처리량 투자가 아닙니다. 내결함성 보험료입니다.
Q. 그럼 Google Spanner는 어떻게 Raft로 확장합니까?
클러스터에 Raft 그룹이 하나라는 가정을 버립니다.
데이터를 키 범위(Range)로 쪼개 조각마다 독립 Raft 그룹을 두면 리더 역할이 노드에 분산되어 쓰기 처리량이 선형으로 늘어납니다. 대가로 그룹 간 전체 순서를 잃으므로 크로스 파티션 트랜잭션에는 2PC를 Raft 위에 올려야 하고 지연이 수 배가 됩니다. 그래서 실제 성능 설계는 함께 트랜잭션을 걸 데이터를 같은 Range에 모아 2PC를 피하는 쪽으로 갑니다. Spanner의 테이블 인터리빙이 같은 목적입니다.
Q. 결정성이 깨지는 코드를 실제로 본 적 있습니까?
가장 흔한 건 now()와 UUID.randomUUID()입니다.
MySQL STATEMENT 복제에서 이 함수들이 소스와 레플리카에서 다른 값을 만들어 발산하는 게 고전적 사례이고 MySQL도 그래서 이런 함수를 unsafe로 분류합니다. MySQL 8.0에서 binlog_format이 deprecated 되었으며 앞으로 ROW만 남습니다.
찾기 어려운 것들이 더 위험합니다. 분산 정렬에서 동점자 tie-break를 지정하지 않으면 랭킹이 실행마다 바뀝니다. double 분산 집계는 결합 순서에 따라 값이 흔들려 임계값 경계에서 결과가 뒤집힙니다. 그리고 코드 버전이 있습니다. SMR 시스템에서 로직 배포는 스키마 마이그레이션과 같은 급의 위험이고 새 코드로 옛 로그를 재생하면 원래 상태와 다른 상태가 나옵니다. Temporal이 워크플로 버저닝 API를 제공하는 이유입니다.
해법은 일관됩니다. 비결정적 결정은 상태 기계 안에서 하지 않고 리더(외부)로부터 주입받습니다. 리더가 값을 확정해서 로그에 박아 넣습니다. 그러면 언제든 재실행해도 같은 상태가 나옵니다.
Q. 재고가 음수가 되면 안 된다는 제약은 왜 전체 순서를 요구합니까?
정확히 말하면 증감 연산 자체는 순서와 무관합니다. 덧셈은 교환법칙이 성립하므로 최종값이 같고 그래서 순수 카운터는 CRDT로 만들 수 있습니다.
순서를 요구하는 건 조건부 거부입니다. 잔고가 음수가 되는 요청을 거부해야 하면, 어떤 요청을 거부했는지가 순서에 따라 달라지고 최종 상태가 갈립니다. 이건 read-modify-write이고 곧 선형적 CAS입니다. 선형적 CAS는 TOB와 등가이므로 합의 비용을 피할 수 없습니다.
실무에서는 escrow 기법으로 부분 회피합니다. 재고 100개를 10개 샤드에 배분하면 각 샤드가 로컬에서 독립 결정할 수 있어 합의 없이 병렬 처리되고 샤드가 소진될 때만 재조정에 합의를 씁니다. 대가는 거짓 품절(false-negative)입니다.
Q. TOB가 있으면 읽기도 선형적입니까?
아닙니다. 팔로워에서 읽으면 복제 지연만큼 과거를 봅니다.
해법은 읽기를 로그에 통과시키는 방식(Raft 원래 방식, 로그 append + fsync 비용)과 ReadIndex 방식(리더에게 커밋 인덱스를 받아 appliedIndex가 그 값에 도달할 때까지 대기, 디스크 쓰기는 없지만 네트워크 왕복은 남음)이 있습니다. 이때 리더가 정족수 하트비트로 자기 리더십을 먼저 확인하지 않으면 stale leader가 옛 인덱스를 알려줘 선형성이 깨집니다.
그리고 선형성이 정말 필요한지 따져야 합니다. 대부분의 "방금 수정한 게 안 보인다"는 민원은 read-your-writes 문제입니다. 클라이언트가 자기 쓰기의 LSN을 들고 다니며 라우팅하는 것만으로 훨씬 싸게 해결됩니다.
참고 자료
- Martin Kleppmann, Designing Data-Intensive Applications, 9장 — 일관성과 합의
- Fred B. Schneider, "Implementing Fault-Tolerant Services Using the State Machine Approach: A Tutorial", ACM Computing Surveys, 1990
- Diego Ongaro, John Ousterhout, "In Search of an Understandable Consensus Algorithm (Raft)", USENIX ATC, 2014
- Leslie Lamport, "Paxos Made Simple", 2001
- MySQL 8.0 Release Notes — 8.0.34
- MySQL 8.0 Reference Manual — Replication Formats
- Apache Kafka — Producer Configs
- Apache Kafka 3.0 Upgrade Notes
- Amazon Athena — Time travel and version travel queries
'기술 학습' 카테고리의 다른 글
| 정규화와 비정규화 비교 정리 (0) | 2026.06.30 |
|---|---|
| 선언형 쿼리와 명령형 쿼리 정리본 (0) | 2026.06.30 |
| Hibernate Connection Release Mode 커넥션 관리 전략 _ JPA @Transactional (0) | 2026.04.25 |
| Redis Cluster _ Lettuce Client의 Topology Refresh (1) | 2026.04.25 |
| 분산 시스템 CAP 정리 (0) | 2026.03.31 |