📖 상세 설명
Raft는 "Reliable, Replicated, Redundant, And Fault-Tolerant"의 약자로, 분산 시스템에서 여러 노드가 동일한 상태에 합의하기 위한 알고리즘입니다. 2013년 스탠포드 대학교의 Diego Ongaro 박사가 논문 "In Search of an Understandable Consensus Algorithm"에서 발표했습니다.
Raft 이전에는 Paxos가 합의 알고리즘의 표준이었지만, 그 복잡성으로 인해 정확한 구현이 어려웠습니다. Raft는 동일한 안전성(Safety)을 보장하면서도 이해하기 쉽도록 문제를 세 가지 독립적인 하위 문제로 분해했습니다: Leader Election(리더 선출), Log Replication(로그 복제), Safety(안전성 보장).
Raft 클러스터의 각 노드는 Leader, Follower, Candidate 세 가지 상태 중 하나입니다. 정상 상황에서는 하나의 Leader가 모든 로그 엔트리를 관리하고 Follower에게 복제합니다. Leader가 실패하면 Follower 중 하나가 Candidate가 되어 새 Leader를 선출합니다. 이 과정은 과반수(Quorum) 투표로 진행됩니다.
실무에서 Raft는 etcd(Kubernetes의 설정 저장소), Consul(서비스 디스커버리), CockroachDB, TiDB(분산 SQL), HashiCorp Vault, NATS JetStream 등 핵심 인프라에 사용됩니다. 3, 5, 7개의 홀수 노드로 구성하여 f개의 노드 장애를 허용합니다(2f+1 노드 필요).
Raft의 핵심 메커니즘
Leader Election
Heartbeat 타임아웃 시 Follower가 Candidate로 전환, 과반수 투표로 Leader 선출. Term 번호로 리더 임기 관리.
Log Replication
Leader가 클라이언트 요청을 로그에 추가하고 Follower에게 복제. 과반수 복제 시 커밋(적용).
Safety
가장 최신 로그를 가진 노드만 Leader가 될 수 있음. 커밋된 로그는 절대 덮어쓰지 않음.
Term (임기)
논리적 시간 단위. 선거마다 증가. 오래된 Term의 메시지는 무시.
노드 상태 전이도
┌─────────────┐
│ Follower │◄──────────────────────────────────┐
└──────┬──────┘ │
│ 타임아웃 (Leader heartbeat 없음) │
▼ │
┌─────────────┐ 과반수 득표 ┌─────────────┐ │
│ Candidate │──────────────►│ Leader │──────┤
└──────┬──────┘ └──────┬──────┘ │
│ │ │
│ 더 높은 Term 발견 │ 더 높은 Term │
│ 또는 새 Leader 발견 │ Leader 발견 │
└──────────────────────────────┴──────────────┘
💻 코드 예제
Go - Raft 노드 구조 (단순화된 구현)
package raft
import (
"sync"
"time"
)
type NodeState int
const (
Follower NodeState = iota
Candidate
Leader
)
type LogEntry struct {
Term int
Index int
Command interface{}
}
type RaftNode struct {
mu sync.Mutex
// 영속 상태 (모든 서버)
currentTerm int // 현재 Term
votedFor string // 현재 Term에서 투표한 후보
log []LogEntry // 로그 엔트리
// 휘발 상태 (모든 서버)
commitIndex int // 커밋된 최고 로그 인덱스
lastApplied int // State Machine에 적용된 최고 인덱스
// 휘발 상태 (Leader만)
nextIndex map[string]int // 각 Follower에게 보낼 다음 로그 인덱스
matchIndex map[string]int // 각 Follower가 복제 완료한 인덱스
// 노드 메타데이터
id string
peers []string
state NodeState
leader string
// 타이머
electionTimeout time.Duration
heartbeatTimeout time.Duration
lastHeartbeat time.Time
}
// RequestVote RPC - 선거 시 투표 요청
type RequestVoteArgs struct {
Term int // 후보의 Term
CandidateID string // 후보 ID
LastLogIndex int // 후보의 마지막 로그 인덱스
LastLogTerm int // 후보의 마지막 로그 Term
}
type RequestVoteReply struct {
Term int // 현재 Term (후보가 업데이트용)
VoteGranted bool // 투표 승인 여부
}
func (n *RaftNode) RequestVote(args *RequestVoteArgs, reply *RequestVoteReply) {
n.mu.Lock()
defer n.mu.Unlock()
reply.Term = n.currentTerm
reply.VoteGranted = false
// 후보의 Term이 오래되었으면 거부
if args.Term < n.currentTerm {
return
}
// 더 높은 Term이면 Follower로 전환
if args.Term > n.currentTerm {
n.currentTerm = args.Term
n.state = Follower
n.votedFor = ""
}
// 아직 투표 안했고, 후보의 로그가 최신이면 투표
if n.votedFor == "" || n.votedFor == args.CandidateID {
if n.isLogUpToDate(args.LastLogIndex, args.LastLogTerm) {
n.votedFor = args.CandidateID
reply.VoteGranted = true
n.resetElectionTimer()
}
}
}
// 후보의 로그가 내 로그보다 최신인지 확인
func (n *RaftNode) isLogUpToDate(lastIndex, lastTerm int) bool {
myLastIndex := len(n.log) - 1
if myLastIndex < 0 {
return true
}
myLastTerm := n.log[myLastIndex].Term
// Term이 더 크거나, Term 같고 Index가 같거나 크면 최신
return lastTerm > myLastTerm ||
(lastTerm == myLastTerm && lastIndex >= myLastIndex)
}
// AppendEntries RPC - 로그 복제 및 Heartbeat
type AppendEntriesArgs struct {
Term int // Leader의 Term
LeaderID string // Leader ID
PrevLogIndex int // 새 로그 직전 인덱스
PrevLogTerm int // 직전 로그의 Term
Entries []LogEntry // 복제할 로그 (Heartbeat 시 빈 배열)
LeaderCommit int // Leader의 commitIndex
}
type AppendEntriesReply struct {
Term int // 현재 Term
Success bool // 로그 일치 여부
}
func (n *RaftNode) AppendEntries(args *AppendEntriesArgs, reply *AppendEntriesReply) {
n.mu.Lock()
defer n.mu.Unlock()
reply.Term = n.currentTerm
reply.Success = false
// 오래된 Leader의 요청은 거부
if args.Term < n.currentTerm {
return
}
// 유효한 Leader를 발견하면 Follower로 전환
if args.Term >= n.currentTerm {
n.currentTerm = args.Term
n.state = Follower
n.leader = args.LeaderID
}
n.resetElectionTimer()
// 로그 일관성 검사
if args.PrevLogIndex >= 0 {
if args.PrevLogIndex >= len(n.log) ||
n.log[args.PrevLogIndex].Term != args.PrevLogTerm {
return // 로그 불일치
}
}
// 로그 추가 (충돌 시 덮어쓰기)
for i, entry := range args.Entries {
idx := args.PrevLogIndex + 1 + i
if idx < len(n.log) {
if n.log[idx].Term != entry.Term {
n.log = n.log[:idx] // 충돌 이후 삭제
}
}
if idx >= len(n.log) {
n.log = append(n.log, entry)
}
}
// commitIndex 업데이트
if args.LeaderCommit > n.commitIndex {
lastNewIndex := args.PrevLogIndex + len(args.Entries)
if args.LeaderCommit < lastNewIndex {
n.commitIndex = args.LeaderCommit
} else {
n.commitIndex = lastNewIndex
}
n.applyCommittedEntries()
}
reply.Success = true
}
etcd 클라이언트 (Go)
package main
import (
"context"
"fmt"
"time"
clientv3 "go.etcd.io/etcd/client/v3"
)
func main() {
// etcd 클러스터 연결 (3노드 Raft)
cli, err := clientv3.New(clientv3.Config{
Endpoints: []string{"localhost:2379", "localhost:2380", "localhost:2381"},
DialTimeout: 5 * time.Second,
})
if err != nil {
panic(err)
}
defer cli.Close()
ctx := context.Background()
// 키-값 저장 (Raft로 복제됨)
_, err = cli.Put(ctx, "/services/api/config", `{"port": 8080}`)
if err != nil {
panic(err)
}
fmt.Println("✅ 설정 저장 완료 (Raft 합의 완료)")
// 키-값 조회
resp, _ := cli.Get(ctx, "/services/api/config")
for _, kv := range resp.Kvs {
fmt.Printf("📦 Key: %s, Value: %s\n", kv.Key, kv.Value)
}
// Leader 정보 확인
status, _ := cli.Status(ctx, "localhost:2379")
fmt.Printf("🎯 Leader ID: %d\n", status.Leader)
fmt.Printf("📊 Raft Term: %d\n", status.RaftTerm)
fmt.Printf("📝 Raft Index: %d\n", status.RaftIndex)
// Watch (변경 감지)
watchCh := cli.Watch(ctx, "/services/", clientv3.WithPrefix())
go func() {
for wresp := range watchCh {
for _, ev := range wresp.Events {
fmt.Printf("🔔 [%s] %s = %s\n", ev.Type, ev.Kv.Key, ev.Kv.Value)
}
}
}()
}