본문으로 건너뛰기
Split-Brain 프로덕션 디버깅 가이드 - Distributed Systems

# Split-Brain 프로덕션 완벽 해결 가이드: 분산 시스템에서 두 개의 리더가 동시에 존재할 때 데이터 충돌 방지하기

Table of Contents

한 클러스터에 두 명의 리더: Split-Brain의 악몽

NVIDIA AIStore의 충격적인 고백 (2025년 2월):

NVIDIA의 분산 스토리지 시스템 AIStore 팀이 블로그를 통해 충격적인 고백을 했습니다: “Split-brain is Inevitable” (Split-Brain은 피할 수 없다). 그들의 v3.26 릴리스에서 발견된 Split-Brain 버그의 근본 원인은 놀랍게도 단순한 net.ipv4.tcp_mem 설정 오류였습니다. 이 작은 설정 하나가 전체 클러스터를 두 개의 독립된 리더를 가진 분리된 그룹으로 분할시켰습니다.

Split-Brain이 초래하는 재앙:

정상 상태:
Cluster → 1 Leader → 모든 노드 동기화 → 일관된 데이터

Split-Brain 상태:
Cluster → Network Partition → Group A (Leader 1) ← 독립적 쓰기
 → Group B (Leader 2) ← 독립적 쓰기
 → 재연결 시 데이터 충돌! (Merge 불가능)

실제 피해 사례:

  • 주문 중복: 같은 주문이 두 파티션에서 독립적으로 처리 → 이중 결제
  • 재고 불일치: 파티션 A는 재고 10개 차감, 파티션 B는 재고 15개 차감 → 초과 판매
  • 데이터 손실: 재연결 시 한쪽 파티션의 데이터 폐기 → 영구 손실
  • 보안 침해: 파티션 A는 사용자 차단, 파티션 B는 여전히 접근 허용 → 무단 접근

왜 Split-Brain이 발생하는가?

분산 시스템에서 네트워크는 언제나 불안정합니다. 다음과 같은 상황에서 Split-Brain이 발생합니다:

  1. 네트워크 파티션: 데이터센터 간 연결 끊김, 스위치 장애
  2. 긴 GC Pause: Java 애플리케이션의 Full GC로 노드가 30초간 응답 불가
  3. 설정 오류: NVIDIA 사례처럼 TCP 메모리 설정 오류
  4. Clock Skew: 노드 간 시간 불일치로 타임아웃 판단 오류
  5. 하드웨어 장애: NIC 장애, 디스크 I/O 정체

2025년 주요 분산 시스템의 Split-Brain 위험:

  • Elasticsearch: 클러스터 분할 시 두 개의 Master 노드 선출
  • Redis Cluster: 네트워크 파티션으로 두 Master가 같은 슬롯 처리
  • Kafka: Controller 분리로 두 개의 Leader Broker 존재
  • Etcd/Consul: Raft 클러스터 분할 시 독립된 리더 선출
  • PostgreSQL Patroni: Failover 과정에서 Old Primary와 New Primary 동시 존재

핵심 해결 전략 (2025년 표준):

  1. Quorum 기반 의사결정: 과반수 합의 없이는 쓰기 불가
  2. Raft/Paxos Consensus: 알고리즘적으로 단일 리더 보장
  3. STONITH Fencing: “Shoot The Other Node In The Head” - 의심되는 노드 강제 종료
  4. Generation/Epoch Number: 리더의 세대 번호로 구 리더 거부
  5. Witness Node: 홀수 노드로 Quorum 구성

이 글에서는 Split-Brain의 근본 원인, 실제 디버깅 사례, 2025년 최신 방지 전략을 다룹니다.

Split-Brain 기본 개념

Split-Brain이란?

Definition: 분산 시스템이 네트워크 장애로 인해 두 개 이상의 독립적인 그룹으로 분할되고, 각 그룹이 자신이 유일한 정상 클러스터라고 판단하여 독립적으로 쓰기를 수행하는 현상

메커니즘:

Step 1: 정상 클러스터
┌─────────────────────────────────┐
│ Leader: Node 1 │
│ Followers: Node 2, Node 3 │
│ 모든 쓰기가 Node 1을 통해 처리 │
└─────────────────────────────────┘

Step 2: 네트워크 파티션 발생
┌──────────────┐ ️ Network ┌──────────────┐
│ Partition A │ Partition │ Partition B │
│ Node 1 │ ←──────────→ │ Node 2 │
│ │ 연결 끊김! │ Node 3 │
└──────────────┘ └──────────────┘

Step 3: Split-Brain 발생
┌──────────────┐ ┌──────────────┐
│ Partition A │ │ Partition B │
│ Leader: Node1│ │ Leader: Node2│ ← 새 리더 선출!
│ "나만 살아있음"│ │ "Node 1 죽음" │
│ 쓰기 계속 처리│ │ 쓰기 계속 처리│
└──────────────┘ └──────────────┘
 ↓ ↓
 데이터 A 데이터 B
 (독립적) (독립적)

Step 4: 재연결 시 충돌
┌─────────────────────────────────┐
│ 데이터 A와 데이터 B가 충돌! │
│ 어느 것이 정답? │
│ Merge 불가능한 경우 많음 │
└─────────────────────────────────┘

