Bluetape4k Leader Part 3: Multi-leader와 전략 선출

이 글은 bluetape4k-leader 시리즈의 3편입니다. Part 1에서는
리더 선출의 기본 모델을 봤고, Part 2에서는
LeaderElector, SuspendLeaderElector, LeaderElectionOptions, LeaderRunResult 같은 core API를 봤습니다.
이번에는 “리더는 반드시 한 명이어야 하는가?”라는 질문으로 들어갑니다. 답은 상황마다 다릅니다. schema
migration처럼 진짜 한 번만 실행해야 하는 작업도 있지만, 200개 tenant의 집계를 처리하는 worker라면 한 명만으로는
너무 느립니다. 반대로 모든 replica가 동시에 달려들면 backend에 먼저 과부하가 걸릴 수 있습니다. 그래서 LeaderGroupElector는
“최대 N개까지만” 실행하는 분산 세마포어 모델을 제공합니다.
그리고 “누가 먼저 락을 잡았는가”보다 “누가 지금 더 적합한가”가 중요한 작업도 있습니다. 최근 실패가 적은 노드, 여유 capacity가 큰 노드, 오래 쉬었던 노드를 우선하고 싶을 때입니다. 이때는 strategic election을 씁니다. 락 경쟁 대신 후보 레지스트리와 전략 함수로 실행할 노드를 고릅니다.
리더 하나로 부족한 경우
섹션 제목: “리더 하나로 부족한 경우”단일 leader election은 단순하고 강합니다. runIfLeader("job")는 한 노드에서만 action을 실행합니다.
문제는 처리량입니다. 예를 들어 다음 작업은 “한 번만”보다 “너무 많지는 않게, 그래도 여러 개”가 더 자연스럽습니다.
- tenant별 집계 job을 동시에 몇 개만 돌리기
- cache warming을 partition 단위로 나누되 전체 동시 실행 수 제한하기
- 외부 API polling을 여러 worker가 나눠 하되 rate limit 넘기지 않기
- 오래 걸리는 maintenance task를 cluster 안에서 N개까지 허용하기
여기서 LeaderGroupElector는 maxLeaders개 slot을 둡니다. slot을 얻은 노드만 action을 실행하고, slot이
가득 차면 호출자는 기다리거나 skip합니다. 이름은 leader지만, 동작 방식은 분산 세마포어에 가깝습니다.

LeaderGroupElector는 lockName 하나에 최대 maxLeaders개의 slot token을 붙입니다. token을 얻은 호출자만 action을 실행합니다.Group election: slot-token TTL 세마포어
섹션 제목: “Group election: slot-token TTL 세마포어”핵심 옵션은 단순합니다.
LeaderGroupElectionOptions( maxLeaders = 3, waitTime = 5.seconds, leaseTime = 60.seconds,)maxLeaders는 동시에 실행할 수 있는 action 수입니다. waitTime은 slot 획득을 얼마나 기다릴지 정하고,
leaseTime은 slot token이 얼마 동안 유지될지 정합니다. action이 정상 종료되면 token은 해제되고,
노드가 죽거나 해제하지 못하면 TTL 만료가 안전망이 됩니다.
blocking API는 Part 2의 LeaderElector와 비슷합니다.
val options = LeaderGroupElectionOptions(maxLeaders = 3)val groupElection = ExposedJdbcLeaderGroupElector(dataSource, options)
val result: AggregationReport? = groupElection.runIfLeader("tenant-aggregation") { aggregateNextTenantBatch()}coroutine 서비스라면 suspend variant를 씁니다.
val options = LeaderGroupElectionOptions(maxLeaders = 3)val groupElection = ExposedR2DbcSuspendLeaderGroupElector(database, options)
val result = groupElection.runIfLeader("tenant-aggregation") { aggregateNextTenantBatchSuspend()}runIfLeader의 null 의미는 단일 leader와 같습니다. action이 실행되지 않았거나, action 자체가 null을
반환했을 수 있습니다. 구분이 필요하면 group elector도 LeaderRunResult API를 제공합니다.
when (val result = groupElection.runIfLeaderResult("tenant-aggregation") { drainQueue() }) { is LeaderRunResult.Elected -> meter.mark("slot.acquired") LeaderRunResult.Skipped -> meter.mark("slot.skipped") is LeaderRunResult.ActionFailed -> meter.mark("slot.failed")}여기서 중요한 운영 규칙이 하나 있습니다. group slot은 “리더 하나의 lease를 연장한다”보다 더 까다롭습니다.
slot마다 owner, TTL, release/expiry 의미가 따라붙기 때문입니다. README도 이 경계를 분명히 합니다. 단일 leader
선출의 autoExtend는 opt-in으로 동작하지만, @LeaderGroupElection은 아직 auto-extension을 지원하지 않습니다.
streaming 계열 Flux<T>/Flow<T>도 group lease extension 의미가 정의되지 않아 0.3.1 범위 밖입니다.
그래서 group election에서는 leaseTime을 action 시간보다 충분히 길게 잡고, action 시간이 길거나 들쭉날쭉하다면
작업을 더 작은 단위로 나누는 편이 안전합니다. TTL은 “정상 작업 시간을 표현하는 값”이지 “아무렇게나 오래
잡아도 되는 보험”이 아닙니다.
관련 소스: LeaderGroupElector.kt, SuspendLeaderGroupElector.kt, LeaderGroupElectionOptions.kt, LeaderGroupElectionState.kt
전략 선출: 락 경쟁 대신 후보 목록으로 고른다
섹션 제목: “전략 선출: 락 경쟁 대신 후보 목록으로 고른다”Group election은 “몇 명까지 실행할 수 있는가”를 해결합니다. Strategic election은 다른 질문입니다. “누가 실행하는 것이 가장 좋은가?”
StrategicLeaderElector는 락을 먼저 잡은 노드를 실행 대상으로 보지 않습니다. 각 노드가 CandidateInfo를
등록하고, runIfLeader 호출 시 후보 목록을 읽은 뒤 ElectionStrategy가 실행할 노드 하나를 고릅니다.
선택된 노드만 action을 실행하고, 나머지 노드는 null을 바로 반환합니다.

