콘텐츠로 이동

Bluetape4k Leader Part 2: 핵심 API와 실행 모델

로봇 심사위원들이 Kotlin JVM 작업대 위에서 실행 모델 후보들을 비교하는 일러스트
리더 선출 API의 실행 모델은 호출자의 동시성 모델에 맞아야 합니다.

이 글은 bluetape4k-leader 시리즈의 2편입니다. Part 1에서 저장소 구조와 기본 모델을 살펴봤다면, 이번에는 실제 API를 다룹니다. 네 가지 선출자 인터페이스와 LeaderElectionOptions, LeaderRunResult, 테넌트 네임스페이스, 상태 스냅샷까지 leader-core의 핵심 API를 살펴봅니다.

leader-core의 모든 선출자는 runIfLeader 메서드를 공유합니다. 계약은 다음과 같습니다.

  • 잠금을 획득한 노드만 작업을 실행합니다.
  • 잠금을 획득하지 못하면 작업을 실행하지 않고 null을 반환합니다.
  • 작업이 던진 예외는 호출자에게 그대로 전파됩니다.
  • 잠금은 작업이 끝나면 해제되고, 정상적으로 해제하지 못하면 리스가 만료될 때 사라집니다.

블로킹 인터페이스는 다음과 같습니다.

interface LeaderElector {
fun <T> runIfLeader(
lockName: String,
action: () -> T,
): T?
}

반환 타입은 T?입니다. null이면 잠금 경합으로 작업이 실행되지 않았다는 뜻입니다. ShedLock처럼 잠금을 획득하지 못하면 건너뜁니다. 경합은 오류가 아니라 예상된 실행 결과입니다.

관련 소스: LeaderElector.kt

작업 자체가 null을 반환할 수 있으면 의미가 모호해집니다. 잠금을 획득하지 못한 것인지, 리더로 실행한 작업이 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 { "다른 노드가 잠금 보유 중, 건너뜀" }
is LeaderRunResult.ActionFailed -> log.error(result.cause) { "작업 실행 중 예외 발생" }
}

CancellationExceptionActionFailed로 감싸지 않고 그대로 전파합니다. 그래야 코루틴의 구조화된 동시성이 취소 신호를 정상적으로 처리할 수 있습니다.

관련 소스: LeaderRunResult.kt

같은 서비스에서도 호출 지점마다 동시성 모델이 다릅니다. 리더 선출 때문에 호출자의 실행 모델을 바꿀 필요는 없습니다.

모델인터페이스작업 타입반환 타입적합한 상황
블로킹LeaderElector() -> TT?MVC, 배치, 일반 JVM
CompletableFutureAsyncLeaderElector() -> CompletableFuture<T>CompletableFuture<T?>Java 비동기 어댑터
코루틴SuspendLeaderElectorsuspend () -> TT?Ktor, R2DBC, WebFlux
가상 스레드VirtualThreadLeaderElector() -> TVirtualFuture<T?>Java 21 가상 스레드
val result: String? = leaderElector.runIfLeader("report-job") {
generateReport()
}

호출 스레드를 블로킹해도 되는 곳에서 사용합니다. Spring MVC 컨트롤러, Quartz 작업, @Scheduled 메서드가 대표적입니다.

val future: CompletableFuture<String?> = asyncLeaderElector.runAsyncIfLeader("report-job") {
CompletableFuture.supplyAsync { generateReport() }
}
future.thenAccept { result -> log.info { "result=$result" } }

AsyncLeaderElectorCompletableFuture 완료 시점과 잠금 생명주기를 연결합니다. 반환한 CompletableFuture가 완료될 때까지 잠금을 유지하므로 비동기 작업이 리더 선출 경계에서 분리되지 않습니다.

val result: String? = suspendLeaderElector.runIfLeader("report-job") {
generateReportSuspend()
}

SuspendLeaderElectorsuspend 람다를 그대로 받으므로 래퍼나 runBlocking이 필요하지 않습니다. 잠금을 보유한 코루틴이 취소되면 CancellationException이 즉시 전파되고 잠금이 해제됩니다. 작업 코드에서 취소 예외를 삼키면 정상 종료가 지연되므로 취소 신호를 그대로 전파해야 합니다.

val future: VirtualFuture<String?> = virtualThreadLeaderElector.runAsyncIfLeader("report-job") {
generateReport()
}
// 현재 가상 스레드가 완료를 기다림
val result = future.await()
// 또는 Java API와 연동
val cf: CompletableFuture<String?> = future.toCompletableFuture()

VirtualFuture.await()는 플랫폼 스레드를 점유하지 않고 호출한 가상 스레드를 대기 상태로 전환합니다. 기존 Java 비동기 파이프라인과 연동할 때는 toCompletableFuture()로 변환합니다.

runIfLeader가 리더 선출 성공과 잠금 경합 시 건너뛰기를 처리하는 시퀀스 다이어그램
리더로 선출되면 작업을 실행해 결과를 반환하고, 잠금 경합이 발생하면 오류 없이 null을 반환합니다.

관련 소스: AsyncLeaderElector.kt, SuspendLeaderElector.kt, VirtualThreadLeaderElector.kt

네 가지 인터페이스 모두 같은 LeaderElectionOptions를 씁니다. 실행 모델이 달라도 옵션 표현은 동일합니다.

프로퍼티기본값의미
waitTime5s잠금 획득을 포기하기 전까지 기다리는 최대 시간
leaseTime60s한 번 부여되는 리더십 리스의 TTL
nodeIdJVM 프로세스 ID잠금을 보유한 노드의 식별자
minLeaseTime0s작업이 일찍 끝나도 잠금을 유지하는 최소 시간
autoExtendfalse장시간 실행하는 작업의 리스를 자동 갱신할지 여부
useDbTimefalse애플리케이션 노드 대신 DB 서버 시계를 사용할지 여부
val options = LeaderElectionOptions(
waitTime = 10.seconds,
leaseTime = 120.seconds,
nodeId = "api-server-1",
minLeaseTime = 5.seconds,
autoExtend = true,
)

