분산 배치 스케줄러의 중복 실행과 단일 장애점을 방지하는 리더 선출(Leader Election)과 하트비트 아키텍처

분산 배치 스케줄러의 중복 실행과 단일 장애점을 방지하는 리더 선출(Leader Election)과 하트비트 아키텍처
분산 배치 스케줄러의 중복 실행과 단일 장애점을 방지하는 리더 선출(Leader Election)과 하트비트 아키텍처

주기적인 공공데이터 수집 크롤러, 일일 정산 정합성 검증, 메시지 발송 큐 등 엔터프라이즈 배치 자동화 파이프라인에서 가장 중요한 운영 제약은 ‘어떤 순간에도 동일한 배치 작업이 단 하나의 활성 노드(Single Active Leader)에서만 실행되어야 한다’는 점입니다.

가용성 확보를 위해 여러 서버에 스케줄러 인스턴스를 다중화(Multi-node)하면, 동일한 작업이 중복 실행되어 데이터베이스에 이중 쓰기(Double Write)가 발생하거나 외부 API 할당량이 순식간에 소진되는 사고가 발생합니다. 반대로 단일 인스턴스(Active-Only)로 운영하면 해당 노드가 다운되었을 때 전체 자동화가 멈추는 단일 장애점(SPOF, Single Point of Failure)이 발생합니다. 본 글에서는 분산 환경에서 리더 권한을 원자적으로 획득하고 장애 시 수 초 이내로 대기 노드가 권한을 승계하는 임차권(Lease) 기반 리더 선출(Leader Election)과 하트비트(Heartbeat) 장애 조치 아키텍처를 분석합니다.

1. 분산 스케줄러의 스플릿 브레인(Split-Brain)과 임차권(Lease) 메커니즘

분산 환경에서 프로세스의 비정상 종료(Crash), 네트워크 지연, 가비지 컬렉션(GC) 일시 중단 등으로 인해 이전 리더가 살아있다고 착각하는 동안 새 리더가 선출되어 2개의 리더가 동시에 작업을 수행하는 현상을 스플릿 브레인(Split-Brain)이라고 합니다.

  • 무제한 락의 위험성: 타임아웃이 없는 영구 락을 획득하면 리더 프로세스가 OOM으로 사망했을 때 영구적인 데드락이 발생하여 시스템 전체가 마비됩니다.
  • 임차권(Lease) 타임아웃: 리더는 무기한 권한을 갖는 대신, 일정 시간(예: 5초) 동안만 유효한 임차권을 획득합니다. 리더 노드는 백그라운드 하트비트(Heartbeat) 스레드를 통해 만료 전에 임차권을 지속적으로 갱신(Renewal)해야 합니다.
  • 자동 장애 조치(Automated Failover): 현직 리더가 하트비트를 보내지 못해 TTL이 만료되면, 대기 중이던 대기 노드(Standby Node) 중 하나가 즉시 락을 선점하여 리더 역할을 승계합니다.

3. 결론 및 도입 성과

Redis의 SET key value NX PX ttl 원자적 명령과 Lua 스크립트를 결합하여 안전하게 임차권을 획득, 갱신, 반납하는 프로덕션 파이썬 구현체입니다.

# Redis 기반 분산 리더 선출 및 하트비트 매니저 구현체
import time
import uuid
import threading
import logging

logger = logging.getLogger("LeaderElection")

class RedisLeaderElector:
    def __init__(self, redis_client, lock_key: str, lease_ms: int = 5000):
        self.redis = redis_client
        self.lock_key = lock_key
        self.lease_ms = lease_ms
        self.node_id = str(uuid.uuid4())  # 노드 고유 식별자
        self.is_leader = False
        self._stop_event = threading.Event()
        self._heartbeat_thread = None

    def start(self):
        """백그라운드 리더 획득 및 하트비트 감시 루프 실행"""
        self._heartbeat_thread = threading.Thread(target=self._run_election_loop, daemon=True)
        self._heartbeat_thread.start()

    def _run_election_loop(self):
        renewal_interval = (self.lease_ms / 1000.0) / 3.0  # 만료 전 주기적 갱신 (1.6초)

        while not self._stop_event.is_set():
            try:
                if not self.is_leader:
                    # 1. 리더 획득 시도 (SET NX PX)
                    acquired = self.redis.set(
                        self.lock_key, self.node_id, nx=True, px=self.lease_ms
                    )
                    if acquired:
                        self.is_leader = True
                        logger.info("🎉 [리더 선출] 노드(%s)가 리더 권한을 획득했습니다.", self.node_id)
                else:
                    # 2. 기존 리더: 하트비트를 통한 임차권 갱신 (원자적 Lua 스크립트)
                    renew_lua = """
                    if redis.call('get', KEYS[1]) == ARGV[1] then
                        return redis.call('pexpire', KEYS[1], ARGV[2])
                    else
                        return 0
                    end
                    """
                    renewed = self.redis.eval(renew_lua, 1, self.lock_key, self.node_id, self.lease_ms)
                    if not renewed:
                        self.is_leader = False
                        logger.warning("⚠️ [임차권 만료] 노드(%s) 리더 권한을 상실했습니다.", self.node_id)
            except Exception as exc:
                logger.error("리더 선출 루프 예외: %s", exc)

            time.sleep(renewal_interval)

    def stop(self):
        """정상 종료 시 리더 자진 반납 (Lua 스크립트로 안전 삭제)"""
        self._stop_event.set()
        if self.is_leader:
            release_lua = """
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('del', KEYS[1])
            else
                return 0
            end
            """
            self.redis.eval(release_lua, 1, self.lock_key, self.node_id)
            self.is_leader = False
            logger.info("리더 권한을 정상 반납했습니다: %s", self.node_id)
💡 프로덕션 분산 스케줄러 3대 안전 설계 원칙:
1. 펜싱 토큰(Fencing Token) 결합: 긴 GC 중단으로 이전 리더가 임차권을 잃은 줄 모른 채 실행을 재개할 수 있습니다. 저장소에 쓸 때 단조 증가하는 토큰 번호를 검증하여 구버전 리더의 쓰기를 거부해야 합니다.
2. 적정 임차 시간(Lease Duration) 설정: TTL이 너무 짧으면 사소한 네트워크 지터에 리더가 엎치락뒤치락(Thrashing)하고, 너무 길면 리더 다운 시 장애 감지가 지연됩니다. 통상 3~5초 임차에 1/3 주기 갱신이 안정적입니다.
3. 단계적 자진 사임(Graceful Step-down): 배포 시 SIGTERM 시그널을 받으면 즉시 락을 해제(Release)하여 대기 노드가 5초 대기 없이 즉각 리더를 승계하도록 구현해야 합니다.

3. 결론 및 도입 성과

임차권 기반 리더 선출 아키텍처는 고가의 분산 합의 엔진(etcd, ZooKeeper)을 구축하지 않고도 Redis만으로 분산 스케줄러의 중복 실행 방지와 자동 장애 조치를 모두 만족시키는 가장 효율적인 설계입니다. 펜싱 토큰과 하트비트 매커니즘을 결합함으로써 단일 장애점 없는 무중단 엔터프라이즈 배치 자동화를 완성할 수 있습니다.


출처 및 1차 공식 기술 문서

Similar Posts

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다