Split-Brain vs 일반 장애

일반 노드 장애:

Before:
Node 1 (Leader), Node 2, Node 3

After (Node 3 죽음):
Node 1 (Leader), Node 2 ← 여전히 과반수 (2/3)
정상 작동 계속

Split-Brain:

Before:
Node 1, Node 2, Node 3

After (Network Partition):
Group A: Node 1 ← 1/3 (과반수 아님, 하지만 자신만 본다)
Group B: Node 2, Node 3 ← 2/3 (과반수)

문제: Group A도 자신이 유일하다고 착각하면?
→ 두 그룹 모두 독립적으로 쓰기!

왜 위험한가?

데이터 충돌 시나리오:

// Split-Brain 상황에서의 데이터 충돌

// Partition A에서:
// User가 Product 1 주문 (재고 10 → 9)
db.Exec("UPDATE products SET stock = stock - 1 WHERE id = 1")
// stock = 9

// Partition B에서 (동시에):
// 다른 User가 Product 1 주문 (재고 10 → 9)
db.Exec("UPDATE products SET stock = stock - 1 WHERE id = 1")
// stock = 9

// 재연결 후:
// 실제로는 2개 팔렸는데 stock = 9!
// 정답은 stock = 8이어야 함
// 1개 초과 판매 위험!

은행 시스템에서의 치명적 시나리오:

Initial Balance: $1,000

Partition A: 출금 $500 → Balance = $500
Partition B: 출금 $800 → Balance = $200

재연결 후:
- Last Write Wins 전략 사용 시: Balance = $200 (Partition A의 출금 무시!)
- 실제 정답: Balance = -$300 (초과 출금 발생!)
- 어느 쪽이든 틀림!

NVIDIA AIStore 실제 사례 (2025년)

사건 개요

배경:

  • 제품: AIStore (NVIDIA의 대용량 분산 스토리지)
  • 시기: 2025년 2월 블로그 공개
  • 원인: net.ipv4.tcp_mem 커널 파라미터 설정 오류

증상:

# 클러스터 상태 확인
ais cluster show

# 출력:
# Cluster Map:
# Group A: Node1 (Primary), Node2, Node3 ← "나는 Primary"
# Group B: Node4 (Primary), Node5, Node6 ← "나도 Primary!"

# 두 개의 Primary가 동시에 존재!

근본 원인 분석

TCP 메모리 고갈:

# 잘못된 설정
net.ipv4.tcp_mem = "184320 245760 368640" # 너무 낮음!

# 이 설정으로 인해:
# 1. 높은 네트워크 트래픽 시 TCP 버퍼 고갈
# 2. Keepalive 패킷 전송 실패
# 3. 노드 간 연결이 끊어진 것으로 판단
# 4. 각 그룹이 독립적으로 새 Primary 선출

타임라인:

T+0s: 정상 클러스터 (1 Primary)
T+10s: 높은 네트워크 트래픽 발생
T+15s: TCP 메모리 고갈, Keepalive 실패
T+20s: Node1-Node4 간 연결 타임아웃
T+25s: Group A와 Group B 분리
T+30s: Group B가 새 Primary 선출 (Node4)
T+35s: Split-Brain 상태! (Node1 + Node4 모두 Primary)

AIStore의 해결책

v3.26 릴리스: Ex Post Facto Reunification

// AIStore의 재통합 전략

type ClusterMap struct {
 Generation int // 세대 번호
 Primary NodeID // Primary 노드
 Nodes []NodeID // 클러스터 멤버
}

func reunifyCluster(partitionA, partitionB ClusterMap) {
 // 1. Generation 비교
 if partitionA.Generation > partitionB.Generation {
 // Partition A가 최신 → B를 폐기
 discardPartition(partitionB)
 return partitionA
 }

 // 2. Generation 같으면 Node ID 비교 (결정론적)
 if partitionA.Primary < partitionB.Primary {
 discardPartition(partitionB)
 return partitionA
 }

 discardPartition(partitionA)
 return partitionB
}

문제점:

️ 재통합 시 한쪽 파티션의 데이터 손실!
- Partition B가 폐기되면
- Partition B에서 발생한 모든 쓰기가 사라짐
- 일부 데이터 손실은 불가피

Quorum 기반 Split-Brain 방지

Quorum이란?

Definition: 과반수(Majority) 합의를 요구하여, 네트워크 파티션 발생 시 과반수를 차지한 그룹만 작동하도록 하는 메커니즘

기본 원리:

클러스터: 5개 노드
Quorum: 3개 (5 / 2 + 1)

정상 상태:
Node 1, Node 2, Node 3, Node 4, Node 5
쓰기 요청 → 최소 3개 노드가 응답해야 성공

Network Partition:
Group A: Node 1, Node 2 ← 2개 (Quorum 미달!)
Group B: Node 3, Node 4, Node 5 ← 3개 (Quorum 달성!)

결과:
Group A: 쓰기 거부 (Quorum 없음)
Group B: 쓰기 계속 (Quorum 있음)

 Split-Brain 방지! (한쪽만 작동)