CandidateInfo는 선출 판단에 필요한 작은 이력을 담습니다.
CandidateInfo( nodeId = "node-b", registeredAt = Instant.now(), lastCompletionTime = previousCompletion, successCount = 14, failureCount = 1, metadata = mapOf( "healthPercent" to "86", "availableCapacityPercent" to "88", ),)전략은 교체 가능한 플러그인입니다.
| 전략 | 기준 |
|---|---|
FifoElectionStrategy | 가장 먼저 등록된 후보. 동률은 nodeId 사전순 |
RandomElectionStrategy(seed) | 후보 목록을 정렬한 뒤 seed 기반 또는 system random 선택 |
ScoredElectionStrategy(scorer) | CandidateScorer가 계산한 점수가 가장 높은 후보 |
score 기반 전략에는 기본 scorer가 있습니다.
| Scorer | 선호하는 후보 |
|---|---|
SuccessRateScorer | 성공률이 높은 노드 |
IdleTimeScorer | 오래 쉬었던 노드 |
RecentSuccessScorer | 최근 성공 이력이 좋은 노드 |
WeightedScorer | 여러 scorer의 가중 합이 높은 노드 |
예제의 strategic-election 모듈은 readiness, 성공률, idle time을 합쳐 maintenance node를 선택합니다.
val strategy = ScoredElectionStrategy( WeightedScorer( ServiceReadinessScorer to 0.50, SuccessRateScorer to 0.35, IdleTimeScorer to 0.15, ))
val result = election.runIfLeader("service-maintenance", strategy) { runMaintenance()}전략 선출에서 조심할 점은 일관성입니다. 모든 노드가 같은 후보 목록에 같은 deterministic strategy를 적용하면
같은 실행 노드를 계산합니다. 반대로 후보 등록이나 조회 타이밍이 달라지면 실행 노드가 서로 달라질 수 있습니다.
분산 환경에서는 candidate TTL, heartbeat 주기, registry 조회 일관성을 설계의 일부로 봐야 합니다.
RandomElectionStrategy도 분산 환경에서는 shared seed 없이는 노드마다 제각각 랜덤 선택을 하게 됩니다.
관련 소스: StrategicLeaderElector.kt, StrategicSuspendLeaderElector.kt, CandidateInfo.kt, ElectionStrategy.kt, ScoredElectionStrategy.kt, WeightedScorer.kt, examples/strategic-election
어떤 모델을 선택할까
섹션 제목: “어떤 모델을 선택할까”세 모델은 경쟁 관계라기보다 질문이 다릅니다.
| 상황 | 선택 | 이유 |
|---|---|---|
| schema migration, daily settlement, webhook poller처럼 한 번만 실행해야 함 | LeaderElector / SuspendLeaderElector | 단일 lock으로 경합 모델이 가장 단순함 |
| 여러 worker가 나눠 처리하되 동시 실행 수를 제한해야 함 | LeaderGroupElector / SuspendLeaderGroupElector | maxLeaders slot으로 cluster-wide 상한을 둠 |
| 최근 성공률, idle time, health, capacity 같은 기준으로 실행 노드를 골라야 함 | StrategicLeaderElector / StrategicSuspendLeaderElector | lock 선착순 대신 후보 목록과 strategy로 실행 노드 하나를 고름 |
| tenant별로 독립적인 리더가 필요함 | tenant-scoped elector + 단일 또는 group API | tenantId:lockName처럼 namespace를 분리함 |
| 결과 관측, metric, 후처리에서 skip과 action-null을 구분해야 함 | LeaderRunResult API | Elected(null)과 Skipped를 분리함 |
실무에서는 보통 단일 leader에서 시작합니다. 처리량이 부족하면 group으로 넓히고, “아무나”가 아니라 “조건이 좋은 노드”가 필요해질 때 strategic으로 넘어갑니다. 처음부터 전략 선출로 시작하면 그럴듯해 보일 수 있습니다. 대신 후보 heartbeat, TTL, 일관성, scorer 해석까지 함께 운영해야 합니다. 멋져 보인다고 운영 비용이 사라지지는 않습니다.
참고자료
섹션 제목: “참고자료”- Repository: bluetape4k-leader
- README: README.md
- Korean README: README.ko.md
- Group implementations: Redis Lettuce, Redis Redisson, Exposed JDBC, Exposed R2DBC, MongoDB, Hazelcast
- Strategic implementations: LocalStrategicLeaderElector.kt, LettuceStrategicLeaderElector.kt, RedissonStrategicLeaderElector.kt
마무리
섹션 제목: “마무리”Part 2의 핵심은 호출자의 실행 모델에 맞는 elector를 고르는 것이었습니다. Part 3의 핵심은 실행 권한의 형태를 고르는 것입니다. 하나만 실행할지, N개까지 허용할지, 아니면 후보를 점수화해서 실행할 노드를 고를지 선택해야 합니다.
다음 Part 4에서는 이 API를 직접 호출하지 않고 Spring Boot와 Ktor 통합으로 끌어올립니다. @LeaderElection,
@LeaderGroupElection, Ktor route/scheduler helper가 어디까지 자동화하고, 어디서부터는 명시적으로 다뤄야
하는지 봅니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.