1. 문제 정의
마스터-슬레이브 구조의 클러스터에서 모든 하드웨어는 언제든지 장애가 발생할 수 있다고 가정합니다. 마스터 노드가 다운되었을 때, 슬레이브 중 하나를 새로운 마스터로 선출해야 합니다. ZooKeeper를 사용하면 이 리더 선출(Leader Election) 기능을 비교적 간단하게 구현할 수 있습니다. 본 글에서는 일반적인 용어를 사용하여 리더(Leader)와 팔로워(Follower)로 칭합니다.
2. 핵심 이슈 분석
리더 선출 과정은 다음 두 가지 핵심 문제를 해결해야 합니다:
- 누가 리더가 될 것인가? 여러 노드가 동시에 리더가 되려고 할 때, 단 하나의 노드만 리더로 선정되어야 합니다. 이는 마치 멀티스레드 환경에서의 뮤텍스 잠금(Mutex Lock)과 유사합니다.
- 리더가 다운되었을 때 팔로워가 이를 어떻게 감지할 것인가? 리더의 장애를 신속하게 감지하고 새로운 선출 과정을 시작해야 합니다.
첫 번째 문제 해결: ZooKeeper 상의 특정 ZNode(예: /election/leader)를 분산 락으로 간주합니다. 클러스터의 모든 노드는 이 ZNode의 생성을 시도합니다. ZooKeeper의 create() 연산은 원자적(Atomic)이므로, 오직 하나의 노드만 성공적으로 ZNode를 생성할 수 있습니다. 생성에 성공한 노드가 리더가 됩니다. 리더는 이 ZNode에 자신의 호스트명이나 ID 등의 메타데이터를 저장하여, 다른 팔로워들이 누가 리더인지 알 수 있도록 합니다.
두 번째 문제 해결: ZooKeeper의 ZNode는 크게 영구 노드(Persistent Node)와 임시 노드(Ephemeral Node)로 나뉩니다. 임시 노드는 클라이언트의 세션(Session)과 생명 주기가 동일합니다. 클라이언트(리더)가 연결을 종료하거나 세션이 만료되면(예: 장애), ZooKeeper는 해당 임시 노드를 자동으로 삭제합니다. 팔로워들은 리더가 생성한 임시 노드에 Watcher를 등록합니다. 노드가 삭제되면 Watcher가 트리거되어 팔로워들은 리더의 다운을 즉시 감지하고 새로운 선출 과정을 시작할 수 있습니다.
3. 선출 프로세스 상세
- 준비 단계: 클러스터 내 모든 노드는 자신의 상태를
LOOKING(선출 중)으로 설정합니다. - 리더 경쟁:
LOOKING상태의 모든 노드는 ZooKeeper에/election/leader임시 노드 생성을 시도합니다. - 리더 선정:
- 생성에 성공한 노드는 자신의 상태를
LEADER로 변경하고, 필요 정보를 ZNode에 기록합니다. - 생성에 실패한 노드는 상태를
FOLLOWER로 변경합니다. 팔로워는/election/leader노드의 데이터를 읽고, 동시에 해당 노드의 삭제 이벤트를 감지하는 Watcher를 설정합니다.
- 생성에 성공한 노드는 자신의 상태를
- 장애 감지 및 재선출:
- 리더 노드가 다운되면, 리더의 세션이 만료되어
/election/leader임시 노드가 삭제됩니다. - 팔로워에 등록된 Watcher가 이를 감지하고, 모든 팔로워는 다시 상태를
LOOKING으로 변경한 후 2단계로 돌아가 새로운 선출을 진행합니다. - 중요 케이스: 팔로워가 Watcher를 설정하려는 순간, 이미 리더가 다운되어 ZNode가 삭제되었을 수 있습니다. 이 경우, ZooKeeper는
KeeperException.NoNodeException을 발생시킵니다. 팔로워는 이 예외를 포착하여 즉시 새 선출을 시작해야 합니다. 이는getData()호출과 Watcher 설정 사이의 원자성(Atomicity)이 보장되지 않기 때문에 발생할 수 있는 경쟁 조건(Race Condition)입니다.
- 리더 노드가 다운되면, 리더의 세션이 만료되어
4. Java 구현 예제
Node.java - 각 클러스터 노드를 나타내는 클래스입니다.
package com.example.leader;
import org.apache.zookeeper.*;
import java.io.IOException;
public class ClusterNode {
private NodeStatus currentStatus;
private String leaderPath;
private ZooKeeper zkClient;
public enum NodeStatus {
CANDIDATE,
LEADER,
FOLLOWER
}
public ClusterNode(String leadershipNodePath, ZooKeeper zk) {
this.leaderPath = leadershipNodePath;
this.zkClient = zk;
startElection();
}
private void startElection() {
currentStatus = NodeStatus.CANDIDATE;
String nodeId = Thread.currentThread().getName();
try {
// 1. 리더쉽 ZNode 생성을 시도 (임시 노드)
zkClient.create(leaderPath, nodeId.getBytes(),
ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
// 2. 성공 시 LEADER
currentStatus = NodeStatus.LEADER;
System.out.println(nodeId + " - LEADER 선출 성공");
} catch (KeeperException.NodeExistsException e) {
// 3. 실패 시 FOLLOWER
currentStatus = NodeStatus.FOLLOWER;
System.out.println(nodeId + " - FOLLOWER. 현재 리더 정보 조회 중...");
watchLeaderNode();
} catch (KeeperException | InterruptedException e) {
System.err.println("선출 과정 중 오류 발생: " + e.getMessage());
}
}
private void watchLeaderNode() {
try {
// 데이터를 가져오면서 동시에 Watcher 등록
byte[] leaderData = zkClient.getData(leaderPath, event -> {
if (event.getType() == Watcher.Event.EventType.NodeDeleted) {
System.out.println("리더 노드 삭제 감지! 재선출 시작...");
startElection();
}
}, null);
System.out.println(Thread.currentThread().getName() + " - 리더 정보: "
+ new String(leaderData));
} catch (KeeperException.NoNodeException e) {
// 이미 리더가 사라진 경우, 바로 재선출
System.out.println("리더 노드가 이미 존재하지 않음. 재선출 시작");
startElection();
} catch (KeeperException | InterruptedException e) {
System.err.println("리더 감시 설정 중 오류: " + e.getMessage());
}
}
public void shutdown() {
if (zkClient != null) {
try {
zkClient.close();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public NodeStatus getStatus() {
return currentStatus;
}
}
LeaderElectionSimulator.java - 선출 과정을 시뮬레이션하는 테스트 클래스입니다.
package com.example.leader;
import org.apache.zookeeper.ZooKeeper;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class LeaderElectionSimulator {
private static final String LEADER_ZNODE_PATH = "/cluster-leader";
public static void main(String[] args) throws Exception {
CountDownLatch startLatch = new CountDownLatch(1);
AtomicInteger activeNodeCount = new AtomicInteger(0);
int totalNodes = 10;
ZooKeeper zk = new ZooKeeper("localhost:2181", 3000, null);
for (int i = 0; i < totalNodes; i++) {
new Thread(() -> {
try {
startLatch.await(); // 모든 스레드가 한번에 시작되도록 동기화
ClusterNode node = new ClusterNode(LEADER_ZNODE_PATH, zk);
activeNodeCount.incrementAndGet();
// 리더가 30% 확률로 자살하는 시뮬레이션
while (true) {
TimeUnit.MILLISECONDS.sleep(1000);
if (node.getStatus() == ClusterNode.NodeStatus.LEADER
&& ThreadLocalRandom.current().nextDouble() < 0.3) {
System.out.println("======= LEADER " + Thread.currentThread().getName() + " 가 종료됩니다 =======");
node.shutdown();
activeNodeCount.decrementAndGet();
break;
}
}
} catch (Exception e) {
e.printStackTrace();
}
}, "Node-" + i).start();
}
startLatch.countDown(); // 모든 스레드 시작
// 메인 스레드가 종료되지 않도록 유지
Thread.currentThread().join();
}
}
5. 시뮬레이션 결과 및 고려사항
위 시뮬레이션의 전형적인 출력 예시는 다음과 같습니다 (리더가 변경될 때마다 메시지가 출력됨):
Node-4 - LEADER 선출 성공 Node-3 - FOLLOWER. 현재 리더 정보 조회 중... Node-9 - FOLLOWER. 현재 리더 정보 조회 중... Node-0 - FOLLOWER. 현재 리더 정보 조회 중... ======= LEADER Node-4 가 종료됩니다 ======= 리더 노드 삭제 감지! 재선출 시작... Node-6 - LEADER 선출 성공 Node-1 - FOLLOWER. 현재 리더 정보 조회 중... ...
추가 고려사항: 일부 구현에서는 순서 임시 노드(Sequential Ephemeral Node)를 사용하여 오직 가장 순번이 작은 노드만이 리더가 되도록 제한할 수 있습니다. 이는 분산 락에서 사용되는 패턴입니다. 하지만, 리더 선출 시나리오에서는 리더가 다운되었을 때 모든 팔로워가 이 사실을 감지하고 리더 변경 로직을 실행해야 합니다. 모든 노드가 Watcher를 통해 이벤트를 받아야 하므로, 순차 노드를 사용하여 하나의 노드만 깨우는 방식은 이 요구사항에 적합하지 않습니다. 따라서 위 예제와 같이 리더 ZNode는 단순 임시 노드로 유지하고 모든 팔로워가 Watcher를 설정하는 것이 일반적입니다.