콘텐츠로 이동

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

로봇 작업자들이 여러 서비스 노드 사이에서 하나의 리더를 선출하는 작업대 일러스트
Replica는 많아도, 어떤 일은 한 번만 실행되어야 합니다. 안 그러면 운영팀이 두 번 웁니다.

이 글은 bluetape4k-leader 시리즈의 1편입니다. 전체 시리즈는 저장소의 큰 그림부터 시작해 core API, multi-leader/전략 선출, Spring Boot/Ktor 통합, backend와 운영 기능으로 이어집니다.

분산 리더 선출은 이름만 보면 거창합니다. 운영에서는 꽤 생활형 문제입니다. 서버를 3대 띄웠는데 야간 정산 job도 3번 돌고, schema migration도 3번 시도하고, webhook poller도 3개가 같은 이벤트를 잡으려고 달려듭니다. 서비스가 수평 확장된 순간, “한 번만 해야 하는 일”은 자동으로 한 번만 되지 않습니다. 어느 한 노드만 실행하도록 정해야 합니다.

bluetape4k-leader는 그 결정을 Kotlin/JVM 서비스 코드 어디서나 일관된 API로 다루게 해주는 독립 라이브러리입니다. Redis, Exposed JDBC/R2DBC, MongoDB, DynamoDB, etcd, Kubernetes Lease, Hazelcast, ZooKeeper 같은 backend를 제공하고, blocking, CompletableFuture, coroutine, virtual thread API를 나란히 제공합니다.

문제: replica는 여러 개인데 작업은 한 번만

섹션 제목: “문제: replica는 여러 개인데 작업은 한 번만”

가장 흔한 예는 scheduled job입니다. @Scheduled는 각 JVM에서 잘 동작합니다. 문제는 너무 잘 동작한다는 점입니다. pod가 3개면 tick도 3개입니다.

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

핵심은 null입니다. 리더가 아니면 예외를 던지지 않고 건너뜁니다. ShedLock을 써 본 사람에게 익숙한 “경합 시 건너뛰기” 동작입니다. 락 경합에서 밀린 것이지 실패가 아닙니다. 이런 경합을 전부 error log로 쌓으면 다음 진짜 장애 때 신호가 노이즈 속에 묻힙니다.

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

  • lock을 얻은 호출만 action을 실행합니다.
  • lock을 얻지 못하면 null을 반환합니다.
  • action이 예외를 던지면 호출자에게 전파합니다.
  • 실행이 끝나거나 lease가 끝나면 backend 정책에 따라 leadership을 해제하거나 만료시킵니다.

정확한 결과 구분이 필요하면 LeaderRunResult를 씁니다. action이 null을 반환할 수 있는 코드라면 단순 T?로는 “락 미획득”과 “action이 null 반환”을 구분할 수 없습니다.

when (val result = leaderElector.runIfLeaderResult("daily-job") { runJob() }) {
is LeaderRunResult.Elected -> log.info { "executed=${result.value}" }
LeaderRunResult.Skipped -> log.info { "another node is leader" }
is LeaderRunResult.ActionFailed -> log.warn(result.cause) { "leader action failed" }
}

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

bluetape4k-leaderbluetape4k-projects 안에 머무르지 않고 독립 저장소가 된 이유는 다루는 범위가 넓기 때문입니다. core API만 보면 단순하지만, 실제 운영에서는 backend 간 동작 일관성, lease semantics, Spring/Ktor 통합, metrics, examples, benchmark가 같이 따라옵니다.

