🏗️ 아키텍처

Raft

Raft Consensus Algorithm

분산 시스템에서 노드 간 합의를 이루기 위한 알고리즘. Diego Ongaro와 John Ousterhout가 2013년 Paxos의 복잡성을 해결하기 위해 설계했습니다. "Understandability"를 최우선 목표로, Leader Election, Log Replication, Safety 세 가지 핵심 메커니즘으로 구성됩니다. etcd, Consul, TiKV, CockroachDB, NATS JetStream 등에서 사용됩니다.

📖 상세 설명

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)
            }
        }
    }()
}

📊 성능 & 비교

Raft vs 다른 합의 알고리즘

특성 Raft Paxos PBFT Zab (ZooKeeper)
이해 용이성 높음 ⭐ 낮음 중간 중간
장애 허용 f (2f+1 노드) f (2f+1 노드) f (3f+1 노드) f (2f+1 노드)
비잔틴 장애 ❌ 미지원 ❌ 미지원 ✅ 지원 ❌ 미지원
리더 기반 ✅ 강한 리더 △ 다중 제안자 ✅ Primary ✅ 리더
지연 시간 1 RTT (리더→팔로워) 2 RTT 3 RTT 1 RTT
주요 구현 etcd, Consul, TiKV Chubby, Spanner Tendermint, Hyperledger ZooKeeper

Raft 클러스터 크기별 특성

노드 수 과반수 (Quorum) 허용 장애 수 권장 사용 사례
3 2 1 개발/테스트, 소규모 프로덕션
5 3 2 프로덕션 권장
7 4 3 글로벌 분산, 고가용성

실무 팁: 프로덕션에서는 5노드 클러스터를 권장합니다. 3노드는 1개 장애만 허용하므로 롤링 업데이트 중 추가 장애 발생 시 서비스가 중단됩니다.

🗣️ 실무 대화 예시

💬 Kubernetes 운영 회의에서

"etcd 클러스터가 3노드인데, 5노드로 확장하는 게 좋겠어요. 현재 1개 노드 장애만 허용하는데, 롤링 업데이트 중에 추가 장애가 발생하면 Raft 과반수를 잃어서 클러스터 전체가 읽기 전용이 됩니다."

💬 기술 면접에서

"Raft에서 Split Brain 방지 원리요? 과반수(Quorum) 투표입니다. 네트워크 파티션이 발생해도 과반수를 확보한 파티션만 Leader를 선출할 수 있어요. 소수 파티션의 기존 Leader는 Heartbeat 응답을 못 받아서 Follower로 강등됩니다."

💬 장애 분석 회의에서

"etcd 지연이 급증한 원인은 디스크 I/O 병목이에요. Raft는 커밋 전에 WAL(Write-Ahead Log)을 디스크에 fsync해야 하는데, 느린 디스크에서는 Leader가 Follower 응답을 기다리다 타임아웃이 발생합니다. NVMe SSD로 교체하거나 etcd 전용 디스크를 할당해야 해요."

⚠️ 주의사항

짝수 노드 클러스터: 4노드 클러스터는 3노드와 장애 허용이 같습니다(1개). 추가 비용만 들고 이점이 없으므로 홀수(3, 5, 7)로 구성하세요.

느린 디스크: Raft는 커밋 전 WAL fsync가 필수입니다. 느린 디스크는 Leader 선출 실패, 지연 증가, 클러스터 불안정의 원인이 됩니다. SSD 필수.

네트워크 지연 불균형: Raft Leader는 가장 빠른 과반수 응답을 기다립니다. 노드 간 지연 편차가 크면 일부 노드가 지속적으로 뒤처져 Leader 선출이 불안정해집니다.

올바른 방법: etcd는 etcdctl endpoint health로 클러스터 상태를 모니터링하고, Raft 인덱스 차이가 큰 Follower는 스냅샷으로 동기화하세요.

🔗 관련 용어

📚 더 배우기