콘텐츠로 이동

Bluetape4k Leader Part 1: 리더 선출의 기본 모델

로봇 작업자들이 여러 서비스 노드 사이에서 하나의 리더를 선출하는 작업대 일러스트
레플리카 수와 관계없이 일부 작업은 한 번만 실행되어야 합니다.

이 글은 bluetape4k-leader 시리즈의 첫 편입니다. 저장소의 전체 구조를 살펴본 뒤 핵심 API, 복수 리더와 전략 선출, Spring Boot·Ktor 통합, 백엔드와 운영 기능을 차례로 다룹니다.

분산 리더 선출이 해결하는 문제는 구체적입니다. 서버를 3대로 늘리면 야간 정산 작업과 스키마 마이그레이션도 3번 실행될 수 있고, 웹훅 폴러 3개가 같은 이벤트를 선점하려고 경쟁할 수 있습니다. 서비스를 수평 확장해도 “한 번만 실행해야 하는 작업”이 자동으로 단일 실행되는 것은 아닙니다. 작업을 수행할 노드 하나를 정해야 합니다.

bluetape4k-leader는 이러한 결정을 Kotlin/JVM 서비스에서 일관된 API로 처리하는 독립 라이브러리입니다. Redis, Exposed JDBC/R2DBC, MongoDB, DynamoDB, etcd, Kubernetes Lease, Hazelcast, ZooKeeper 백엔드를 지원하며, 블로킹, CompletableFuture, 코루틴, 가상 스레드 API를 제공합니다.

문제: 레플리카는 여러 개지만 작업은 한 번만

섹션 제목: “문제: 레플리카는 여러 개지만 작업은 한 번만”

대표적인 사례는 스케줄 작업입니다. @Scheduled는 각 JVM에서 독립적으로 실행되므로 Pod가 3개면 동일한 스케줄 신호도 3번 처리됩니다.

val result = leaderElector.runIfLeader("nightly-settlement") {
settlementService.processYesterday()
}
if (result == null) {
log.info { "다른 인스턴스가 실행 중이므로 건너뜁니다" }
}

핵심은 null 반환입니다. 리더로 선출되지 않은 호출은 예외를 던지지 않고 작업을 건너뜁니다. ShedLock의 “경합 시 건너뛰기”와 같은 동작입니다. 잠금 경합에서 밀린 호출은 실패가 아니므로 모두 오류 로그로 기록하면 실제 장애 신호를 식별하기 어려워집니다.

runIfLeader의 기본 계약은 단순합니다.

  • 잠금을 획득한 호출만 action을 실행합니다.
  • 잠금을 획득하지 못하면 null을 반환합니다.
  • action에서 발생한 예외는 호출자에게 전파합니다.
  • 실행이 끝나거나 리스가 만료되면 백엔드 정책에 따라 리더 권한을 해제하거나 만료 처리합니다.

실행 결과를 명시적으로 구분해야 한다면 LeaderRunResult를 사용합니다. actionnull을 반환할 수 있으면 단순한 T?만으로는 “잠금 미획득”과 “actionnull을 반환한 경우”를 구분할 수 없습니다.

when (val result = leaderElector.runIfLeaderResult("daily-job") { runJob() }) {
is LeaderRunResult.Elected -> log.info { "실행 완료=${result.value}" }
LeaderRunResult.Skipped -> log.info { "다른 노드가 리더입니다" }
is LeaderRunResult.ActionFailed -> log.warn(result.cause) { "리더 작업 실패" }
}

관련 소스: LeaderElector.kt, LeaderRunResult.kt

bluetape4k-leaderbluetape4k-projects에서 독립 저장소로 분리한 이유는 관리 범위가 넓기 때문입니다. 핵심 API 자체는 작지만 운영 환경에서는 백엔드 간 동작 일관성, 리스 의미론, Spring·Ktor 통합, 메트릭, 예제, 벤치마크를 함께 관리해야 합니다.