Bluetape4k Leader module map with BOM, core APIs, backend modules, integrations, examples, and preview backends
core API는 작게 유지하고, backend와 framework integration은 독립 모듈로 분리합니다.
영역대표 모듈읽어야 하는 순간
Core APIleader-coreLeaderElector, SuspendLeaderElector, group election, result type을 파악할 때
Redisleader-redis-lettuce, leader-redis-redisson가장 빠르게 분산 lock/lease를 붙이고 싶을 때
SQL/Coroutineleader-exposed-jdbc, leader-exposed-r2dbc이미 RDB를 운영 중이고 row 기반 lease가 자연스러울 때
Infra-nativeleader-etcd, leader-k8s, leader-consulplatform의 lease/session 기능을 직접 쓰고 싶을 때
Frameworkleader-spring-boot, leader-ktor, leader-micrometerannotation, scheduler, actuator/metrics와 연결할 때
Examples/Benchmarkexamples/*, benchmark운영 시나리오와 backend 비용을 같이 볼 때

현재 README 기준으로 안정화된 backend에는 Redis Lettuce/Redisson, Exposed JDBC/R2DBC, MongoDB, Hazelcast, ZooKeeper가 있고, DynamoDB, etcd, Consul, Kubernetes Lease는 preview로 분류되어 있습니다.

ShedLock 스타일의 annotation과 “경합 시 건너뛰기”만 필요하면 선택지는 많습니다. bluetape4k-leader가 더 신경 쓰는 부분은 “서비스 코드의 실행 모델을 억지로 바꾸지 않는 것”입니다.

Kotlin 서비스에서는 같은 leader election이라도 호출 지점이 다릅니다.

실행 모델API어울리는 코드
BlockingLeaderElector기존 MVC, batch, plain JVM job
AsyncAsyncLeaderElectorCompletableFuture 기반 adapter
CoroutineSuspendLeaderElectorsuspend service, R2DBC, Ktor handler
Virtual threadVirtualThreadLeaderElectorJava 21 virtual-thread 실행 경계

Coroutine API는 action이 suspend입니다. 취소가 오면 CancellationException을 감추지 않고 다시 던져야 합니다. 작은 구현 디테일처럼 보이지만 long-running worker에서는 생명줄입니다. 취소를 삼킨 리더는 서비스가 종료 중인데도 일을 계속 붙잡고 있습니다.

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

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

ShedLock은 scheduled job 중복 실행을 막는 실전적인 도구입니다. bluetape4k-leader도 핵심 동작을 공유합니다. 락을 못 얻으면 조용히 skip — 일부러 익숙하게 뒀습니다. 다만 목표가 “Spring scheduled method 보호”에서 멈추지 않습니다.

항목ShedLock 방식bluetape4k-leader 방식
경합 처리lock 미획득 시 skiprunIfLeadernull, result API는 Skipped
Spring annotation주 사용 경로지원하지만 core API와 분리
Coroutine별도 적응 필요SuspendLeaderElector 제공
Async제한적/adapter 필요AsyncLeaderElector 제공
Virtual thread일반 blocking 코드로 가능별도 virtual-thread elector 계층 제공
Multi-leader주 목적 아님LeaderGroupElector로 N개 동시 리더
Strategy election주 목적 아님FIFO, random, scored, weighted 전략 선출
Backend 폭여러 lock providerRedis, SQL/R2DBC, MongoDB, DynamoDB, etcd, Kubernetes, Hazelcast, ZooKeeper

“Spring scheduled job 하나만 막으면 된다”면 ShedLock과 문제의식이 거의 겹칩니다. coroutine worker, 테넌트별 poller, N개 슬롯을 나눠 쓰는 작업, Kubernetes Lease 기반 operator, Ktor scheduler까지 한 가지 API로 묶고 싶을 때 bluetape4k-leader가 더 넓게 커버합니다.

예제 6개는 각각 하나의 운영 실패 시나리오를 다룹니다. “이 라이브러리가 왜 필요한가”를 코드로 보여줍니다.

Batch Scheduler — 야간 정산 job이 replica 수만큼 실행되는 문제. Lettuce Redis lock으로 한 노드만 실행합니다.

Batch Scheduler Architecture: three scheduler instances share one Redis leader lock

Migration Gate — 부팅 시 schema migration을 여러 pod가 동시에 시도하는 문제. Exposed JDBC lock으로 한 pod만 실행하고, 나머지는 완료 여부를 확인합니다.

Migration Gate Architecture: rolling deploy pods check migration markers before and after the Exposed JDBC leader lock

Webhook Poller — 여러 poller가 같은 webhook event를 중복 claim하는 문제. MongoDB 기반 SuspendLeaderElector로 한 poller만 event를 가져가게 합니다.

Webhook Poller Architecture: one elected poller claims MongoDB events atomically

Cache Warmer — 파티션별 cache warming이 여러 노드에서 중복 실행되는 문제. 파티션마다 독립 lock을 두어 분산 warming을 안전하게 합니다.

Cache Warmer Architecture: every cache partition gets its own leader lock

Tenant Aggregator — 테넌트별 coroutine polling이 여러 노드에서 동시에 도는 문제. forTenant()로 테넌트별 독립 lock을 만들고 SuspendLeaderElector로 실행을 제어합니다.

Tenant Aggregator Architecture: each tenant has a long-running coroutine and an independent leader lock

K8s Operator — operator replica 여러 개가 같은 reconcile loop를 동시에 실행하는 문제. Kubernetes Lease API를 직접 쓰는 KubernetesLeaseLeaderElector로 제어합니다.

K8s Operator Architecture: three Spring Boot operator pods share one Kubernetes Lease before reconciling

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

처음부터 모든 backend를 비교하면 금방 지칩니다. Part 5에서 자세히 다루고, 여기서는 첫 선택 기준만 잡습니다.

  • Redis를 이미 운영하고 있으면 Lettuce/Redisson이 가장 빠른 시작점입니다.
  • RDB만 있는 서비스라면 Exposed JDBC/R2DBC가 운영 부담을 줄입니다.
  • Kubernetes operator나 platform-native controller라면 Kubernetes Lease가 자연스럽습니다.
  • etcd/Consul/ZooKeeper를 이미 control plane으로 쓰고 있다면 해당 session/lease 모델을 검토할 만합니다.
  • 같은 작업을 N개까지 허용해야 하면 단일 leader가 아니라 LeaderGroupElector를 봅니다.
  • “가장 오래 쉰 노드”, “가중치가 높은 후보”처럼 선택 규칙이 필요하면 strategic election을 봅니다.

리더 선출은 “분산 시스템” 이론보다 “한 번만 해야 하는 일을 진짜 한 번만 실행하기” 쪽에 가깝습니다. scheduled job, migration, poller, cache warmer, operator reconcile loop가 모두 같은 질문을 던집니다. 지금 이 replica가 실행해도 되는가?

bluetape4k-leader는 그 질문에 backend마다 다른 API가 아니라 공통 실행 모델로 답합니다. Part 2에서는 LeaderElector, SuspendLeaderElector, LeaderElectionOptions, LeaderRunResult를 직접 봅니다. 중요한 구분은 “어느 노드가 실행했는가”와 “나머지 노드는 무엇을 했는가”입니다.

댓글

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