waitTimeleaseTime — 두 값은 서로 다른 경계를 제어합니다. waitTime은 잠금 획득을 기다리는 최대 시간입니다. 다른 노드가 잠금을 보유한 채 waitTime이 지나면 runIfLeadernull을 반환합니다. leaseTime은 한 번 부여되는 리더십 리스의 TTL입니다. 노드가 종료되어 잠금을 해제하지 못하더라도 리스가 만료되면 다른 노드가 잠금을 획득할 수 있고, autoExtend = true이면 지원되는 단일 리더 백엔드가 작업 실행 중 리스를 갱신합니다.

minLeaseTime — 작업이 빨리 끝나도 잠금을 최소 N초 동안 유지합니다. 로컬 선출자는 해제 전에 기다리고, 지원되는 분산 백엔드는 남은 최소 리스 시간을 저장소 TTL에 위임하므로 호출자가 즉시 반환될 수 있습니다.

autoExtend — 작업이 leaseTime보다 오래 걸릴 수 있을 때 사용합니다. true이면 백그라운드에서 리스를 주기적으로 갱신합니다. 정산 배치나 마이그레이션처럼 실행 시간이 가변적인 작업에 적합합니다.

useDbTime — DB 기반 구현에서 노드 간 시계 편차를 피할 때 사용합니다. true이면 잠금 타임스탬프를 DB 서버 시계로 비교합니다.

forTenant()는 선출자를 감싸 모든 lockName에 테넌트 접두사를 자동으로 붙입니다. 반환된 선출자는 원본과 같은 인터페이스를 제공합니다.

백엔드에 저장되는 키 형식은 tenant:{tenantId}:{lockName}입니다.

tenant:acme:report-job
tenant:beta-corp:report-job
val acmeElector = leaderElector.forTenant("acme")
val result = acmeElector.runIfLeader("report-job") {
generateReportForTenant("acme")
}

두 테넌트가 같은 "report-job" 이름을 사용해도 백엔드 키가 다르므로 서로 경합하지 않습니다.

콜론(:)은 구분자이므로 테넌트 ID나 잠금 이름에 사용할 수 없으며, 접두사를 포함한 전체 이름은 일반적인 백엔드 키 제한에 맞춰 255자를 넘지 않아야 합니다. SaaS 서비스에서 테넌트마다 같은 정산 작업을 독립적으로 실행할 때 사용할 수 있습니다.

LeaderElectionState.state(lockName)은 현재 선출 상태의 스냅샷을 반환합니다.

val s = leaderElector.state("batch-lock")
if (s.isOccupied) {
println("현재 리더: ${s.leader?.leaderId}")
}

LeaderStatelockName, status(Empty 또는 Occupied), 잠금 보유자 정보를 담습니다.

중요한 제약은 상태가 최선 노력 방식의 스냅샷이라는 점입니다. 이 값으로 작업 실행 여부를 결정하면 안 됩니다. 조회한 직후에도 상태가 바뀔 수 있습니다. 실행 결정은 항상 runIfLeader의 원자적 잠금 획득 경로를 거쳐야 합니다.

스냅샷은 현재 리더를 보여주는 모니터링 대시보드, 관리 API, /actuator/leader 같은 진단 엔드포인트에 적합합니다. 동시성 제어가 아니라 상태 관찰이 목적일 때 사용해야 합니다.

Part 1에서 전체 그림을 비교했습니다. 여기서는 Kotlin 코드 레벨에서 실제로 달라지는 지점만 봅니다:

항목ShedLockbluetape4k-leader
suspend 함수 작업래퍼나 어댑터 필요SuspendLeaderElectorsuspend 작업을 직접 받음
CompletableFuture 작업CompletableFuture 완료까지 잠금을 유지하도록 직접 처리AsyncLeaderElectorCompletableFuture 완료와 잠금 생명주기를 연결
가상 스레드 작업보통 호출 지점에서 일반 블로킹 코드 사용VirtualThreadLeaderElectorVirtualFuture 반환
취소 / CancellationException외부 코루틴 범위가 처리취소 시 잠금 해제 후 CancellationException 재전파
null 허용 작업 반환값true/false 결과나 사용자 정의 래퍼 필요LeaderRunResult.ElectedSkipped로 구분
건너뛰기 의미@SchedulerLock은 잠금 획득 실패 시 건너뜀runIfLeadernull, 결과 API는 Skipped 반환

Spring MVC 애플리케이션의 @Scheduled 메서드 하나만 보호한다면 ShedLock이 더 단순한 선택입니다. 반면 서비스에 코루틴 작업자, 테넌트별 폴러, CompletableFuture 체인, 가상 스레드 작업이 함께 있다면 bluetape4k-leader로 공통 옵션 타입과 결과 타입, 일관된 리스 의미를 사용할 수 있습니다.

실행 모델은 호출 지점의 동시성 모델에 맞춰 선택합니다. 블로킹 서비스는 LeaderElector, 코루틴 서비스는 SuspendLeaderElector를 사용하며 옵션과 결과 타입은 네 실행 모델에서 동일합니다.

Part 3에서는 LeaderGroupElector를 다룹니다. 리더 하나가 아니라 N개 노드가 동시에 특정 역할을 맡아야 할 때, 그리고 단순 잠금 경합 대신 순위나 가중치로 실행 노드를 선택해야 할 때 사용하는 API입니다.

댓글

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