Skip to content

필사 모드: Raft Consensus 완전 가이드 2025: Leader Election, Log Replication, Safety, etcd/Consul 실전 분석

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며: 왜 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와 동일한 안전성 보장을 제공하지만, **문제를 세 개의 독립된 하위 문제로 분해**한다:

1. **Leader Election**: 리더 선출

2. **Log Replication**: 로그 복제

3. **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은:

1. **Election(선거) 페이즈**로 시작

2. **Normal operation(정상 운영) 페이즈**로 이어짐

3. 리더 실패 시 → 새 Term으로 넘어감

Term 1 Term 2 Term 3 Term 4

[L][N][N] [L][N] (무리더) [L][N][N]...

선거 후 선거 후 선거 실패 선거 후 정상

정상 정상

Term은 **stale한 리더(구 리더)** 를 걸러내는 역할도 한다: 더 높은 Term을 본 노드는 즉시 자신의 상태를 갱신한다.

필수 RPC 두 가지

Raft는 단 두 개의 RPC만 사용한다:

1. **RequestVote RPC**: Candidate가 투표를 요청할 때.

2. **AppendEntries RPC**: Leader가 로그 복제 및 heartbeat을 보낼 때.

이 단순함이 Raft의 이해 가능성을 크게 높인다.

2. Leader Election: 리더 선출

선거 타임아웃

모든 Follower는 **election timeout**을 가진다 (보통 150~300ms 랜덤). 이 시간 안에 Leader로부터 AppendEntries를 받지 못하면:

1. 스스로를 **Candidate**로 전환.

2. **currentTerm을 1 증가**.

3. 자기 자신에게 **투표**.

4. 다른 모든 노드에게 **RequestVote RPC** 전송.

5. 새로운 **election timer** 시작.

투표 요청의 조건

RequestVote를 받은 노드는 다음 조건 **모두** 만족 시 찬성표를 던진다:

1. 요청자의 `term >= currentTerm`.

2. 이 Term에 **아직 아무에게도 투표하지 않음** (또는 같은 후보).

3. 요청자의 **로그가 자신 것만큼 최신**일 것 (up-to-date 검사).

**up-to-date 검사**는 다음과 같이 정의된다:

- 로그의 마지막 엔트리의 **term이 더 큰 쪽이 최신**.

- 같은 term이면 **index가 더 큰 쪽이 최신**.

이 조건이 **안전 속성의 핵심**이다. 뒤에서 다시 설명한다.

선거 결과 3가지

1. **다수(majority) 득표** → **Leader**가 된다. 즉시 heartbeat 전송 시작.

2. **다른 리더 발견**(더 높거나 같은 term의 AppendEntries 수신) → **Follower**로 전환.

