콘텐츠로 이동

Bluetape4k Leader Part 3: 복수 리더와 전략 선출

로봇 서비스 노드들이 세마포어 슬롯과 전략 선출 장치 사이에서 복수 리더와 후보 점수 선출을 비교하는 3D 작업대 일러스트
작업 하나에 리더 한 명으로 부족하고 모든 노드의 동시 실행은 위험할 때 그룹 선출로 동시 실행 수를 제한합니다.

이 글은 bluetape4k-leader 시리즈의 3편입니다. Part 1에서는 리더 선출의 기본 모델을 봤고, Part 2에서는 LeaderElector, SuspendLeaderElector, LeaderElectionOptions, LeaderRunResult 같은 핵심 API를 살펴봤습니다.

이번에는 리더가 반드시 한 명이어야 하는지 살펴봅니다. 스키마 마이그레이션처럼 한 번만 실행해야 하는 작업이 있는 반면, 수백 개 테넌트의 집계를 처리하는 작업은 리더 한 명만으로 부족할 수 있습니다. 모든 레플리카가 동시에 실행되면 백엔드에 과부하가 발생할 수 있습니다. LeaderGroupElector는 이런 작업을 최대 N개 노드에서만 실행하는 분산 세마포어 모델을 제공합니다.

락을 먼저 획득한 노드보다 현재 조건에 더 적합한 노드를 선택해야 하는 작업도 있습니다. 최근 실패가 적거나 가용 용량이 크고, 유휴 시간이 긴 노드를 우선할 수 있습니다. 전략 선출은 락 선착순 대신 후보 레지스트리와 전략 함수로 실행 노드를 선택합니다.

단일 리더 선출에서 runIfLeader("job")는 한 노드에서만 작업을 실행합니다. 단순하지만 처리량에 상한이 생깁니다. 다음 작업은 한 번만 실행하기보다 클러스터 전체의 동시 실행 수를 제한하면서 여러 노드에 분산하는 편이 적합합니다.

  • 테넌트별 집계 작업의 동시 실행 수 제한
  • 캐시 예열을 파티션 단위로 나누면서 전체 동시 실행 수 제한
  • 여러 작업자가 외부 API 폴링을 나눠 처리하면서 호출률 제한 준수
  • 장시간 유지 보수 작업에 참여하는 클러스터 노드를 최대 N개로 제한

LeaderGroupElectormaxLeaders개의 슬롯을 둡니다. 슬롯 토큰을 획득한 노드만 작업을 실행하고, 슬롯이 가득 차면 호출자는 기다리거나 건너뜁니다. 이름은 리더 선출이지만 동작 방식은 분산 세마포어에 가깝습니다.

LeaderGroupElector가 maxLeaders 슬롯, 활성 슬롯, 대기 또는 건너뛰기, 해제와 만료 경로로 동시 실행 수를 제한하는 세마포어 다이어그램
LeaderGroupElector는 하나의 lockName에 최대 maxLeaders개의 슬롯 토큰을 할당하며, 토큰을 획득한 호출자만 작업을 실행합니다.

그룹 선출: 슬롯 토큰 TTL 세마포어

섹션 제목: “그룹 선출: 슬롯 토큰 TTL 세마포어”

핵심 옵션은 단순합니다.

LeaderGroupElectionOptions(
maxLeaders = 3,
waitTime = 5.seconds,
leaseTime = 60.seconds,
)

maxLeaders는 동시에 실행할 수 있는 작업 수입니다. waitTime은 슬롯 획득을 기다리는 최대 시간이고, leaseTime은 슬롯 토큰의 리스 TTL입니다. 작업이 정상 종료되면 토큰을 해제하고, 노드가 종료되어 토큰을 해제하지 못하면 TTL 만료로 슬롯을 회수합니다.

블로킹 API는 Part 2의 LeaderElector와 같은 형태입니다.

val options = LeaderGroupElectionOptions(maxLeaders = 3)
val groupElection = ExposedJdbcLeaderGroupElector(dataSource, options)
val result: AggregationReport? = groupElection.runIfLeader("tenant-aggregation") {
aggregateNextTenantBatch()
}

코루틴 서비스에서는 suspend 변형을 사용합니다.

val options = LeaderGroupElectionOptions(maxLeaders = 3)
val groupElection = ExposedR2DbcSuspendLeaderGroupElector(database, options)
val result = groupElection.runIfLeader("tenant-aggregation") {
aggregateNextTenantBatchSuspend()
}

runIfLeader가 반환한 null의 의미는 단일 리더 선출과 같습니다. 작업이 실행되지 않았거나 작업 자체가 null을 반환했을 수 있습니다. 두 결과를 구분해야 하면 그룹 선출자에서도 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")
}

그룹 슬롯의 임대 연장은 단일 리더 임대 연장보다 복잡합니다. 각 슬롯에 소유자, TTL, 해제, 만료 계약이 따로 있기 때문입니다. 단일 리더 선출의 autoExtend는 명시적으로 활성화할 수 있지만, @LeaderGroupElection은 아직 자동 연장을 지원하지 않습니다. 스트리밍 계열 Flux<T>Flow<T>도 슬롯별 그룹 임대 연장 계약이 정의되지 않아 0.4.0 범위에서 지원하지 않습니다.

따라서 그룹 선출에서는 leaseTime을 예상 작업 시간보다 충분히 길게 설정하고, 작업 시간이 길거나 편차가 크면 작업을 더 작은 단위로 나눠야 합니다. TTL은 예상 작업 시간을 반영하는 운영 설정이며 무제한 점유를 허용하는 장치가 아닙니다.