Elasticsearch의 Quorum 설정

elasticsearch.yml:

# Split-Brain 방지 설정

# Cluster 구성
cluster.name: production-cluster
node.name: node-1

# Discovery 설정
discovery.seed_hosts:
 - node-1.example.com
 - node-2.example.com
 - node-3.example.com
 - node-4.example.com
 - node-5.example.com

# Quorum 설정 (핵심!)
cluster.initial_master_nodes:
 - node-1
 - node-2
 - node-3
 - node-4
 - node-5

# Master 선출을 위한 최소 노드 수
discovery.zen.minimum_master_nodes: 3 # (5 / 2) + 1

# 이 설정으로:
# - 3개 미만의 Master-eligible 노드는 Master 선출 불가
# - Network Partition 시 과반수 그룹만 작동

동작 방식:

5 노드 클러스터, minimum_master_nodes = 3

Scenario 1: 2-3 분할
Group A (2 nodes): Master 선출 불가 → Read-Only 모드
Group B (3 nodes): Master 선출 가능 → 정상 작동

Scenario 2: 1-4 분할
Group A (1 node): Master 선출 불가 → Read-Only
Group B (4 nodes): Master 선출 가능 → 정상 작동

Scenario 3: 3-2 분할
Group A (3 nodes): Master 선출 가능 → 정상 작동
Group B (2 nodes): Master 선출 불가 → Read-Only

 모든 경우에 최대 1개 그룹만 쓰기 가능!

Redis Cluster의 Quorum

redis.txt:

# Redis Cluster Quorum 설정

# Cluster 활성화
cluster-enabled yes
cluster-txtig-file nodes.txt
cluster-node-timeout 5000

# Replica가 Master로 승격되기 위한 최소 Master 수
cluster-replica-validity-factor 10

# Cluster 구성 (3 Master + 3 Replica)
# Master 1: Node A
# Master 2: Node B
# Master 3: Node C
# Replica 1: Node D (Master A의 Replica)
# Replica 2: Node E (Master B의 Replica)
# Replica 3: Node F (Master C의 Replica)

Failover with Quorum:

# Redis Cluster의 Quorum 기반 Failover

# 상황: Master A 죽음

# Replica D가 승격을 시도:
# 1. 다른 Master들(B, C)에게 투표 요청
# 2. 과반수(2개) 동의 필요 (총 Master 3개)
# 3. B와 C가 동의 → Replica D가 새 Master로 승격

# Network Partition 발생:
# Group 1: Master A, Replica D
# Group 2: Master B, Master C, Replica E, Replica F

# Master A 죽음:
# Group 1: Replica D만 남음
# - 투표 요청하지만 다른 Master 없음 (Quorum 미달)
# - 승격 불가!

# Group 2: Master B, C 생존
# - 정상 작동 계속
# - 과반수 Master 존재

# Split-Brain 방지!

Raft Consensus Algorithm

Raft 기본 원리

Raft의 핵심 보장:

  1. 단일 리더: 한 Term에 최대 1개의 Leader만 존재
  2. 과반수 합의: 모든 결정은 과반수 투표 필요
  3. Term (임기): 리더의 세대 번호, 높은 Term이 우선

노드 상태:

Follower: 평소 상태, Leader로부터 명령 수신
Candidate: Leader 선출 시도 중
Leader: 클러스터의 리더, 모든 쓰기 처리

Leader 선출 과정:

Step 1: Follower → Candidate
- Election Timeout (150-300ms) 내에 Heartbeat 없으면
- Term 증가, 자신에게 투표, 다른 노드에 투표 요청

Step 2: 투표 수집
- 과반수 투표 받으면 Leader로 승격
- 과반수 못 받으면 다시 Follower

Step 3: Leader 작동
- 주기적으로 Heartbeat 전송 (Append Entries RPC)
- 모든 쓰기 처리, 과반수 복제 확인 후 커밋

Raft가 Split-Brain을 방지하는 방법

Scenario: Network Partition (3-2 분할)

5 노드 클러스터: Node 1 (Leader), Node 2, 3, 4, 5

Network Partition:
Group A: Node 1, Node 2 ← 2개 (과반수 아님)
Group B: Node 3, Node 4, Node 5 ← 3개 (과반수!)

Group A (Node 1 - 기존 Leader):
- Heartbeat를 Node 3, 4, 5에 전송 시도
- 응답 없음 (Network Partition)
- 과반수 응답 없으면 Leader 자격 상실!
- Follower로 강등 (Step-down)
- 쓰기 거부!

Group B (Node 3, 4, 5):
- Election Timeout 도래
- Node 3이 Candidate가 됨, Term 증가 (Term 2)
- Node 4, 5에게 투표 요청
- 2표 획득 (과반수 3/5 달성!)
- Node 3이 새 Leader 선출 (Term 2)
- 쓰기 계속 처리

재연결 시:
- Node 1 (Term 1, Old Leader)
- Node 3 (Term 2, New Leader)
- Node 1이 Node 3의 Heartbeat 수신
- Term 2 > Term 1 → Node 1이 Node 3를 Leader로 인정
- 자동 재통합!

