Bluetape4k Leader Part 2: Core API와 실행 모델

이 글은 bluetape4k-leader 시리즈의 2편입니다. Part 1에서
저장소 구조와 기본 모델을 봤다면, 이번에는 실제 코드로 들어갑니다. 네 가지 elector 인터페이스,
LeaderElectionOptions, LeaderRunResult, 테넌트 네임스페이스, state 스냅샷까지 leader-core의 전체 API를 봅니다.
runIfLeader 계약
섹션 제목: “runIfLeader 계약”leader-core의 모든 elector는 runIfLeader라는 메서드를 공유합니다. 계약은 짧습니다.
- lock을 얻은 노드만 action을 실행합니다.
- lock을 얻지 못하면 action 없이
null을 반환합니다. - action이 예외를 던지면 호출자에게 그대로 전파됩니다.
- lock은 action이 끝나면 해제되거나, lease time에 따라 만료됩니다.
blocking 인터페이스 기준으로 보면:
interface LeaderElector { fun <T> runIfLeader( lockName: String, action: () -> T, ): T?}반환 타입은 T?입니다. null이면 lock 경합으로 action이 실행되지 않은 것입니다.
ShedLock처럼 “락을 얻지 못하면 건너뛰는” 의미입니다. 경합은 에러가 아니라 예상된 결과입니다.
관련 소스: LeaderElector.kt
null이 모호할 때: LeaderRunResult
섹션 제목: “null이 모호할 때: LeaderRunResult”action 자체가 null을 반환할 수 있는 코드에서는 문제가 생깁니다. “락을 못 얻어서 null”인지
“리더로 실행됐는데 action이 null을 돌려준 것”인지 구분할 방법이 없습니다.
LeaderRunResult는 이 모호함을 sealed interface로 풀어냅니다:
sealed interface LeaderRunResult<out T> { data class Elected<out T>( val value: T?, val leaderId: String? = null, ) : LeaderRunResult<T>
data object Skipped : LeaderRunResult<Nothing>
data class ActionFailed(val cause: Throwable) : LeaderRunResult<Nothing>}구분이 필요하면 runIfLeaderResult를 씁니다:
when (val result = leaderElector.runIfLeaderResult("batch-job") { processBatch() }) { is LeaderRunResult.Elected -> log.info { "리더로 실행됨, value=${result.value}" } LeaderRunResult.Skipped -> log.debug { "다른 노드가 lock 보유 중, skip" } is LeaderRunResult.ActionFailed -> log.error(result.cause) { "action 실행 중 예외 발생" }}CancellationException은 ActionFailed로 감싸지 않습니다. 그대로 전파되어야 coroutine의 structured
concurrency가 정상적으로 동작합니다.
관련 소스: LeaderRunResult.kt
실행 모델별 선택 기준
섹션 제목: “실행 모델별 선택 기준”같은 서비스 안에서도 호출 지점은 다릅니다. 리더 선출 때문에 실행 모델을 바꾸고 싶지는 않습니다.
| 모델 | 인터페이스 | Action 타입 | 반환 타입 | 적합한 상황 |
|---|---|---|---|---|
| Blocking | LeaderElector | () -> T | T? | MVC, batch, 일반 JVM |
| CompletableFuture | AsyncLeaderElector | () -> CompletableFuture<T> | CompletableFuture<T?> | Java async adapter |
| Coroutine | SuspendLeaderElector | suspend () -> T | T? | Ktor, R2DBC, WebFlux |
| Virtual thread | VirtualThreadLeaderElector | () -> T | VirtualFuture<T?> | Java 21 virtual thread |
Blocking
섹션 제목: “Blocking”val result: String? = leaderElector.runIfLeader("report-job") { generateReport()}호출 스레드를 막아도 되는 곳에서 씁니다. Spring MVC controller, Quartz job, @Scheduled 메서드가 대표적입니다.
CompletableFuture
섹션 제목: “CompletableFuture”val future: CompletableFuture<String?> = asyncLeaderElector.runAsyncIfLeader("report-job") { CompletableFuture.supplyAsync { generateReport() }}future.thenAccept { result -> log.info { "result=$result" } }AsyncLeaderElector는 future 완료와 lock 해제를 묶습니다. future가 끝나기 전에 lock이 먼저 해제되는
상황을 피할 수 있습니다.
Coroutine
섹션 제목: “Coroutine”val result: String? = suspendLeaderElector.runIfLeader("report-job") { generateReportSuspend()}SuspendLeaderElector는 suspend 람다를 그대로 받습니다. wrapper도 runBlocking도 없습니다.
lock을 들고 있는 동안 coroutine이 취소되면 CancellationException이 즉시 전파되고 lock이 풀립니다.
취소를 삼킨 리더가 작업을 계속 붙잡고 있으면 graceful shutdown이 아니라 shutdown을 방해하는 작업이 됩니다.
Virtual thread
섹션 제목: “Virtual thread”val future: VirtualFuture<String?> = virtualThreadLeaderElector.runAsyncIfLeader("report-job") { generateReport()}
// 현재 virtual thread를 blockval result = future.await()
// 또는 Java API와 연동val cf: CompletableFuture<String?> = future.toCompletableFuture()VirtualFuture.await()는 platform thread를 잡지 않고 virtual thread를 park합니다.
기존 Java async pipeline과 이어야 할 때는 toCompletableFuture()로 변환합니다.