관련 소스: LeaderGroupElector.kt, SuspendLeaderGroupElector.kt, LeaderGroupElectionOptions.kt, LeaderGroupElectionState.kt

전략 선출: 락 경쟁 대신 후보 목록으로 고른다

섹션 제목: “전략 선출: 락 경쟁 대신 후보 목록으로 고른다”

그룹 선출은 몇 개 노드까지 실행할 수 있는지를 결정합니다. 전략 선출은 현재 어떤 노드가 실행해야 하는지를 결정합니다.

StrategicLeaderElector는 락을 먼저 잡은 노드를 실행 대상으로 보지 않습니다. 각 노드가 CandidateInfo를 등록하고, runIfLeader 호출 시 후보 목록을 읽은 뒤 ElectionStrategy가 실행할 노드 하나를 고릅니다. 선택된 노드만 작업을 실행하고, 나머지 노드는 즉시 null을 반환합니다.

CandidateInfo 등록, 후보 레지스트리 조회, 전략 적용, 선택된 노드의 작업 실행, 미선출 노드 건너뛰기, 결과 갱신으로 이어지는 전략 선출 흐름
전략 선출은 후보를 먼저 등록한 뒤 동일한 후보 목록에 동일한 전략을 적용해 실행 노드 하나를 선택합니다.

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)후보 목록을 nodeId로 정렬한 뒤 지정한 seed 또는 시스템 난수로 선택
ScoredElectionStrategy(scorer)CandidateScorer가 계산한 점수가 가장 높은 후보

점수 기반 전략에는 다음 점수 계산기가 제공됩니다.

점수 계산기선호하는 후보
SuccessRateScorer성공률이 높은 노드
IdleTimeScorer유휴 시간이 긴 노드
RecentSuccessScorer최근 성공 이력이 우수한 노드
WeightedScorer여러 점수 계산기의 가중 합이 높은 노드

예제의 strategic-election 모듈은 준비 상태, 성공률, 유휴 시간을 조합해 유지 보수 노드를 선택합니다.

val strategy = ScoredElectionStrategy(
WeightedScorer(
ServiceReadinessScorer to 0.50,
SuccessRateScorer to 0.35,
IdleTimeScorer to 0.15,
)
)
val result = election.runIfLeader("service-maintenance", strategy) {
runMaintenance()
}

전략 선출에서는 후보 목록의 일관성을 보장해야 합니다. 모든 노드가 동일한 후보 목록에 동일한 결정적 전략을 적용하면 같은 실행 노드를 계산합니다. 반대로 후보 등록이나 조회 시점이 달라지면 선택 결과가 달라질 수 있습니다. 분산 환경에서는 후보 TTL, 하트비트 주기, 레지스트리 조회 일관성을 함께 설계해야 합니다. 또한 RandomElectionStrategy에 공통 seed를 지정하지 않으면 각 노드가 서로 다른 후보를 무작위로 선택할 수 있습니다.

관련 소스: StrategicLeaderElector.kt, StrategicSuspendLeaderElector.kt, CandidateInfo.kt, ElectionStrategy.kt, ScoredElectionStrategy.kt, WeightedScorer.kt, examples/strategic-election

세 모델은 서로 다른 실행 조건을 해결합니다.

상황선택이유
스키마 마이그레이션, 일일 정산, 웹훅 폴링처럼 한 번만 실행해야 함LeaderElector / SuspendLeaderElector단일 락 경합 모델이 가장 단순함
여러 작업자가 나눠 처리하되 동시 실행 수를 제한해야 함LeaderGroupElector / SuspendLeaderGroupElectormaxLeaders 슬롯으로 클러스터 전체 상한을 설정
최근 성공률, 유휴 시간, 상태, 용량으로 실행 노드를 선택해야 함StrategicLeaderElector / StrategicSuspendLeaderElector락 선착순 대신 후보 목록과 전략으로 실행 노드 하나를 선택
테넌트별로 독립된 리더가 필요함테넌트 범위 선출자 + 단일 또는 그룹 APItenantId:lockName 형식으로 네임스페이스 분리
메트릭이나 후처리에서 건너뛰기와 작업의 null 반환을 구분해야 함LeaderRunResult APIElected(null)Skipped를 분리

실무에서는 단일 리더 선출로 시작하는 편이 좋습니다. 처리량이 부족하면 그룹 선출로 확장하고, 임의의 노드가 아니라 조건에 맞는 노드를 선택해야 할 때 전략 선출을 도입합니다. 전략 선출은 후보 하트비트, TTL, 조회 일관성, 평가 점수의 해석까지 함께 운영해야 하므로 실제 선택 기준이 생긴 뒤 도입해야 합니다.

Part 2의 핵심은 호출자의 실행 모델에 맞는 선출자를 고르는 것이었습니다. Part 3의 핵심은 실행 권한의 형태를 고르는 것입니다. 하나만 실행할지, N개까지 허용할지, 아니면 후보를 점수화해서 실행할 노드를 고를지 선택해야 합니다.

다음 Part 4에서는 Spring Boot와 Ktor 통합을 다룹니다. @LeaderElection, @LeaderGroupElection, Ktor 라우트와 스케줄러 도우미가 자동화하는 범위와 애플리케이션에서 명시적으로 제어해야 하는 경계를 살펴봅니다.

댓글

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