3. **타임아웃 (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`: 해당 엔트리가 생성된 term

- `command`: 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)

1. **클라이언트 요청** → Leader의 로그에 append (아직 uncommitted).

2. Leader가 **AppendEntries**를 Follower들에게 병렬 전송.

3. **과반수(majority)** 가 성공하면 → **commit**.

4. Leader가 로그를 **state machine에 적용**하고 클라이언트에 응답.

5. 다음 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는 다음 두 가지를 보장한다:

1. **두 로그에 같은 index와 term을 가진 엔트리가 있으면, 그 엔트리의 명령은 동일하다.**

2. **두 로그에 같은 index와 term을 가진 엔트리가 있으면, 그 앞의 모든 엔트리도 동일하다.**

이것이 **Log Matching Property**다. 이 성질은 다음으로 유지된다:

- Leader는 주어진 term/index에 한 번만 엔트리를 생성한다.

- AppendEntries의 일관성 검사(`prevLogIndex`, `prevLogTerm`)가 이를 강제한다.

로그 불일치 해결

Leader가 바뀐 직후, Follower들의 로그가 Leader와 다를 수 있다. Raft는 이를 이렇게 해결한다:

1. Leader는 각 Follower에 대해 `nextIndex`를 관리.

2. AppendEntries가 실패하면 `nextIndex`를 **1 감소**시키고 재시도.

3. 일치점을 찾을 때까지 반복.

4. 일치점 이후의 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 (원논문)

**단계:**

1. Leader가 **C_old,new** (두 설정의 합집합) 엔트리를 로그에 추가.

2. C_old,new가 커밋되면, Leader는 **C_new** 엔트리를 추가.

3. 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할 수 있다 (네트워크 분리로 다른 리더가 이미 선출됨).

**해결 방법들:**

1. **Read through Log**: 읽기도 로그 엔트리로 만들어 과반 커밋 후 응답. 안전하지만 느림.

2. **ReadIndex**: Leader가 현재 commitIndex를 기록하고, heartbeat으로 아직 리더인지 확인 후 응답. 빠름.

3. **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가 인기인가?

1. **구현 가능성**: 논문에서 직접 의사코드와 구현 가이드 제공.

2. **디버깅 용이**: 상태가 명확 (Follower/Candidate/Leader).

3. **풍부한 레퍼런스**: 수많은 오픈소스 구현체 존재.

4. **교육 친화적**: 대학 수업에서 가르치기 쉬움.

Google 내부에서는 여전히 Paxos가 우세하지만 (Spanner, Chubby), 외부 오픈소스 세계는 Raft가 압도적이다.

12. 직접 구현해 보기

학습 자료

1. **원 논문**: ["In Search of an Understandable Consensus Algorithm"](https://raft.github.io/raft.pdf) - 18쪽. 놀랍도록 읽기 쉽다.

2. **공식 사이트**: [raft.github.io](https://raft.github.io) - 인터랙티브 시각화.

3. **The Raft Consensus Algorithm 강의 영상**: Diego Ongaro의 1시간 강의.

4. **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로 작성하며, 테스트 케이스가 매우 엄격하다.

흔한 학습자 실수

1. **Heartbeat 속에 로그 안 보냄**: Heartbeat은 빈 AppendEntries지만 로그 업데이트도 여기에 piggyback.

2. **term 체크 누락**: 모든 RPC에서 term 비교 후 필요 시 step down.

3. **commitIndex 잘못 갱신**: 이전 term 엔트리 커밋 문제.

4. **persistence 빠뜨림**: currentTerm, votedFor, log는 반드시 디스크에 fsync.

5. **timer reset 위치 오류**: heartbeat 수신 시에만 reset해야 함.

퀴즈로 복습하기

**A.** 문제를 **독립된 하위 문제로 분해**한 것이다. Raft는 합의 문제를 (1) Leader Election, (2) Log Replication, (3) Safety의 세 하위 문제로 나누고, 각각을 독립적으로 이해할 수 있게 했다. 또한 **상태 공간을 줄여서** (Follower/Candidate/Leader 3가지) 추론을 단순화했고, **강한 리더 모델**을 채택해서 로그 흐름을 단방향으로 만들었다.

**A.** 과반(majority) 요구 때문이다. 2N+1 노드는 N개의 장애를 견딘다. 예를 들어:

- 3노드: 과반 2, 1개 장애 허용.

- 5노드: 과반 3, 2개 장애 허용.

- **4노드**: 과반 3, 1개 장애 허용. → 3노드와 같은 내결함성인데 노드는 하나 더 필요. **비효율**.

짝수 노드는 **내결함성이 같거나 더 낮은데 자원은 더 들기** 때문에 홀수를 선호한다.

**A.** **Leader Completeness** 속성을 보장하기 위해서다. 커밋된 엔트리는 과반 노드에 존재한다. 새 리더가 되려면 과반의 투표가 필요한데, 이때 "투표자의 로그보다 최신인 후보에게만 투표"한다는 규칙이 있으면, **커밋된 엔트리를 누락한 후보는 당선될 수 없다**. 이는 다음으로 증명된다: 커밋된 엔트리를 가진 과반 노드 중 최소 한 명은 투표해야 당선 가능 → 그 노드의 로그가 후보보다 최신 → 후보는 그 엔트리를 반드시 포함해야 투표를 얻을 수 있다.

**A.** Figure 8 코너 케이스 때문이다. 과거 term 엔트리가 과반에 복제되었더라도, 그것만으로 커밋 처리하면 **나중에 다른 엔트리가 그 위치에 덮어쓰일 수 있다**. Raft는 이를 막기 위해 "**현재 term의 엔트리가 커밋되면** 그에 딸려서 이전 엔트리들도 커밋된다"는 규칙을 사용한다. 이는 Log Matching Property에 의해 안전하다.

**A.** **장점**: 일시적 네트워크 지연이나 GC pause로 인한 불필요한 선거를 막을 수 있다. 선거 폭풍 방지에 효과적. **단점**: 실제로 리더가 죽었을 때 **새 리더 선출까지 더 오래 걸린다**. 이 기간 동안 클러스터는 쓰기가 불가능하다. 보통 기본값 1000ms는 대부분의 온프레미스 환경에 적합하지만, 클라우드 환경이나 고부하 시스템에서는 3000~5000ms로 늘려야 할 수 있다.

마치며: Raft가 우리에게 준 것

Raft는 단순한 알고리즘이 아니라 **연구 철학**이다. Diego Ongaro와 John Ousterhout은 "기존 알고리즘이 어렵다면, 이해 가능한 새 알고리즘을 만들자"는 접근으로 분산 시스템 연구에 큰 전환점을 가져왔다.

핵심 아이디어 정리

1. **Leader-based consensus**: 단일 리더가 모든 쓰기를 직렬화.

2. **Term 기반 논리 시간**: stale 리더 자동 감지.

3. **Election safety**: 과반 투표로 단일 리더 보장.

4. **Log Matching**: 일관성 검사로 로그 동기화.

5. **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)](https://raft.github.io/raft.pdf) - Raft 원 논문

- [Raft visualization](https://raft.github.io) - 인터랙티브 시뮬레이션

- [TLA+ Specification of Raft](https://github.com/ongardie/raft.tla) - 정형 검증

- [etcd Raft Library](https://pkg.go.dev/go.etcd.io/raft/v3) - Go 구현체

- [MIT 6.824 Distributed Systems](https://pdos.csail.mit.edu/6.824/) - 대학 강의

- [Consensus: Bridging Theory and Practice (Ongaro 박사논문)](https://web.stanford.edu/~ouster/cgi-bin/papers/OngaroPhD.pdf) - 더 깊이 있는 설명

- [Diego Ongaro 강의 영상](https://www.youtube.com/watch?v=vYp4LYbnnW8)

현재 단락 (1/374)

다섯 대의 서버가 있다. 각각 독립적으로 "지금 잔고는 얼마인가?"라는 질문에 답할 수 있어야 한다. 그런데 이런 상황이 벌어진다:

작성 글자: 0원문 글자: 14,475작성 단락: 0/374