✍️ 필사 모드: Raft Consensus 완전 가이드 2025: Leader Election, Log Replication, Safety, etcd/Consul 실전 분석
한국어들어가며: 왜 Raft인가?
분산 합의의 근본 문제
다섯 대의 서버가 있다. 각각 독립적으로 "지금 잔고는 얼마인가?"라는 질문에 답할 수 있어야 한다. 그런데 이런 상황이 벌어진다:
- 네트워크가 두 쪽으로 갈라진다 (Network Partition)
- 몇 대의 서버가 죽는다
- 어떤 서버는 느리게 응답한다
- 일부 요청은 일부 서버에만 도달한다
그런데도 모든 서버가 같은 상태(잔고) 를 유지해야 한다. 이것이 분산 합의(Distributed Consensus) 문제다.
Paxos, 그리고 그 고통
1989년 Leslie Lamport가 Paxos를 발표했다. 수학적으로는 완벽했지만, 이해하기가 너무 어려웠다. Lamport 본인도 "Paxos Made Simple"이라는 논문을 따로 써야 했고, 그마저도 구현하기가 극악하게 어려웠다.
Google의 Chubby 논문은 이렇게 말했다:
"There are significant gaps between the description of the Paxos algorithm and the needs of a real-world system... the final system will be based on an un-proven protocol."
Paxos를 구현하려는 사람들은 모두 각자의 Paxos 변종을 만들었고, 어느 것도 완전히 검증되지 않았다.
Raft의 등장
2014년 Stanford의 Diego Ongaro와 John Ousterhout이 발표한 Raft는 한 가지 목표로 설계되었다:
"이해 가능성(Understandability)을 최우선으로 한다."
Raft는 Paxos와 동일한 안전성 보장을 제공하지만, 문제를 세 개의 독립된 하위 문제로 분해한다:
- Leader Election: 리더 선출
- Log Replication: 로그 복제
- Safety: 안전 속성
이 분해 덕분에 Raft는 폭발적으로 확산되었다:
- etcd (Kubernetes의 백엔드)
- Consul (HashiCorp)
- CockroachDB, TiDB, YugabyteDB
- MongoDB (내부 합의 프로토콜)
- RedPanda, Apache Kafka KRaft 모드
Kubernetes를 쓴다면, 당신은 이미 매일 Raft에 의존하고 있다.
1. Raft의 기본 모델
노드 상태 (State)
Raft 클러스터의 각 노드는 정확히 하나의 상태에 있다:
┌──────────────┐
│ Follower │──── 타임아웃 ────┐
└──────────────┘ │
▲ ▼
│ ┌──────────────┐
│ 더 높은 term │ Candidate │
│ 또는 리더 발견 └──────────────┘
│ │
│ 다수표 획득
│ ▼
│ ┌──────────────┐
└──────────────────│ Leader │
└──────────────┘
- Follower: 수동적. 리더의 명령을 받아 로그를 복제.
- Candidate: 선거 중. 다른 노드들에게 투표 요청.
- Leader: 클러스터의 유일한 쓰기 진입점. 모든 변경 사항을 로그에 추가하고 Follower에게 복제.
정상 동작 시 항상 1 리더 + N-1 팔로워 구조다.
Term: 논리적 시간
Raft는 Term(임기) 이라는 단조 증가하는 정수로 시간을 관리한다. 각 Term은:
- Election(선거) 페이즈로 시작
- Normal operation(정상 운영) 페이즈로 이어짐
- 리더 실패 시 → 새 Term으로 넘어감
Term 1 Term 2 Term 3 Term 4
[L][N][N] [L][N] (무리더) [L][N][N]...
선거 후 선거 후 선거 실패 선거 후 정상
정상 정상
Term은 stale한 리더(구 리더) 를 걸러내는 역할도 한다: 더 높은 Term을 본 노드는 즉시 자신의 상태를 갱신한다.
필수 RPC 두 가지
Raft는 단 두 개의 RPC만 사용한다:
- RequestVote RPC: Candidate가 투표를 요청할 때.
- AppendEntries RPC: Leader가 로그 복제 및 heartbeat을 보낼 때.
이 단순함이 Raft의 이해 가능성을 크게 높인다.
2. Leader Election: 리더 선출
선거 타임아웃
모든 Follower는 election timeout을 가진다 (보통 150~300ms 랜덤). 이 시간 안에 Leader로부터 AppendEntries를 받지 못하면:
- 스스로를 Candidate로 전환.
- currentTerm을 1 증가.
- 자기 자신에게 투표.
- 다른 모든 노드에게 RequestVote RPC 전송.
- 새로운 election timer 시작.
투표 요청의 조건
RequestVote를 받은 노드는 다음 조건 모두 만족 시 찬성표를 던진다:
- 요청자의
term >= currentTerm. - 이 Term에 아직 아무에게도 투표하지 않음 (또는 같은 후보).
- 요청자의 로그가 자신 것만큼 최신일 것 (up-to-date 검사).
up-to-date 검사는 다음과 같이 정의된다:
- 로그의 마지막 엔트리의 term이 더 큰 쪽이 최신.
- 같은 term이면 index가 더 큰 쪽이 최신.
이 조건이 안전 속성의 핵심이다. 뒤에서 다시 설명한다.
선거 결과 3가지
- 다수(majority) 득표 → Leader가 된다. 즉시 heartbeat 전송 시작.
- 다른 리더 발견(더 높거나 같은 term의 AppendEntries 수신) → Follower로 전환.
- 타임아웃 (split vote) → Term 증가 후 새 선거 시작.
Split Vote 방지
만약 여러 Candidate가 동시에 선거를 시작하면 표가 분산되어 다수를 얻지 못할 수 있다. Raft는 랜덤 타임아웃으로 이를 해결한다:
- 각 노드의 election timeout을 [150ms, 300ms] 랜덤 범위에서 선택.
- 대부분 경우 한 노드가 먼저 타임아웃 → 혼자 Candidate가 됨.
수학적으로 split vote는 드물게만 발생하며, 발생해도 다음 Term에서 보통 해결된다.
파이썬 의사 코드
class RaftNode:
def start_election(self):
self.state = "candidate"
self.current_term += 1
self.voted_for = self.id
self.votes_received = {self.id}
self.reset_election_timer()
for peer in self.peers:
self.send_request_vote(peer)
def handle_vote_response(self, response):
if response.term > self.current_term:
self.become_follower(response.term)
return
if response.vote_granted:
self.votes_received.add(response.voter_id)
if len(self.votes_received) > len(self.peers) // 2:
self.become_leader()
def become_leader(self):
self.state = "leader"
self.reset_heartbeat_timer()
self.send_heartbeats() # 즉시 권위 선언
3. Log Replication: 로그 복제
로그 엔트리 구조
Raft의 로그는 번호가 매겨진 엔트리들의 순차적인 리스트다:
index: 1 2 3 4 5 6
term: 1 1 1 2 3 3
cmd: [x=3] [y=1] [x=2] [z=0] [x=9] [y=5]
각 엔트리는:
index: 로그 내 위치 (1부터 시작)term: 해당 엔트리가 생성된 termcommand: state machine에 적용할 명령
로그 복제 흐름
sequenceDiagram
Client->>Leader: SET x=5
Leader->>Leader: append to log (uncommitted)
Leader->>Follower1: AppendEntries(x=5)
Leader->>Follower2: AppendEntries(x=5)
Leader->>Follower3: AppendEntries(x=5)
Leader->>Follower4: AppendEntries(x=5)
Follower1-->>Leader: success
Follower2-->>Leader: success
Note over Leader: majority ack → commit
Leader->>Leader: apply to state machine
Leader-->>Client: OK
Leader->>Follower1: AppendEntries(commitIndex=N)
Leader->>Follower2: AppendEntries(commitIndex=N)
- 클라이언트 요청 → Leader의 로그에 append (아직 uncommitted).
- Leader가 AppendEntries를 Follower들에게 병렬 전송.
- 과반수(majority) 가 성공하면 → commit.
- Leader가 로그를 state machine에 적용하고 클라이언트에 응답.
- 다음 AppendEntries에서 commitIndex를 전달 → Follower도 적용.
AppendEntries RPC
AppendEntries(
term, // 리더의 현재 term
leaderId,
prevLogIndex, // 새 엔트리 바로 앞의 로그 인덱스
prevLogTerm, // 위 인덱스의 term
entries[], // 복제할 엔트리 (비어있으면 heartbeat)
leaderCommit // 리더의 commitIndex
)
Follower의 응답:
term: 현재 term (더 높으면 Leader가 step down)success:prevLogIndex위치의 term이prevLogTerm과 일치하면 true
Log Matching Property
Raft는 다음 두 가지를 보장한다:
- 두 로그에 같은 index와 term을 가진 엔트리가 있으면, 그 엔트리의 명령은 동일하다.
- 두 로그에 같은 index와 term을 가진 엔트리가 있으면, 그 앞의 모든 엔트리도 동일하다.
이것이 Log Matching Property다. 이 성질은 다음으로 유지된다:
- Leader는 주어진 term/index에 한 번만 엔트리를 생성한다.
- AppendEntries의 일관성 검사(
prevLogIndex,prevLogTerm)가 이를 강제한다.
로그 불일치 해결
Leader가 바뀐 직후, Follower들의 로그가 Leader와 다를 수 있다. Raft는 이를 이렇게 해결한다:
- Leader는 각 Follower에 대해
nextIndex를 관리. - AppendEntries가 실패하면
nextIndex를 1 감소시키고 재시도. - 일치점을 찾을 때까지 반복.
- 일치점 이후의 Follower 로그는 Leader의 로그로 덮어쓴다.
def send_append_entries(self, follower_id):
next_idx = self.next_index[follower_id]
prev_idx = next_idx - 1
prev_term = self.log[prev_idx].term if prev_idx > 0 else 0
entries = self.log[next_idx:] # next_idx 이후 모두
response = self.rpc(follower_id, "AppendEntries", {
"term": self.current_term,
"prev_log_index": prev_idx,
"prev_log_term": prev_term,
"entries": entries,
"leader_commit": self.commit_index,
})
if response.success:
self.next_index[follower_id] = next_idx + len(entries)
self.match_index[follower_id] = self.next_index[follower_id] - 1
self.maybe_advance_commit()
else:
if response.term > self.current_term:
self.become_follower(response.term)
else:
self.next_index[follower_id] -= 1 # 한 칸 뒤로
4. Safety: 안전 속성
Raft가 올바른지 증명하기 위해 5가지 안전 속성을 만족해야 한다:
1. Election Safety
"주어진 term에 최대 한 명의 리더만 선출된다."
보장 방법: 각 노드는 term당 한 번만 투표한다. 다수 노드 집합이 두 개일 수 없으므로, 두 Candidate가 동시에 다수 득표는 불가능.
2. Leader Append-Only
"리더는 자신의 로그에서 엔트리를 덮어쓰거나 삭제하지 않는다. 오직 추가만 한다."
보장 방법: 구현상 단순히 규칙으로 강제.
3. Log Matching
"두 로그에 같은 index와 term의 엔트리가 있으면, 해당 index까지 모든 엔트리는 동일하다."
보장 방법: AppendEntries의 prevLogIndex/prevLogTerm 검사.
4. Leader Completeness
"어떤 term에 커밋된 엔트리는 그 term 이후의 모든 리더에게도 존재한다."
이것이 가장 중요하고 미묘한 속성이다. 커밋된 엔트리가 절대 사라지면 안 된다.
보장 방법: Election Restriction
- RequestVote에서 up-to-date 검사 수행.
- 커밋된 엔트리는 다수 노드에 존재 → 다수의 투표자가 그 엔트리를 보유.
- 최신 로그를 가진 후보만 당선 가능 → 항상 커밋된 엔트리를 포함한 후보가 이긴다.
5. State Machine Safety
"어떤 노드가 주어진 index의 엔트리를 state machine에 적용했다면, 다른 어떤 노드도 같은 index에 다른 엔트리를 적용할 수 없다."
이는 위 4가지 속성으로부터 도출된다.
미묘한 버그: 이전 term의 엔트리 커밋
Raft 논문의 Figure 8은 매우 유명한 코너 케이스다. 요약:
- Leader S1이 term 2에서 엔트리를 Follower들에게 복제.
- S1이 죽기 전에 다수에 도달했지만 commit 표시를 하지 못함.
- 새 리더 S5가 term 3에서 다른 엔트리로 덮어쓸 수 있음.
해결책: "Leader는 자기 term의 엔트리만 commit count로 계산한다."
즉, Leader는 이전 term의 엔트리를 다수에 복제했더라도 그것만으로 커밋하지 않는다. 현재 term의 엔트리가 커밋되면, 부수적으로 이전 엔트리들도 커밋된다 (Log Matching에 의해).
def maybe_advance_commit(self):
for n in range(self.commit_index + 1, len(self.log) + 1):
# 현재 term의 엔트리만 직접 커밋 대상
if self.log[n].term != self.current_term:
continue
count = 1 # 자기 자신
for peer in self.peers:
if self.match_index[peer] >= n:
count += 1
if count > len(self.peers) // 2:
self.commit_index = n
5. Cluster Membership Change
운영 중인 Raft 클러스터에서 노드를 추가/제거할 수 있어야 한다. 그런데 이게 매우 까다롭다.
문제: 단순한 변경의 위험성
만약 3노드 클러스터(C_old = {A, B, C})를 5노드(C_new = {A, B, C, D, E})로 바꾼다고 하자. 모든 노드가 동시에 새 설정을 적용할 수 없다. 일부는 C_old, 일부는 C_new를 사용하는 순간이 존재한다.
이때:
- C_old의 다수 =
{A, B} - C_new의 다수 =
{A, B, C}
두 집합이 겹치지 않을 수 있다면, 두 명의 리더가 동시에 존재할 수 있다. 이는 치명적이다.
해결책 1: Joint Consensus (원논문)
단계:
- Leader가 C_old,new (두 설정의 합집합) 엔트리를 로그에 추가.
- C_old,new가 커밋되면, Leader는 C_new 엔트리를 추가.
- C_new가 커밋되면 설정 변경 완료.
이 중간 상태에서 결정은 C_old의 다수 AND C_new의 다수 모두 필요하다. 그래서 두 리더가 동시에 존재할 수 없다.
해결책 2: Single Server Change (etcd 방식)
"한 번에 한 노드만 추가/제거한다."
이 제약만 있으면 joint consensus 없이도 안전하다. 왜냐하면 한 노드 추가/제거로는 다수가 겹치지 않을 수 없기 때문이다:
- 3노드 → 4노드:
{A, B, C}의 다수(2명)와{A, B, C, D}의 다수(3명)는 반드시 겹친다.
etcd와 대부분의 구현이 이 방식을 사용한다.
6. Log Compaction (Snapshot)
Raft 로그는 무한히 증가할 수 없다. 오래된 엔트리를 정리해야 한다.
스냅샷의 필요성
- 디스크 공간 절약
- 새 노드가 빠르게 따라잡을 수 있게
- 시작 시 로그 재생 시간 단축
스냅샷 포맷
각 노드는 독립적으로 스냅샷을 생성한다:
Snapshot {
last_included_index: 5, // 스냅샷에 포함된 마지막 로그 인덱스
last_included_term: 2, // 해당 엔트리의 term
state_machine_state: {...} // 실제 state machine 상태
}
스냅샷 생성 후 로그의 last_included_index 이전은 삭제한다.
InstallSnapshot RPC
느린 Follower가 너무 뒤처져서 Leader가 필요한 로그를 이미 버렸다면, Leader는 InstallSnapshot RPC로 전체 스냅샷을 전송한다.
InstallSnapshot(
term,
leaderId,
lastIncludedIndex,
lastIncludedTerm,
offset, // 청크 전송을 위한 오프셋
data[], // 스냅샷 데이터
done // 마지막 청크인지
)
Follower는 스냅샷을 받아서 자신의 로그와 state machine을 교체한다.
etcd의 구현
etcd는 주기적으로 스냅샷을 생성하며, 기본 --snapshot-count=10000 (10000개 엔트리마다)이다. 너무 자주 하면 I/O 비용, 너무 드물면 로그가 커진다.
7. 클라이언트 상호작용
Linearizable Read
"최신 데이터를 읽고 싶다" 는 단순해 보이지만 어렵다. 단순히 Leader에게 물어봐도, 그 Leader가 사실은 stale할 수 있다 (네트워크 분리로 다른 리더가 이미 선출됨).
해결 방법들:
-
Read through Log: 읽기도 로그 엔트리로 만들어 과반 커밋 후 응답. 안전하지만 느림.
-
ReadIndex: Leader가 현재 commitIndex를 기록하고, heartbeat으로 아직 리더인지 확인 후 응답. 빠름.
-
Lease Read: Leader가 일정 기간(lease) 동안 자신이 리더임을 보장받음. 그 기간 동안은 바로 응답. 시계 동기화 필요.
etcd는 기본적으로 ReadIndex를 사용한다.
중복 요청 방지 (Linearizable Write)
클라이언트가 요청을 보낸 후 응답이 오지 않으면 재시도할 수 있다. 이때 같은 명령이 두 번 실행되면 안 된다.
해결: 각 클라이언트에 고유 ID와 순차 번호 부여
- Leader는
(client_id, sequence_no)페어를 state machine에 저장. - 이미 처리한 요청이면 이전 결과를 그대로 반환.
- 이는 exactly-once 시맨틱을 제공한다.
8. 실전 시스템 분석
etcd
etcd는 Raft의 가장 유명한 구현체이며, Kubernetes의 모든 상태를 저장한다.
주요 특징:
- Go로 작성된 raft 라이브러리 (
go.etcd.io/raft). - MVCC 기반 key-value 스토어.
- Watch API로 변경 알림 구독 가능.
- Lease로 TTL 키 지원.
- 기본 포트 2379 (클라이언트), 2380 (peer).
성능 특성 (3노드 etcd):
- 쓰기: ~10,000 ops/s (디스크 fsync 병목)
- 읽기 (linearizable): ~40,000 ops/s
- 권장 클러스터 크기: 3 또는 5
3보다 적으면 내결함성 부족, 5보다 많으면 복제 지연 증가.
Consul
HashiCorp의 Consul은 서비스 디스커버리와 KV 스토어로 유명하다:
- Raft 기반 consistent mode KV 스토어.
- 멀티 데이터센터 지원 (각 DC가 자체 Raft 클러스터).
- DNS 인터페이스로 서비스 디스커버리.
CockroachDB / TiDB / YugabyteDB
NewSQL 데이터베이스들은 데이터를 range 또는 region으로 분할하고, 각 range마다 Raft 그룹을 만든다:
- CockroachDB: 64MB range, 각각 3-replica Raft 그룹.
- TiDB (TiKV): 96MB region, Multi-Raft.
- 수만 개의 Raft 그룹을 동시에 관리.
Multi-Raft 최적화:
- 메시지 배치 (여러 Raft 그룹의 메시지를 묶어 전송)
- Follower Replication (리더 과부하 방지)
- Region merge/split (동적 파티셔닝)
MongoDB Replication
MongoDB의 replica set은 "Raft-like" 프로토콜을 사용한다:
- Primary 선출 = Leader Election.
- Oplog 복제 = Log Replication.
w: "majority"쓰기 보장 = 과반 복제 확인.
완전히 Raft는 아니지만 설계 철학을 공유한다.
Kafka KRaft
Kafka 2.8부터 ZooKeeper 대신 KRaft 모드로 메타데이터를 관리한다:
- ZooKeeper 의존 제거 → 배포 단순화.
- Controller quorum이 Raft로 메타데이터 로그 관리.
- 수백만 파티션 지원 가능.
9. 성능 최적화 기법
1. Pipeline AppendEntries
Leader는 Follower의 응답을 기다리지 않고 다음 AppendEntries를 보낸다. TCP 파이프라인과 유사한 효과로 처리량 대폭 향상.
2. Batching
여러 클라이언트 요청을 하나의 AppendEntries로 묶어 전송. fsync 비용을 여러 요청이 분담.
3. Parallel Disk Write
log append와 follower send를 병렬로 처리. 디스크와 네트워크가 동시에 바쁨.
4. PreVote
선거 전 사전 투표를 통해 파티션에서 복귀한 노드가 불필요하게 term을 증가시키는 것을 방지. etcd는 기본 활성화.
5. Follower Read (비선형적)
Stale read를 허용하는 경우, Follower가 직접 응답. Leader 부하 감소.
6. Witness Replicas
CockroachDB는 전체 데이터를 저장하지 않고 투표만 하는 경량 replica를 지원. 3개 중 1개를 witness로 만들면 저장 공간 절약.
10. 운영상 흔한 문제와 해결책
문제 1: 선거 폭풍 (Election Storm)
증상: 리더가 계속 바뀜. 처리량 0에 가까움.
원인:
- 네트워크 지연이 election timeout보다 큼.
- GC pause, 디스크 I/O 블로킹.
- CPU 과부하.
해결:
election-timeout을 늘림 (etcd 기본 1000ms → 5000ms).heartbeat-interval을 줄임 (기본 100ms → 50ms).- PreVote 활성화.
- 네트워크 및 디스크 모니터링 강화.
문제 2: 로그 무한 증가
증상: 디스크가 가득 참.
원인: 스냅샷이 생성되지 않거나, Follower가 계속 뒤처짐.
해결:
--snapshot-count확인.- Follower 지연 모니터링.
- 뒤처진 Follower 재시작 (InstallSnapshot 트리거).
문제 3: Quorum Loss
증상: 클러스터가 응답하지 않음. context deadline exceeded.
원인: 노드 과반이 장애.
예시: 3노드 클러스터에서 2노드 장애 → 남은 1노드는 과반 형성 불가 → 읽기도 안 됨.
해결책:
- Disaster recovery: 남은 노드에서 단일 노드 클러스터로 강제 복원.
etcdctl snapshot restore후 단일 노드로 재시작.- 새 노드들을 하나씩 추가.
예방: 항상 홀수 노드 사용, 다른 AZ에 분산.
문제 4: 느린 디스크
Raft는 fsync를 매 write마다 호출한다 (안전성). SSD와 HDD의 fsync 지연 차이가 Raft 처리량에 직결된다.
- HDD: fsync ~10ms → 최대 100 ops/s
- SSD: fsync ~0.1ms → 최대 10,000 ops/s
- NVMe: fsync ~0.01ms → 최대 100,000 ops/s
절대 Raft 노드를 HDD 위에 올리지 말자.
문제 5: 네트워크 파티션
Split Brain 방지: Raft는 과반 요구로 split brain을 막는다. 두 파티션 중 과반을 가진 쪽만 진행한다.
Minority partition: 과반 미달 쪽은 읽기/쓰기 모두 실패. 이것은 버그가 아니라 기능이다. CAP 정리에서 Raft는 CP 시스템이다.
11. Raft vs Paxos vs Zab
| 항목 | Raft | Multi-Paxos | Zab (ZooKeeper) |
|---|---|---|---|
| 이해 난이도 | 쉬움 | 어려움 | 중간 |
| Leader 기반 | Yes | 선택적 | Yes |
| Election 방식 | Timeout + RequestVote | 제안 번호 기반 | FLE (Fast Leader Election) |
| 로그 일관성 | 강제 동일 | 느슨 | 강제 동일 |
| 사용 예 | etcd, Consul, CockroachDB | Google Spanner, Chubby | ZooKeeper |
| 구현 체크리스트 | 공식 제공 | 각자 변종 | ZooKeeper 특화 |
왜 Raft가 인기인가?
- 구현 가능성: 논문에서 직접 의사코드와 구현 가이드 제공.
- 디버깅 용이: 상태가 명확 (Follower/Candidate/Leader).
- 풍부한 레퍼런스: 수많은 오픈소스 구현체 존재.
- 교육 친화적: 대학 수업에서 가르치기 쉬움.
Google 내부에서는 여전히 Paxos가 우세하지만 (Spanner, Chubby), 외부 오픈소스 세계는 Raft가 압도적이다.
12. 직접 구현해 보기
학습 자료
- 원 논문: "In Search of an Understandable Consensus Algorithm" - 18쪽. 놀랍도록 읽기 쉽다.
- 공식 사이트: raft.github.io - 인터랙티브 시각화.
- The Raft Consensus Algorithm 강의 영상: Diego Ongaro의 1시간 강의.
- MIT 6.824: Distributed Systems 수업. Lab 2에서 Raft를 직접 구현한다.
MIT 6.824 Lab 2 구조
- 2A: Leader Election
- 2B: Log Replication
- 2C: Persistence (재시작 시 상태 복구)
- 2D: Log Compaction (Snapshot)
이 4단계를 통과하면 기본적인 Raft 구현이 완성된다. Go로 작성하며, 테스트 케이스가 매우 엄격하다.
흔한 학습자 실수
- Heartbeat 속에 로그 안 보냄: Heartbeat은 빈 AppendEntries지만 로그 업데이트도 여기에 piggyback.
- term 체크 누락: 모든 RPC에서 term 비교 후 필요 시 step down.
- commitIndex 잘못 갱신: 이전 term 엔트리 커밋 문제.
- persistence 빠뜨림: currentTerm, votedFor, log는 반드시 디스크에 fsync.
- timer reset 위치 오류: heartbeat 수신 시에만 reset해야 함.
퀴즈로 복습하기
Q1. Raft가 Paxos에 비해 "이해 가능성"을 높이기 위해 선택한 핵심 설계 원칙은?
A. 문제를 독립된 하위 문제로 분해한 것이다. Raft는 합의 문제를 (1) Leader Election, (2) Log Replication, (3) Safety의 세 하위 문제로 나누고, 각각을 독립적으로 이해할 수 있게 했다. 또한 상태 공간을 줄여서 (Follower/Candidate/Leader 3가지) 추론을 단순화했고, 강한 리더 모델을 채택해서 로그 흐름을 단방향으로 만들었다.
Q2. 왜 Raft 클러스터는 보통 3, 5, 7처럼 홀수로 구성하는가?
A. 과반(majority) 요구 때문이다. 2N+1 노드는 N개의 장애를 견딘다. 예를 들어:
- 3노드: 과반 2, 1개 장애 허용.
- 5노드: 과반 3, 2개 장애 허용.
- 4노드: 과반 3, 1개 장애 허용. → 3노드와 같은 내결함성인데 노드는 하나 더 필요. 비효율.
짝수 노드는 내결함성이 같거나 더 낮은데 자원은 더 들기 때문에 홀수를 선호한다.
Q3. RequestVote RPC에서 "up-to-date log" 검사는 왜 필요한가?
A. Leader Completeness 속성을 보장하기 위해서다. 커밋된 엔트리는 과반 노드에 존재한다. 새 리더가 되려면 과반의 투표가 필요한데, 이때 "투표자의 로그보다 최신인 후보에게만 투표"한다는 규칙이 있으면, 커밋된 엔트리를 누락한 후보는 당선될 수 없다. 이는 다음으로 증명된다: 커밋된 엔트리를 가진 과반 노드 중 최소 한 명은 투표해야 당선 가능 → 그 노드의 로그가 후보보다 최신 → 후보는 그 엔트리를 반드시 포함해야 투표를 얻을 수 있다.
Q4. Raft에서 왜 "이전 term의 엔트리는 복제 수만으로 커밋하지 않는가"?
A. Figure 8 코너 케이스 때문이다. 과거 term 엔트리가 과반에 복제되었더라도, 그것만으로 커밋 처리하면 나중에 다른 엔트리가 그 위치에 덮어쓰일 수 있다. Raft는 이를 막기 위해 "현재 term의 엔트리가 커밋되면 그에 딸려서 이전 엔트리들도 커밋된다"는 규칙을 사용한다. 이는 Log Matching Property에 의해 안전하다.
Q5. etcd에서 election timeout을 늘리면 어떤 트레이드오프가 있는가?
A. 장점: 일시적 네트워크 지연이나 GC pause로 인한 불필요한 선거를 막을 수 있다. 선거 폭풍 방지에 효과적. 단점: 실제로 리더가 죽었을 때 새 리더 선출까지 더 오래 걸린다. 이 기간 동안 클러스터는 쓰기가 불가능하다. 보통 기본값 1000ms는 대부분의 온프레미스 환경에 적합하지만, 클라우드 환경이나 고부하 시스템에서는 3000~5000ms로 늘려야 할 수 있다.
마치며: Raft가 우리에게 준 것
Raft는 단순한 알고리즘이 아니라 연구 철학이다. Diego Ongaro와 John Ousterhout은 "기존 알고리즘이 어렵다면, 이해 가능한 새 알고리즘을 만들자"는 접근으로 분산 시스템 연구에 큰 전환점을 가져왔다.
핵심 아이디어 정리
- Leader-based consensus: 단일 리더가 모든 쓰기를 직렬화.
- Term 기반 논리 시간: stale 리더 자동 감지.
- Election safety: 과반 투표로 단일 리더 보장.
- Log Matching: 일관성 검사로 로그 동기화.
- Leader Completeness: up-to-date 검사로 커밋된 데이터 보존.
우리가 매일 Raft를 쓰고 있다는 사실
kubectl apply→ Kubernetes API Server → etcd (Raft)consul kv put→ Consul (Raft)- CockroachDB 쓰기 → Multi-Raft
- Kafka 3.x 메타데이터 → KRaft
Raft를 이해하면 이 시스템들의 동작, 장애, 성능 특성이 투명해진다.
다음 단계
Raft를 완전히 이해했다면:
- EPaxos (Leaderless Paxos)
- Flexible Paxos (Quorum 유연화)
- HotStuff (블록체인용 BFT)
- Byzantine Raft (악의적 노드까지 견디는 변종)
같은 고급 주제로 나아갈 수 있다. 하지만 먼저 Raft를 손으로 구현해 보자. 분산 시스템 엔지니어의 성장에 가장 효과적인 훈련이다.
참고 자료
- In Search of an Understandable Consensus Algorithm (Ongaro & Ousterhout, 2014) - Raft 원 논문
- Raft visualization - 인터랙티브 시뮬레이션
- TLA+ Specification of Raft - 정형 검증
- etcd Raft Library - Go 구현체
- MIT 6.824 Distributed Systems - 대학 강의
- Consensus: Bridging Theory and Practice (Ongaro 박사논문) - 더 깊이 있는 설명
- Diego Ongaro 강의 영상
현재 단락 (1/389)
다섯 대의 서버가 있다. 각각 독립적으로 "지금 잔고는 얼마인가?"라는 질문에 답할 수 있어야 한다. 그런데 이런 상황이 벌어진다: