ZooKeeper 실전: 임시 노드와 Watcher를 활용한 리더 선출 구현

1. 문제 정의

마스터-슬레이브 구조의 클러스터에서 모든 하드웨어는 언제든지 장애가 발생할 수 있다고 가정합니다. 마스터 노드가 다운되었을 때, 슬레이브 중 하나를 새로운 마스터로 선출해야 합니다. ZooKeeper를 사용하면 이 리더 선출(Leader Election) 기능을 비교적 간단하게 구현할 수 있습니다. 본 글에서는 일반적인 용어를 사용하여 리더(Leader)와 팔로워(Follower)로 칭합니다.

2. 핵심 이슈 분석

리더 선출 과정은 다음 두 가지 핵심 문제를 해결해야 합니다:

  1. 누가 리더가 될 것인가? 여러 노드가 동시에 리더가 되려고 할 때, 단 하나의 노드만 리더로 선정되어야 합니다. 이는 마치 멀티스레드 환경에서의 뮤텍스 잠금(Mutex Lock)과 유사합니다.
  2. 리더가 다운되었을 때 팔로워가 이를 어떻게 감지할 것인가? 리더의 장애를 신속하게 감지하고 새로운 선출 과정을 시작해야 합니다.

첫 번째 문제 해결: ZooKeeper 상의 특정 ZNode(예: /election/leader)를 분산 락으로 간주합니다. 클러스터의 모든 노드는 이 ZNode의 생성을 시도합니다. ZooKeeper의 create() 연산은 원자적(Atomic)이므로, 오직 하나의 노드만 성공적으로 ZNode를 생성할 수 있습니다. 생성에 성공한 노드가 리더가 됩니다. 리더는 이 ZNode에 자신의 호스트명이나 ID 등의 메타데이터를 저장하여, 다른 팔로워들이 누가 리더인지 알 수 있도록 합니다.

두 번째 문제 해결: ZooKeeper의 ZNode는 크게 영구 노드(Persistent Node)임시 노드(Ephemeral Node)로 나뉩니다. 임시 노드는 클라이언트의 세션(Session)과 생명 주기가 동일합니다. 클라이언트(리더)가 연결을 종료하거나 세션이 만료되면(예: 장애), ZooKeeper는 해당 임시 노드를 자동으로 삭제합니다. 팔로워들은 리더가 생성한 임시 노드에 Watcher를 등록합니다. 노드가 삭제되면 Watcher가 트리거되어 팔로워들은 리더의 다운을 즉시 감지하고 새로운 선출 과정을 시작할 수 있습니다.

3. 선출 프로세스 상세

  1. 준비 단계: 클러스터 내 모든 노드는 자신의 상태를 LOOKING (선출 중)으로 설정합니다.
  2. 리더 경쟁: LOOKING 상태의 모든 노드는 ZooKeeper에 /election/leader 임시 노드 생성을 시도합니다.
  3. 리더 선정:
    • 생성에 성공한 노드는 자신의 상태를 LEADER로 변경하고, 필요 정보를 ZNode에 기록합니다.
    • 생성에 실패한 노드는 상태를 FOLLOWER로 변경합니다. 팔로워는 /election/leader 노드의 데이터를 읽고, 동시에 해당 노드의 삭제 이벤트를 감지하는 Watcher를 설정합니다.
  4. 장애 감지 및 재선출:
    • 리더 노드가 다운되면, 리더의 세션이 만료되어 /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를 설정하는 것이 일반적입니다.

태그: ZooKeeper 리더 선출 임시 노드 Watcher 분산 시스템

7월 2일 02:52에 게시됨