BOM, 핵심 API, 백엔드 모듈, 프레임워크 통합, 예제, 프리뷰 백엔드로 구성된 Bluetape4k Leader 모듈 지도
핵심 API는 작게 유지하고 백엔드와 프레임워크 통합은 독립 모듈로 분리합니다.
영역대표 모듈읽어야 하는 순간
핵심 APIleader-coreLeaderElector, SuspendLeaderElector, 그룹 선출, 결과 타입을 파악할 때
Redisleader-redis-lettuce, leader-redis-redissonRedis로 분산 잠금과 리스를 구성할 때
SQL·코루틴leader-exposed-jdbc, leader-exposed-r2dbc기존 관계형 데이터베이스에 행 기반 리스를 저장할 때
인프라 네이티브leader-etcd, leader-k8s, leader-consul플랫폼의 리스·세션 기능을 직접 사용할 때
프레임워크leader-spring-boot, leader-ktor, leader-micrometer애너테이션, 스케줄러, Actuator, 메트릭과 연동할 때
예제·벤치마크examples/*, benchmark운영 시나리오와 백엔드 비용을 함께 비교할 때

현재 README에서 Redis Lettuce/Redisson, Exposed JDBC/R2DBC, MongoDB, Hazelcast, ZooKeeper는 안정 백엔드로, DynamoDB, etcd, Consul, Kubernetes Lease는 프리뷰 백엔드로 분류합니다.

ShedLock 형태의 애너테이션과 “경합 시 건너뛰기”만 필요하다면 여러 선택지가 있습니다. bluetape4k-leader는 서비스 코드가 리더 선출 때문에 기존 실행 모델을 바꾸지 않도록 각 실행 방식에 대응하는 API를 제공합니다.

같은 리더 선출이라도 Kotlin/JVM 서비스의 호출 지점에 따라 실행 방식이 달라집니다.

실행 모델API어울리는 코드
블로킹LeaderElector기존 MVC, 배치, 일반 JVM 작업
비동기AsyncLeaderElectorCompletableFuture 기반 어댑터
코루틴SuspendLeaderElectorsuspend 서비스, R2DBC, Ktor 핸들러
가상 스레드VirtualThreadLeaderElectorJava 21 가상 스레드 실행 경계

코루틴 API의 actionsuspend 함수입니다. 취소 요청을 받으면 CancellationException을 감추지 않고 다시 던져야 합니다. 장기 실행 작업에서 취소 예외를 삼키면 서비스가 종료되는 동안에도 리더 작업이 계속 실행될 수 있습니다.

val result = suspendLeaderElector.runIfLeader("tenant-aggregator") {
metricsService.aggregateTenant(tenantId)
}

관련 소스: SuspendLeaderElector.kt, LeaderElectionOptions.kt

ShedLock은 스케줄 작업의 중복 실행을 막는 도구입니다. bluetape4k-leader도 잠금을 획득하지 못하면 작업을 건너뛰는 핵심 동작을 공유합니다. 그러나 지원 범위는 Spring 스케줄 메서드 보호에 한정되지 않습니다.

항목ShedLock 방식bluetape4k-leader 방식
경합 처리잠금 미획득 시 건너뜀runIfLeadernull, 결과 API는 Skipped 반환
Spring 애너테이션주 사용 경로지원하지만 핵심 API와 분리
코루틴별도 어댑터 필요SuspendLeaderElector 제공
비동기별도 어댑터 필요AsyncLeaderElector 제공
가상 스레드일반 블로킹 코드로 사용전용 VirtualThreadLeaderElector 제공
복수 리더주 목적 아님LeaderGroupElector로 N개 동시 리더 허용
전략 선출주 목적 아님FIFO, 무작위, 점수, 가중치 기반 선출
지원 백엔드여러 잠금 공급자Redis, SQL/R2DBC, MongoDB, DynamoDB, etcd, Kubernetes, Hazelcast, ZooKeeper

Spring 스케줄 메서드 하나의 중복 실행만 막는다면 ShedLock과 해결 범위가 겹칩니다. 코루틴 작업자, 테넌트별 폴러, N개 슬롯을 공유하는 작업, Kubernetes Lease 기반 오퍼레이터, Ktor 스케줄러까지 하나의 API로 다뤄야 한다면 bluetape4k-leader가 더 넓은 실행 모델을 제공합니다.

다음 6개 예제는 각각 하나의 운영 실패 시나리오와 이를 방지하는 실행 경계를 보여줍니다.

배치 스케줄러 — 야간 정산 작업이 레플리카 수만큼 실행되는 문제를 다룹니다. Lettuce Redis 잠금으로 한 노드만 작업을 수행합니다.

스케줄러 인스턴스 3개가 하나의 Redis 리더 잠금을 공유하는 배치 스케줄러 구조

마이그레이션 게이트 — 여러 Pod가 시작할 때 스키마 마이그레이션을 동시에 시도하는 문제를 다룹니다. Exposed JDBC 잠금으로 한 Pod만 마이그레이션을 실행하고 나머지는 완료 여부를 확인합니다.

롤링 배포 중인 Pod가 Exposed JDBC 리더 잠금 전후로 마이그레이션 완료 표식을 확인하는 구조

웹훅 폴러 — 여러 폴러가 같은 웹훅 이벤트를 중복 선점하는 문제를 다룹니다. MongoDB 기반 SuspendLeaderElector로 한 폴러만 이벤트를 가져가게 합니다.

선출된 폴러 하나가 MongoDB 이벤트를 원자적으로 선점하는 웹훅 폴러 구조

캐시 워머 — 파티션별 캐시 워밍이 여러 노드에서 중복 실행되는 문제를 다룹니다. 파티션마다 독립된 잠금을 사용해 캐시 워밍을 분산 실행합니다.

각 캐시 파티션이 독립된 리더 잠금을 사용하는 캐시 워머 구조

테넌트 집계기 — 테넌트별 코루틴 폴링이 여러 노드에서 동시에 실행되는 문제를 다룹니다. lockNamePrefix와 테넌트 ID로 독립된 잠금 이름을 만들고 SuspendLeaderElector로 실행을 제어합니다.

각 테넌트가 장기 실행 코루틴과 독립된 리더 잠금을 사용하는 테넌트 집계 구조

Kubernetes 오퍼레이터 — 여러 오퍼레이터 레플리카가 같은 reconcile 루프를 동시에 실행하는 문제를 다룹니다. Kubernetes Lease API를 직접 사용하는 KubernetesLeaseLeaderElector로 실행을 제어합니다.

Spring Boot 오퍼레이터 Pod 3개가 reconcile 작업 전에 하나의 Kubernetes Lease를 공유하는 구조

관련 소스: examples/batch-scheduler, examples/tenant-aggregator, examples/k8s-operator

백엔드 선택에는 운영 중인 인프라와 작업의 동시 실행 한도를 먼저 반영해야 합니다. 자세한 비교는 Part 5에서 다루며, 여기서는 초기 선택 기준을 정리합니다.

  • Redis를 이미 운영하고 있다면 Lettuce나 Redisson으로 시작할 수 있습니다.
  • RDB만 있는 서비스라면 Exposed JDBC/R2DBC가 운영 부담을 줄입니다.
  • Kubernetes 오퍼레이터나 플랫폼 네이티브 컨트롤러라면 Kubernetes Lease를 검토합니다.
  • etcd, Consul, ZooKeeper를 이미 컨트롤 플레인으로 사용한다면 해당 세션·리스 모델을 활용할 수 있습니다.
  • 같은 작업을 최대 N개 노드에서 실행해야 한다면 단일 리더 대신 LeaderGroupElector를 사용합니다.
  • 가장 오래 유휴 상태였던 노드나 가중치가 높은 후보를 선출해야 한다면 전략 선출을 사용합니다.

리더 선출의 목적은 한 번만 실행해야 하는 작업을 실제로 단일 실행하는 데 있습니다. 스케줄 작업, 마이그레이션, 폴러, 캐시 워머, 오퍼레이터의 reconcile 루프는 모두 현재 레플리카가 작업을 실행해도 되는지 판단해야 합니다.

bluetape4k-leader는 백엔드별 API 대신 공통 실행 모델로 이 판단을 처리합니다. Part 2에서는 LeaderElector, SuspendLeaderElector, LeaderElectionOptions, LeaderRunResult의 계약을 살펴봅니다. 핵심은 작업을 실행한 노드와 경합에서 건너뛴 노드의 결과를 구분하는 것입니다.

댓글

GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.