코드 예시 (etcd - Raft 구현):

// etcd의 Raft Leader Election

type Raft struct {
 term int // 현재 Term
 state NodeState // Follower, Candidate, Leader
 votedFor NodeID // 투표한 후보
 leader NodeID // 현재 리더
 votes int // 받은 표 수
}

func (r *Raft) startElection() {
 // 1. Term 증가
 r.term++
 r.state = Candidate
 r.votedFor = r.id
 r.votes = 1 // 자신에게 투표

 // 2. 모든 노드에게 투표 요청
 for _, peer := range r.peers {
 go func(p Node) {
 resp := p.RequestVote(r.term, r.id)

 if resp.VoteGranted {
 r.votes++

 // 3. 과반수 획득?
 if r.votes > len(r.peers)/2 {
 r.becomeLeader()
 }
 }
 }(peer)
 }
}

func (r *Raft) becomeLeader() {
 r.state = Leader
 r.leader = r.id

 // 4. Heartbeat 전송 시작
 go r.sendHeartbeats()
}

func (r *Raft) sendHeartbeats() {
 for r.state == Leader {
 successCount := 0

 for _, peer := range r.peers {
 if peer.AppendEntries(r.term, r.id) {
 successCount++
 }
 }

 // 과반수 응답 없으면 Leader 자격 상실!
 if successCount <= len(r.peers)/2 {
 r.stepDown() // Follower로 강등
 return
 }

 time.Sleep(50 * time.Millisecond) // Heartbeat interval
 }
}

etcd 클러스터 설정 예시

docker-compose.yml:

version: '3'
services:
 etcd-1:
 image: quay.io/coreos/etcd:v3.5.10
 command:
 - /usr/local/bin/etcd
 - --name=etcd-1
 - --initial-advertise-peer-urls=http://etcd-1:2380
 - --listen-peer-urls=http://0.0.0.0:2380
 - --advertise-client-urls=http://etcd-1:2379
 - --listen-client-urls=http://0.0.0.0:2379
 - --initial-cluster=etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380,etcd-3=http://etcd-3:2380
 - --initial-cluster-state=new
 - --initial-cluster-token=etcd-cluster

 etcd-2:
 image: quay.io/coreos/etcd:v3.5.10
 command:
 - /usr/local/bin/etcd
 - --name=etcd-2
 - --initial-advertise-peer-urls=http://etcd-2:2380
 - --listen-peer-urls=http://0.0.0.0:2380
 - --advertise-client-urls=http://etcd-2:2379
 - --listen-client-urls=http://0.0.0.0:2379
 - --initial-cluster=etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380,etcd-3=http://etcd-3:2380
 - --initial-cluster-state=new
 - --initial-cluster-token=etcd-cluster

 etcd-3:
 image: quay.io/coreos/etcd:v3.5.10
 command:
 - /usr/local/bin/etcd
 - --name=etcd-3
 - --initial-advertise-peer-urls=http://etcd-3:2380
 - --listen-peer-urls=http://0.0.0.0:2380
 - --advertise-client-urls=http://etcd-3:2379
 - --listen-client-urls=http://0.0.0.0:2379
 - --initial-cluster=etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380,etcd-3=http://etcd-3:2380
 - --initial-cluster-state=new
 - --initial-cluster-token=etcd-cluster

클러스터 상태 확인:

# Leader 확인
etcdctl --endpoints=http://etcd-1:2379 endpoint status --cluster -w table

# 출력:
# +-------------------+------------------+---------+---------+-----------+
# | ENDPOINT | ID | VERSION | LEADER | RAFT TERM |
# +-------------------+------------------+---------+---------+-----------+
# | http://etcd-1:2379| 8e9e05c52164694d | 3.5.10 | false | 2 |
# | http://etcd-2:2379| 91bc3c398fb3c146 | 3.5.10 | false | 2 |
# | http://etcd-3:2379| fd422379fda50e48 | 3.5.10 | true | 2 | ← Leader
# +-------------------+------------------+---------+---------+-----------+

STONITH Fencing (강제 격리)

STONITH란?

“Shoot The Other Node In The Head”

Definition: Split-Brain 의심 시 물리적으로 노드를 강제 종료하여 데이터 충돌을 방지하는 메커니즘

동작 원리:

정상 클러스터:
Node 1 (Primary), Node 2 (Standby)

네트워크 장애:
Node 2가 Node 1의 Heartbeat를 받지 못함

Node 2의 판단:
"Node 1이 죽었나? 내가 Primary가 되어야 하나?"

️ 위험 상황:
- 실제로는 Node 1이 살아있을 수 있음 (네트워크만 끊김)
- Node 2가 Primary로 승격하면 Split-Brain!

 STONITH 작동:
Node 2: "Node 1을 확실히 죽이자!"
 → IPMI/iLO를 통해 Node 1의 전원 강제 OFF
 → Node 1 완전히 종료 확인
 → Node 2가 안전하게 Primary로 승격

IPMI를 이용한 Fencing

IPMI (Intelligent Platform Management Interface):

# IPMI로 원격 노드 전원 제어

# Node 상태 확인
ipmitool -I lanplus -H node1-ipmi.example.com -U admin -P password power status

# 출력:
# Chassis Power is on

# Node 강제 종료 (STONITH!)
ipmitool -I lanplus -H node1-ipmi.example.com -U admin -P password power off

# 출력:
# Chassis Power Control: Down/Off

# Node 재시작
ipmitool -I lanplus -H node1-ipmi.example.com -U admin -P password power on

Pacemaker + STONITH 설정

Pacemaker Cluster 구성:

# Pacemaker 설치 (Ubuntu)
apt-get install pacemaker pcs fence-agents

# Cluster 생성
pcs cluster auth node1 node2 -u hacluster -p password
pcs cluster setup --name ha-cluster node1 node2
pcs cluster start --all

# STONITH 활성화 (매우 중요!)
pcs property set stonith-enabled=true

# IPMI Fence Agent 설정
pcs stonith create fence-node1 fence_ipmilan \
 pcmk_host_list="node1" \
 ipaddr="node1-ipmi.example.com" \
 login="admin" \
 passwd="password" \
 lanplus=1

pcs stonith create fence-node2 fence_ipmilan \
 pcmk_host_list="node2" \
 ipaddr="node2-ipmi.example.com" \
 login="admin" \
 passwd="password" \
 lanplus=1

# Fence 테스트
pcs stonith fence node2

# 출력:
# Node: node2 fenced
# (node2가 강제 종료됨!)

Split-Brain 방지 시나리오:

Cluster: node1 (Primary), node2 (Standby)

T+0s: 네트워크 파티션 발생
T+5s: node2가 node1의 Heartbeat 수신 실패
T+10s: node2가 Fence 결정
 "node1을 죽이고 내가 Primary가 되자"
T+15s: node2 → IPMI 명령 → node1 강제 종료
T+20s: node1 전원 OFF 확인
T+25s: node2가 Primary로 승격
T+30s: 단일 Primary 보장! (node1은 죽음)

재연결 시:
T+60s: 관리자가 node1 수동 재시작
T+65s: node1이 Standby로 재참여
T+70s: 정상 클러스터 복구

PostgreSQL Patroni + STONITH

Patroni 설정 (patroni.yml):

# Patroni (PostgreSQL HA) + Watchdog

scope: postgres-cluster
name: node1

restapi:
 listen: 0.0.0.0:8008
 connect_address: node1:8008

etcd:
 hosts: etcd1:2379,etcd2:2379,etcd3:2379

bootstrap:
 dcs:
 ttl: 30
 loop_wait: 10
 retry_timeout: 10
 maximum_lag_on_failover: 1048576

watchdog:
 mode: required # Watchdog 강제!
 device: /dev/watchdog
 safety_margin: 5

postgresql:
 listen: 0.0.0.0:5432
 connect_address: node1:5432
 data_dir: /var/lib/postgresql/14/main
 authentication:
 replication:
 username: replicator
 password: rep_password
 superuser:
 username: postgres
 password: postgres_password

Watchdog 동작:

# Linux Watchdog 설정

# Watchdog 장치 확인
ls -l /dev/watchdog
# crw------- 1 root root 10, 130 Nov 6 10:00 /dev/watchdog

# Watchdog 모듈 로드
modprobe softdog

# Patroni가 Watchdog에 주기적으로 "kick" (I'm alive!)
# 만약 Patroni가 죽거나 응답 없으면:
# → Watchdog이 5초 후 자동으로 시스템 리부트!
# → Split-Brain 방지 (죽은 Primary가 계속 쓰기 못 함)

Generation/Epoch Number 전략

Generation Number란?

Definition: 리더의 세대 번호를 사용하여, 재연결 시 낮은 세대의 리더를 거부하는 메커니즘

기본 원리:

초기 상태:
Leader: Node 1 (Generation 1)

Network Partition:
Group A: Node 1 (Generation 1 - Old Leader)
Group B: Node 2, 3 (새 Leader 선출 → Generation 2)

재연결 시:
Node 1 (Generation 1): "나는 Leader다!"
Node 2 (Generation 2): "너의 Generation이 낮다. 거부!"

 Node 1이 자동으로 Follower로 강등
 Node 2가 유일한 Leader

Kafka의 Epoch Number

Kafka Controller Epoch:

# Kafka Cluster 구성
# Broker 1 (Controller, Epoch 10)
# Broker 2, Broker 3

# ZooKeeper에 저장된 Controller Epoch
zkCli.sh
get /controller_epoch

# 출력:
# {"version":1,"brokerid":1,"timestamp":"1699305600000","epoch":10}

# Network Partition 발생
# Group A: Broker 1 (Epoch 10)
# Group B: Broker 2, 3

# Group B가 새 Controller 선출 (Broker 2)
# Epoch 증가: 10 → 11
set /controller_epoch {"version":1,"brokerid":2,"timestamp":"1699305700000","epoch":11}

# 재연결 시:
# Broker 1 (Epoch 10): "나는 Controller"
# Broker 2 (Epoch 11): "Epoch 10 < 11, 거부!"
# Broker 1 강제 Follower로 전환

Kafka Producer 코드:

// Kafka Producer가 Epoch를 통해 Leader 검증

Properties props = new Properties();
props.put("bootstrap.servers", "broker1:9092,broker2:9092,broker3:9092");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("acks", "all"); // 모든 Replica 확인
props.put("enable.idempotence", "true"); // Exactly-Once

KafkaProducer<String, String> producer = new KafkaProducer<>(props);

ProducerRecord<String, String> record = new ProducerRecord<>("my-topic", "key", "value");

try {
 // Producer가 Leader에게 전송
 // Leader는 응답에 Epoch 포함
 RecordMetadata metadata = producer.send(record).get();

 // 만약 Old Leader (낮은 Epoch)에게 전송 시도하면:
 // NotLeaderForPartitionException 발생!
 // Producer가 자동으로 새 Leader 발견 및 재전송

} catch (NotLeaderForPartitionException e) {
 // Old Leader 감지 → Metadata 갱신
 producer.send(record).get(); // 재시도
}

MongoDB Replica Set의 Term

MongoDB Replica Set:

// MongoDB의 Term (Raft의 Term과 유사)

// Replica Set 상태 확인
rs.status()

// 출력:
{
 "set": "rs0",
 "members": [
 {
 "_id": 0,
 "name": "mongo1:27017",
 "stateStr": "PRIMARY",
 "electionTime": Timestamp(1699305600, 1),
 "electionDate": ISODate("2025-11-06T10:00:00Z"),
 "term": NumberLong("5") // ← Term 5
 },
 {
 "_id": 1,
 "name": "mongo2:27017",
 "stateStr": "SECONDARY",
 "syncSourceHost": "mongo1:27017",
 "term": NumberLong("5")
 }
 ]
}

// Network Partition:
// Group A: mongo1 (Term 5, Primary)
// Group B: mongo2, mongo3 (새 Primary 선출 → Term 6)

// 재연결 시:
// mongo1 (Term 5) → mongo2로부터 Heartbeat 수신
// Heartbeat에 Term 6 포함
// mongo1: "Term 6 > Term 5, 나는 더 이상 Primary 아님"
// mongo1이 자동으로 Secondary로 강등

// Split-Brain 방지!

실전 디버깅 사례

사례 1: Elasticsearch Split-Brain (3-2 파티션)

증상:

# 클러스터 상태 확인
curl -X GET "http://es-node1:9200/_cluster/health?pretty"

# 출력:
{
 "cluster_name" : "production",
 "status" : "red", # RED 상태!
 "number_of_nodes" : 2, # 5개 중 2개만 보임
 "active_primary_shards" : 150,
 "active_shards" : 300,
 "unassigned_shards" : 150 # 절반이 Unassigned!
}

# 다른 파티션 확인
curl -X GET "http://es-node3:9200/_cluster/health?pretty"

# 출력:
{
 "cluster_name" : "production",
 "status" : "yellow",
 "number_of_nodes" : 3, # 5개 중 3개만 보임
 "active_primary_shards" : 300,
 "active_shards" : 600
}

# Split-Brain 발생!
# Group A (node1, node2): 2개 노드, Master = node1
# Group B (node3, node4, node5): 3개 노드, Master = node3

원인:

# 잘못된 설정 (elasticsearch.yml)

# Group A와 Group B 모두:
discovery.zen.minimum_master_nodes: 2 # ← 너무 낮음!

# 5개 노드 클러스터에서 minimum_master_nodes = 2이면:
# Group A (2개): Master 선출 가능 (2 >= 2)
# Group B (3개): Master 선출 가능 (3 >= 2)
# 두 그룹 모두 독립적으로 Master 선출!

** 수정:**

# 올바른 설정

# 5개 노드 클러스터
discovery.zen.minimum_master_nodes: 3 # (5 / 2) + 1

# 이제:
# Group A (2개): Master 선출 불가 (2 < 3)
# Group B (3개): Master 선출 가능 (3 >= 3)
# 한쪽만 작동!

복구:

# 1. 모든 노드 종료
systemctl stop elasticsearch

# 2. 설정 수정
vim /etc/elasticsearch/elasticsearch.yml
# discovery.zen.minimum_master_nodes: 3

# 3. 모든 노드 재시작
systemctl start elasticsearch

# 4. 클러스터 상태 확인
curl -X GET "http://localhost:9200/_cluster/health?pretty"

# 출력:
{
 "cluster_name" : "production",
 "status" : "green", # GREEN!
 "number_of_nodes" : 5, # 5개 노드 모두 복구
 "active_primary_shards" : 300,
 "active_shards" : 600,
 "unassigned_shards" : 0
}

사례 2: Redis Cluster Split-Brain

증상:

# Redis Cluster 상태 확인
redis-cli --cluster check redis-node1:6379

# 출력:
>>> Performing Cluster Check (using node redis-node1:6379)
M: abc123... redis-node1:6379
 slots:0-5460 (5461 slots) master
 1 additional replica(s)

S: def456... redis-node2:6379
 slots: (0 slots) slave
 replicates abc123...

# 다른 Master가 안 보임!

# 다른 파티션 확인
redis-cli -h redis-node3 --cluster check redis-node3:6379