관련 소스: AsyncLeaderElector.kt, SuspendLeaderElector.kt, VirtualThreadLeaderElector.kt
LeaderElectionOptions 상세
섹션 제목: “LeaderElectionOptions 상세”네 가지 인터페이스 모두 같은 LeaderElectionOptions를 씁니다. 실행 모델이 달라도 옵션 표현은 동일합니다.
| 프로퍼티 | 기본값 | 의미 |
|---|---|---|
waitTime | 5s | lock 획득을 포기하기 전 대기하는 최대 시간 |
leaseTime | 60s | action 완료 후 lock을 보유하는 시간 |
nodeId | JVM process id | lock을 보유한 노드의 식별자 |
minLeaseTime | 0s | action이 일찍 완료돼도 lock을 보유하는 최소 시간 |
autoExtend | false | 장시간 실행 중인 action의 lease를 자동으로 갱신할지 여부 |
useDbTime | false | application 노드 clock 대신 database 서버 clock 사용 |
val options = LeaderElectionOptions( waitTime = 10.seconds, leaseTime = 120.seconds, nodeId = "api-server-1", minLeaseTime = 5.seconds, autoExtend = true,)waitTime vs leaseTime — 두 값은 다른 시점을 제어합니다. waitTime은 획득 대기 시간입니다.
다른 노드가 lock을 들고 있고 waitTime이 지나면 runIfLeader는 null을 반환합니다. leaseTime은
action이 끝난 후 backend에서 lock이 남아 있는 시간입니다. 노드가 죽어서 lock을 못 풀었을 때도
다른 노드가 결국 획득할 수 있도록 상한선을 잡습니다.
minLeaseTime — action이 순식간에 끝나도 lock을 최소 N초 유지합니다. backend TTL로 강제되고,
그 시간 동안 호출 스레드가 대기하는 게 아닙니다. lock이 저장소에서 경쟁 노드에게 보이는 상태로
남아 있을 뿐입니다.
autoExtend — leaseTime보다 오래 걸리는 job에서 필요합니다. true면 background에서 주기적으로
lease를 갱신합니다. 정산 batch, 마이그레이션처럼 실행 시간이 가변적인 job에 씁니다.
useDbTime — database-backed 구현에서 노드 간 clock skew가 걱정될 때 씁니다. true면
lock 타임스탬프를 DB 서버 clock으로 비교해 노드별 편차를 없앱니다.
테넌트 네임스페이스
섹션 제목: “테넌트 네임스페이스”forTenant()는 elector를 감싸서 모든 lockName에 자동으로 테넌트 prefix를 붙입니다.
반환된 elector는 원래와 똑같은 인터페이스를 씁니다.
backend에 저장되는 키 형식은 tenant:{tenantId}:{lockName}입니다:
tenant:acme:report-jobtenant:beta-corp:report-jobval acmeElector = leaderElector.forTenant("acme")val result = acmeElector.runIfLeader("report-job") { generateReportForTenant("acme")}두 테넌트가 같은 "report-job" 이름을 써도 backend 키가 달라서 서로 경합하지 않습니다.
제약 두 가지: 콜론(:)은 separator라 tenant id나 lock name에 쓸 수 없고, 전체 키 이름이 255자를
넘으면 안 됩니다. SaaS 서비스에서 테넌트마다 같은 정산 job을 독립적으로 돌릴 때 쓰는 패턴입니다.
State 스냅샷
섹션 제목: “State 스냅샷”LeaderElectionState.state(lockName)은 현재 선출 상태의 스냅샷을 돌려줍니다:
val s = leaderElector.state("batch-lock")if (s.isOccupied) { println("현재 리더: ${s.leader?.leaderId}")}LeaderState는 lockName, status (Empty / Occupied), lock holder 정보를 담습니다.
중요한 제약: state는 best-effort 스냅샷입니다. 이 값을 보고 action 실행 여부를 결정하면 안 됩니다.
읽는 순간과 실행하려는 순간 사이에 이미 바뀌어 있을 수 있습니다. 실행 결정은 항상 runIfLeader의 atomic
acquire 경로를 거쳐야 합니다.
스냅샷이 제 역할을 하는 곳은 “지금 누가 리더인지 보여주는” 쪽입니다 — 모니터링 대시보드, admin API,
/actuator/leader 같은 진단 엔드포인트. 동시성 제어가 아닌 관찰이 목적일 때입니다.
ShedLock과의 비교 (Kotlin 관점)
섹션 제목: “ShedLock과의 비교 (Kotlin 관점)”Part 1에서 전체 그림을 비교했습니다. 여기서는 Kotlin 코드 레벨에서 실제로 달라지는 지점만 봅니다:
| 항목 | ShedLock | bluetape4k-leader |
|---|---|---|
| Suspend function action | wrapper나 adapter 필요 | SuspendLeaderElector가 suspend action을 직접 받음 |
CompletableFuture action | future 완료까지 lock 유지에 수동 처리 필요 | AsyncLeaderElector가 future 완료와 lock lifecycle을 연결 |
| Virtual thread action | 보통 callsite에서 일반 blocking code | VirtualThreadLeaderElector가 VirtualFuture 반환 |
취소 / CancellationException | 외부 coroutine scope가 처리 | 취소 시 lock 해제; CancellationException 재전파 |
| Nullable action 반환값 | true/false 결과나 custom wrapper 필요 | LeaderRunResult.Elected vs Skipped가 구분을 제공 |
| Skip semantics | @SchedulerLock skip-on-lock-fail | runIfLeader는 null 반환; result API는 Skipped 반환 |
@Scheduled 메서드 하나만 보호하는 게 목적이라면 ShedLock이 더 단순한 선택입니다. 서비스에 coroutine
worker, 테넌트별 poller, CompletableFuture 체인, virtual thread job이 섞여 있다면 실행 모델마다
다른 API를 쓰는 대신 하나로 통일하고 싶어집니다. 그때 bluetape4k-leader가 공통 options 타입, 공통
result 타입, 일관된 lease semantics를 제공합니다.
참고자료
섹션 제목: “참고자료”- Repository: bluetape4k-leader
LeaderElector.ktAsyncLeaderElector.ktSuspendLeaderElector.ktVirtualThreadLeaderElector.ktLeaderElectionOptions.ktLeaderRunResult.ktTenantScopedLeaderElectors.kt
마무리
섹션 제목: “마무리”실행 모델 선택은 호출 지점을 보면 대부분 자명합니다. blocking 서비스는 LeaderElector, coroutine
서비스는 SuspendLeaderElector. options와 result 타입은 어느 모델을 쓰든 동일합니다.
Part 3에서는 LeaderGroupElector를 다룹니다. 리더 하나가 아니라 N개 노드가 동시에 특정 역할을 맡아야
할 때, 그리고 단순 lock 경합 대신 순위나 가중치로 선출 노드를 고르고 싶을 때입니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.