# 출력:
>>> Performing Cluster Check (using node redis-node3:6379)
M: ghi789... redis-node3:6379
 slots:0-5460 (5461 slots) master # 같은 슬롯!
 1 additional replica(s)

# Split-Brain!
# node1과 node3이 모두 slot 0-5460을 처리 중!

데이터 충돌:

# Partition A (node1):
redis-cli -h redis-node1 SET user:1000:name "Alice"
# OK

# Partition B (node3):
redis-cli -h redis-node3 SET user:1000:name "Bob"
# OK

# 재연결 후:
redis-cli -h redis-node1 GET user:1000:name
# "Alice"

redis-cli -h redis-node3 GET user:1000:name
# "Bob"

# 데이터 충돌! 어느 것이 정답?

** 해결: Cluster Reunification**

# 1. 클러스터 상태 진단
redis-cli --cluster fix redis-node1:6379

# 출력:
>>> Fixing open slot 0-5460
# Slot 0-5460 assigned to multiple nodes!
# Node abc123... (redis-node1:6379)
# Node ghi789... (redis-node3:6379)

# 2. Manual Fix
redis-cli -h redis-node1 CLUSTER SETSLOT 0 NODE abc123...
redis-cli -h redis-node1 CLUSTER SETSLOT 1 NODE abc123...
#... (모든 충돌 슬롯 수동 할당)

# 3. Cluster 재구성
redis-cli --cluster reshard redis-node1:6379

# 4. 확인
redis-cli --cluster check redis-node1:6379

# 출력:
>>> Performing Cluster Check (using node redis-node1:6379)
# [OK] All nodes agree about slots txtiguration.
# [OK] All 16384 slots covered.
# Split-Brain 해결!

방지 전략:

# redis.txt

# Cluster Quorum 설정
cluster-node-timeout 5000 # 5초

# Replica가 Master로 승격되기 위한 조건
cluster-replica-validity-factor 10

# Minimum Master 수 설정 (Replica 승격 시)
cluster-require-full-coverage yes

# 동작:
# - Replica가 승격하려면 다른 Master와 통신 가능해야 함
# - 네트워크 파티션 시 과반수 없는 그룹은 승격 불가

프로덕션 체크리스트

Split-Brain 방지 설정

# Elasticsearch

discovery.zen.minimum_master_nodes: (N / 2) + 1 # N = 총 Master-eligible 노드 수
cluster.initial_master_nodes:
 - node-1
 - node-2
 - node-3
 - node-4
 - node-5

# etcd (Raft)

--initial-cluster=etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380,etcd-3=http://etcd-3:2380
--initial-cluster-state=new
--heartbeat-interval=100 # 100ms
--election-timeout=1000 # 1000ms

# Redis Cluster

cluster-enabled yes
cluster-node-timeout 5000
cluster-require-full-coverage yes

# PostgreSQL Patroni

watchdog:
 mode: required
 device: /dev/watchdog
 safety_margin: 5

dcs:
 ttl: 30
 loop_wait: 10
 maximum_lag_on_failover: 1048576

# Kafka

# ZooKeeper 기반 (기존)
zookeeper.connect=zk1:2181,zk2:2181,zk3:2181
controller.socket.timeout.ms=30000

# KRaft 기반 (신규)
process.roles=broker,controller
controller.quorum.voters=1@kafka1:9093,2@kafka2:9093,3@kafka3:9093

모니터링 설정

Prometheus Alerts:

groups:
 - name: split_brain_detection
 interval: 30s
 rules:
 # Elasticsearch Multiple Masters
 - alert: ElasticsearchMultipleMasters
 expr: count(elasticsearch_cluster_health_status{role="master"}) > 1
 for: 1m
 labels:
 severity: critical
 annotations:
 summary: "Multiple Elasticsearch masters detected - Split-Brain!"

 # etcd Multiple Leaders
 - alert: EtcdMultipleLeaders
 expr: count(etcd_server_is_leader == 1) > 1
 for: 1m
 labels:
 severity: critical
 annotations:
 summary: "Multiple etcd leaders - Split-Brain!"

 # Redis Cluster Slot txtlict
 - alert: RedisClusterSlottxtlict
 expr: redis_cluster_slots_fail > 0
 for: 5m
 labels:
 severity: warning
 annotations:
 summary: "Redis cluster has failing slots - possible Split-Brain"

 # Patroni Multiple Primaries
 - alert: PatroniMultiplePrimaries
 expr: count(pg_replication_is_replica == 0) > 1
 for: 1m
 labels:
 severity: critical
 annotations:
 summary: "Multiple PostgreSQL primaries - Split-Brain!"

Grafana Dashboard:

{
 "panels": [
 {
 "title": "Cluster Leaders",
 "targets": [
 {
 "expr": "sum(etcd_server_is_leader)",
 "legendFormat": "etcd Leaders"
 },
 {
 "expr": "count(elasticsearch_cluster_health_status{role=\"master\"})",
 "legendFormat": "ES Masters"
 }
 ],
 "thresholds": [
 { "value": 1, "color": "green" },
 { "value": 2, "color": "red" } // Multiple leaders!
 ]
 },
 {
 "title": "Network Partition Events",
 "targets": [
 {
 "expr": "rate(node_network_transmit_drop_total[5m])",
 "legendFormat": "{{ instance }}"
 }
 ]
 },
 {
 "title": "Quorum Status",
 "targets": [
 {
 "expr": "elasticsearch_cluster_health_number_of_nodes",
 "legendFormat": "Active Nodes"
 },
 {
 "expr": "elasticsearch_cluster_minimum_master_nodes",
 "legendFormat": "Quorum Threshold"
 }
 ]
 }
 ]
}

네트워크 테스트

Chaos Engineering (네트워크 파티션 시뮬레이션):

# iptables로 네트워크 파티션 테스트

# node1과 node3 간 연결 차단
iptables -A INPUT -s node3-ip -j DROP
iptables -A OUTPUT -d node3-ip -j DROP

# 클러스터 상태 모니터링
watch -n 1 'curl -s http://localhost:9200/_cluster/health | jq'

# 예상 동작:
# - node1, node2 그룹: Quorum 미달 → Read-Only
# - node3, node4, node5 그룹: Quorum 달성 → 정상 작동

# 30초 후 연결 복구
iptables -D INPUT -s node3-ip -j DROP
iptables -D OUTPUT -d node3-ip -j DROP

# 자동 재통합 확인

tc (Traffic Control)를 이용한 네트워크 지연:

# 네트워크 지연 주입

# eth0에 300ms 지연 추가
tc qdisc add dev eth0 root netem delay 300ms

# Heartbeat 타임아웃 테스트
# (대부분 클러스터는 150-300ms Heartbeat Interval)

# 예상 동작:
# - Heartbeat 실패 → Election Timeout
# - 새 Leader 선출 프로세스 시작

# 지연 제거
tc qdisc del dev eth0 root

마치며

Split-Brain분산 시스템의 가장 위험한 시나리오 중 하나입니다. NVIDIA AIStore 사례처럼 단순한 설정 오류 하나가 전체 클러스터를 두 개의 독립된 그룹으로 분할시킬 수 있습니다. 이 글에서 다룬 핵심 사항들을 정리하면:

핵심 요약:

  1. Quorum 기반 방지: 과반수 합의 없이는 쓰기 불가 (Elasticsearch, Redis)
  2. Raft/Paxos Consensus: 알고리즘적으로 단일 리더 보장 (etcd, Consul)
  3. STONITH Fencing: 의심 노드 강제 종료로 확실한 격리 (Pacemaker)
  4. Generation/Epoch: 세대 번호로 Old Leader 자동 거부 (Kafka, MongoDB)
  5. Witness Node: 홀수 노드 구성으로 Quorum 명확화
  6. Watchdog: 죽은 Primary가 계속 쓰기 못하도록 강제 리부트 (Patroni)
  7. Monitoring: 여러 Leader 동시 감지 즉시 알람

다음 단계:

  • Quorum 설정 전체 검토 ((N / 2) + 1 확인)
  • Raft/Paxos 기반 시스템으로 마이그레이션 검토
  • STONITH/Fencing 메커니즘 설정 (물리 서버 환경)
  • Watchdog 설정 (데이터베이스 HA)
  • Multiple Leader 감지 알람 구성
  • 네트워크 파티션 Chaos Testing 정기 실시
  • Generation/Epoch Number 검증 로직 추가
  • 클러스터 노드 수 홀수로 유지 (3, 5, 7…)

NVIDIA AIStore의 교훈:

“Split-brain is Inevitable” (Split-Brain은 피할 수 없다)

하지만 올바른 Quorum 설정, Consensus 알고리즘, Fencing 메커니즘으로 Split-Brain의 피해는 완전히 방지할 수 있습니다. 단순한 net.ipv4.tcp_mem 설정 하나가 전체 클러스터를 무너뜨릴 수 있듯이, minimum_master_nodes 설정 하나가 Split-Brain을 완벽히 막을 수 있습니다. 지금 바로 클러스터 설정을 점검하고, Quorum이 (N / 2) + 1로 올바르게 설정되어 있는지 확인하세요!

이 글 공유하기:
My avatar

글을 마치며

이 글이 도움이 되었기를 바랍니다. 궁금한 점이나 의견이 있다면 댓글로 남겨주세요.

더 많은 기술 인사이트와 개발 경험을 공유하고 있으니, 다른 포스트도 확인해보세요.

유럽살며 여행하며 코딩하는 노마드의 여정을 함께 나누며, 함께 성장하는 개발자 커뮤니티를 만들어가요! 🚀


관련 포스트

# Thundering Herd: 10,000개 스레드가 동시에 깨어날 때 서버가 멈추는 이유

게시:

여러 스레드가 하나의 이벤트를 기다리다가 동시에 깨어나는 'Thundering Herd' 현상에 대해 알아봅니다. Accept Queue 경합부터 Cache Stampede까지, 시스템 리소스를 낭비하고 성능을 저하시키는 이 고질적인 문제의 원인과 epoll, Jitter 등을 활용한 해결책을 심도 있게 분석합니다.

읽기