콘텐츠로 이동

여러 서버가 같은 작업을 실행하지 않게 만드는 방법

세 서비스 노드가 테넌트별 작업과 하나의 마이그레이션 게이트를 중앙 조정 콘솔로 관리하는 어두운 미니어처 작업대
같은 서버가 여러 대라는 사실보다, 어떤 작업끼리 충돌하는지를 먼저 봐야 합니다.

롤링 배포가 끝나자 세 서버가 동시에 스케줄러를 시작했다고 해보겠습니다. 세 서버 모두 테넌트별 통계를 집계하고, 시작할 때 스키마 마이그레이션도 실행합니다. 단일 서버에서는 멀쩡했던 코드가 이제 같은 집계를 세 번 돌리고 같은 DDL을 거의 동시에 실행합니다.

if (isPrimaryNode) 같은 설정으로 한 서버만 고정하면 당장은 조용해집니다. 하지만 그 서버가 멈추면 작업도 멈춥니다. 반대로 클러스터 전체에 리더 하나만 두면 마이그레이션은 안전해 보여도 서로 무관한 테넌트 집계까지 한 줄로 서게 됩니다.

이 글은 bluetape4k-leadertenant-aggregatormigration-gate 예제를 중심으로, 현실에서 부딪히는 두 작업을 어떻게 나눌지 살펴봅니다. 핵심은 “누가 리더인가”보다 먼저 “어떤 작업끼리 동시에 실행되면 안 되는가”를 정하는 것입니다.

먼저 동시에 실행되면 안 되는 작업을 조정 키로 묶는다

섹션 제목: “먼저 동시에 실행되면 안 되는 작업을 조정 키로 묶는다”

리더 선출은 같은 조정 키를 놓고 경쟁하는 호출자 가운데 한 소유자만 실행하도록 만듭니다. 따라서 키는 서버 이름이 아니라 충돌하는 작업의 범위를 나타내야 합니다.

  • 테넌트 A 집계끼리는 겹치면 안 된다: tenant-aggregator:metrics-tenant-A
  • 테넌트 B 집계는 테넌트 A와 동시에 돌아도 된다: tenant-aggregator:metrics-tenant-B
  • 스키마 마이그레이션은 클러스터 전체에서 하나만 진행해야 한다: schema-v3
서비스 노드 A, B, C가 테넌트 A, 테넌트 B, 전역 마이그레이션 조정 키를 거쳐 각각의 보호 상태를 실행하는 어두운 배경의 아키텍처 다이어그램
같은 키는 직렬화하고 다른 키는 병렬로 실행합니다. 완료 마커는 잠금 소유권이 아니라 영속 상태에 속합니다.

모든 작업에 global-leader 하나를 쓰면 이해하기는 쉽습니다. 대신 테넌트 A의 느린 집계가 테넌트 B를 막고, 마이그레이션을 기다리는 동안 정기 작업도 멈춥니다. 키를 지나치게 잘게 쪼개면 반대 문제가 생깁니다. 같은 데이터를 갱신하는 작업이 서로 다른 키를 사용하면 선출에 성공한 두 작업이 동시에 들어옵니다. 좋은 키는 인스턴스 수가 늘어나도 같은 업무상 충돌 범위를 가리킵니다.

테넌트별 집계는 같은 테넌트만 직렬화한다

섹션 제목: “테넌트별 집계는 같은 테넌트만 직렬화한다”

TenantAggregator는 각 테넌트마다 하위 코루틴과 SuspendLeaderElector를 하나씩 만듭니다. 실제 잠금 이름은 다음처럼 조립됩니다.

private suspend fun tenantLoop(tenantId: String) {
val lockName = "${options.lockNamePrefix}-$tenantId"
val elector = electorFactory(lockName, electionOptions)
while (currentCoroutineContext().isActive) {
elector.runIfLeader(lockName) {
runAggregate(tenantId)
}
delay(options.pollInterval)
}
}

여기서 LeaderGroupElector로 리더 슬롯을 여러 개 두지 않은 이유도 범위와 관련이 있습니다. 슬롯 방식은 어느 테넌트가 어느 슬롯에 배정될지 제어하지 못합니다. 예제는 테넌트마다 독립된 잠금 이름을 사용해 “같은 테넌트는 하나, 다른 테넌트는 병렬”이라는 규칙을 코드에 그대로 남깁니다.

테스트도 이름만 보고 끝내지 않고 동시 실행 계약을 직접 셉니다. 세 집계 작업을 시작하고, 각 테넌트의 실행 구간에 진입한 코루틴 수가 1을 넘는지 기록합니다. 모든 테넌트가 한 번 이상 처리된 뒤에도 위반 횟수는 0이어야 합니다.

// arrange
val aggregators = createAggregators(nodes = 3, tenants = DEFAULT_TENANTS)
val concurrentRunners = trackConcurrentRunnersByTenant()
// act
aggregators.forEach { it.start(scope) }
waitUntil { DEFAULT_TENANTS.all { aggregateCount(it) >= 1 } }.shouldBeTrue()
delay(2.seconds) // 추가 cycle에서도 겹치는지 확인
// assert
concurrentRunners.violations.get() shouldBeEqualTo 0
DEFAULT_TENANTS.forEach {
aggregateCount(it) shouldBeGreaterOrEqualTo 1
}

집계 함수가 한 번 실패했다고 폴링 전체를 끝내지도 않습니다. aggregateFunction의 일반 예외는 해당 실행 주기에서 격리하고 다음 주기를 계속합니다. 반면 CancellationException은 다시 던집니다. 종료 요청까지 삼켜 버리면 stopGracefully가 작업자를 멈추지 못하기 때문입니다. 한 테넌트의 실패가 다른 테넌트의 코루틴까지 취소하지 않도록 최상위 범위는 supervisorScope를 사용합니다.

이 설계가 보장하는 것은 같은 테넌트의 동시 실행 방지입니다. 처리 함수가 외부 시스템에 값을 쓴 직후 프로세스가 중단됐다면 다음 리더가 같은 입력을 다시 처리할 수 있습니다. 집계 기준 시점, 체크포인트, 업서트 같은 재실행 계약은 별도로 필요합니다.

마이그레이션은 전역 잠금과 완료 마커가 모두 필요하다

섹션 제목: “마이그레이션은 전역 잠금과 완료 마커가 모두 필요하다”

마이그레이션은 테넌트별 집계와 충돌 범위가 다릅니다. 같은 스키마를 바꾸는 세 인스턴스가 서로 다른 키를 가질 이유가 없습니다. 모든 인스턴스가 schema-v3 같은 하나의 키를 두고 경쟁해야 합니다.

하지만 전역 잠금만으로는 부족합니다. 잠금은 “지금 누가 실행 중인가”를 알려 줄 뿐, schema-v3가 지난 배포에서 이미 끝났다는 사실을 영구 보존하지 않습니다. MigrationGate는 완료 마커를 세 번 확인할 수 있는 순서로 이 빈틈을 줄입니다.

fun runMigration(id: String): Outcome {
if (isApplied(id)) return Outcome.AlreadyApplied(id) // precheck
val outcome = elector.runIfLeader(options.lockName) {
if (isApplied(id)) return@runIfLeader Outcome.AlreadyApplied(id)
migration() // marker 포함
Outcome.Migrated(id, elapsedMs())
}
if (outcome != null) return outcome
return if (isApplied(id)) Outcome.AlreadyApplied(id) // post-skip
else Outcome.Skipped(id, "lock not acquired")
}

사전 확인은 이미 끝난 마이그레이션이 잠금을 다시 기다리지 않게 합니다. 잠금 안의 재확인은 사전 확인 직후 다른 인스턴스가 완료한 경우를 잡습니다. 잠금을 얻지 못한 인스턴스는 마지막으로 마커를 확인해 AlreadyAppliedSkipped를 구분합니다. 마커 조회 자체가 실패하면 “아직 적용되지 않음”으로 추정하지 않고 Outcome.Failed를 반환합니다. 상태를 모르는 것과 미적용 상태는 다릅니다.

세 인스턴스를 동시에 호출하는 테스트의 기대값도 이 순서와 맞습니다.

// arrange + act
val outcomes = runConcurrently(instances = 3) {
gate.runMigration(
migrationId = migrationId,
isApplied = { markerExists(migrationId) },
migration = { migrateAndMarkApplied(migrationId) },
)
}
// assert
migrationCalls.get() shouldBeEqualTo 1
outcomes.count { it is Outcome.Migrated } shouldBeEqualTo 1
outcomes.count { it is Outcome.AlreadyApplied } shouldBeEqualTo 2

여기에는 중요한 조건이 있습니다. 이 예제의 MigrationGateautoExtend를 설정하지 않으므로 리스를 자동 연장하지 않습니다. 마이그레이션이 leaseTime보다 오래 걸리면 다른 인스턴스가 같은 잠금을 다시 얻을 수 있습니다. 그러므로 마이그레이션은 재실행해도 안전해야 하며, 가능한 경우 스키마·데이터 변경과 완료 마커 기록을 같은 트랜잭션 안에 둬야 합니다. “리더가 실행했으니 정확히 한 번”이라는 결론은 이 코드에서 나오지 않습니다.

리스가 끝나면 다음 후보가 이어받을 수 있다

섹션 제목: “리스가 끝나면 다음 후보가 이어받을 수 있다”

리스가 있는 잠금은 소유자가 명시적으로 반환하지 못해도 영원히 남지 않습니다. 노드 A가 멈추면 노드 B는 먼저 busy를 받고, 리스가 끝난 뒤 새 토큰으로 소유권을 얻습니다.

노드 A가 리스를 얻어 작업하다 실패하고 노드 B가 리스 만료 후 새 토큰으로 인계하는 어두운 배경의 시퀀스 다이어그램
리스 만료는 다음 후보의 진입을 허용합니다. 이전 실행의 외부 효과를 없애 주지는 않습니다.

이 인계에는 세 가지 오해가 자주 따라옵니다.

  • 즉시 장애 인계가 아니다. 다음 후보는 잠금 반환 또는 리스 만료를 기다립니다.
  • 정확히 한 번 실행을 보장하지 않는다. 노드 A가 외부 쓰기를 끝내고 결과를 기록하기 전에 중단되면 노드 B가 같은 일을 다시 할 수 있습니다.
  • 긴 작업이 자동으로 안전해지지 않는다. 작업 시간이 리스를 넘을 수 있다면 연장, 펜싱 토큰, 체크포인트, 멱등성 가운데 필요한 장치를 더해야 합니다.

TenantAggregatorTest는 노드 A가 실행을 시작한 뒤 노드 B를 대기시키고, A를 종료한 다음 B의 실행 횟수가 증가하는지 확인합니다. 이 테스트가 증명하는 것은 후보가 이어받아 폴링을 계속한다는 계약입니다. 이미 발생한 외부 효과가 한 번뿐이라는 계약까지 증명하지는 않습니다.

캐시 예열과 웹훅 폴링은 또 다른 범위를 가진다

섹션 제목: “캐시 예열과 웹훅 폴링은 또 다른 범위를 가진다”

같은 리더 API를 써도 작업 상태를 어디에 두느냐에 따라 설계가 달라집니다.

cache-warmer는 캐시 파티션마다 잠금을 나눕니다. 한 파티션의 예열이 실패해도 다른 파티션은 계속할 수 있습니다. 테넌트 집계와 마찬가지로 병렬성이 필요하지만, 키는 테넌트가 아니라 캐시 파티션입니다.

webhook-poller는 폴링 리더를 하나로 제한하는 데서 끝나지 않습니다. DB 행을 원자적으로 선점하고 선점 리스가 지난 행을 다시 가져오며, maxAttempts에 도달하면 FAILED로 끝냅니다. 리더 선출은 폴링 작업이 겹치는 범위를 줄이고, 영속 행 상태는 최소 한 번 전송 재시도를 책임집니다.

정리하면 조정 키는 실행 입구를 보호합니다. 진행률과 완료 여부, 재시도 횟수, 실패 종결 상태는 작업에 맞는 영속 상태가 맡아야 합니다.

공급자는 장애가 났을 때 필요한 동작으로 고른다

섹션 제목: “공급자는 장애가 났을 때 필요한 동작으로 고른다”

공급자를 고를 때 단순한 성능 순위를 만들면 중요한 조건이 빠집니다. 이미 운영 중인 인프라, 일관성과 리스 모델, 장애 복구 방식, 관측 가능성, 팀이 감당할 운영 비용을 함께 봐야 합니다.

시나리오조정 키필요한 상태고려할 공급자 계열주의점
테넌트 집계테넌트 ID별 키체크포인트 또는 집계 기준 시점Redis, RDB/Exposed, etcd, Consul, ZooKeeper, DynamoDB테넌트 수와 폴링 주기가 백엔드 부하가 됨
스키마·데이터 마이그레이션마이그레이션 버전별 전역 키적용 완료 마커RDB/Exposed, Kubernetes Lease, etcd, Consul, ZooKeeper작업 시간이 리스를 넘는지 확인하고 마이그레이션을 멱등하게 설계
캐시 예열캐시 파티션별 키파티션별 완료·실패 기록Redis, RDB/Exposed, Kubernetes Lease실패한 파티션이 다른 파티션을 막지 않게 격리
웹훅 폴링폴링 작업 또는 샤드 키선점 리스, 시도 횟수, 종결 상태RDB/Exposed + 요구사항에 맞는 선출 공급자리더 선출과 행 선점이 해결하는 문제가 다름
Kubernetes 안의 단일 작업네임스페이스와 작업을 포함한 키필요하면 별도 완료 상태Kubernetes Leasek8s-lease는 K3s로 동작을 보여 주는 예제이며 별도 운영 백엔드 모듈을 뜻하지 않음

Redis가 이미 핵심 인프라이고 짧은 잠금을 많이 다룬다면 자연스러운 선택일 수 있습니다. 작업 데이터와 같은 데이터베이스에 완료 상태를 남겨야 한다면 RDB가 이해하기 쉬운 경계를 줄 수 있습니다. Kubernetes 밖에서도 같은 조정 계약이 필요한지, 제어 영역 장애가 애플리케이션 작업에 어떤 영향을 줄지에 따라 Kubernetes Lease의 적합성은 달라집니다. etcd, Consul, ZooKeeper, DynamoDB도 “지원 목록에 있다”는 이유보다 현재 시스템의 장애 모델과 운영 역량으로 좁혀야 합니다. 백엔드별 구현과 벤치마크 조건은 Part 5에서 따로 다룹니다.

리더가 자주 바뀌는지도 운영 신호다

섹션 제목: “리더가 자주 바뀌는지도 운영 신호다”

정상일 때 한 노드가 실행된다는 사실만 확인해서는 부족합니다. 획득 시도가 계속되는데 성공이 없거나 백엔드 오류가 늘어나는지, 실행 시간이 리스에 가까워지는지, 활성 게이지가 예상 범위를 벗어나는지 봐야 합니다.

prometheus-dashboard/actuator/prometheus에서 leader_aop_attempts_total, leader_aop_acquired_total, leader_aop_active, execution duration을 노출하고, 획득 부재·백엔드 오류·작업 실패·긴 실행 시간을 경보 예제로 연결합니다. leader_aop_active는 JVM 로컬 값이므로 클러스터 대시보드에서는 sum보다 max by (lock_name) (leader_aop_active)가 맞습니다.

잠금 이름에는 테넌트, 사용자, URL처럼 민감하거나 카디널리티가 큰 값이 들어갈 수 있습니다. 예제가 메트릭 레이블을 기본 REDACT로 두는 이유입니다. 필요한 상관관계만 길이가 제한된 해시나 별도 저카디널리티 태그로 드러내야 합니다. 또한 현재 LockExtender 자체의 성공·실패 메트릭은 제공되지 않습니다. 실행 시간이 리스에 가까워졌다는 경보는 연장 실패를 직접 관측한 값이 아니라 보수적인 징후입니다.

리더 선출만으로 막을 수 없는 여섯 가지 상황

섹션 제목: “리더 선출만으로 막을 수 없는 여섯 가지 상황”

지금까지의 예제는 조정 키, 리스, 완료 마커를 어디에 둘지 보여 줍니다. 하지만 여기서 설명한 규칙을 적용해도 아래 상황까지 자동으로 안전해지지는 않습니다. 이는 리더 선출의 결함이라기보다 작업 자체에 별도의 계약이 필요한 경우입니다.

  • 서로 다른 작업이 같은 데이터를 갱신한다. 일일 집계는 daily-aggregate, 과거 데이터 재처리는 aggregate-backfill이라는 키를 쓴다고 해보겠습니다. 이름은 달라도 같은 요약 행을 갱신한다면 두 작업은 동시에 실행될 수 있습니다. 작업 이름이 아니라 실제 쓰기 충돌을 기준으로 키를 공유하거나, DB 제약과 상태 전이로 충돌을 막아야 합니다.
  • 작업이 리스보다 오래 실행된다. 리스가 30초인데 집계가 2분 걸리면 이전 소유자가 아직 쓰는 동안 다음 소유자가 진입할 수 있습니다. 리스 연장만 믿기보다 연장 실패를 포함해 펜싱, 체크포인트, 멱등성을 조합해야 합니다. 펜싱 토큰도 대상 저장소가 토큰을 검사할 때만 오래된 소유자를 막을 수 있습니다.
  • 실행 중 테넌트 목록이 바뀐다. 현재 TenantAggregatorstart() 시점의 options.tenants마다 코루틴을 만듭니다. 시작 뒤 추가된 테넌트에는 작업자가 생기지 않고, 삭제된 테넌트의 작업자는 자동으로 정리되지 않습니다. 운영 환경에서는 원하는 테넌트 목록과 실행 중인 작업자를 주기적으로 맞추고 안전하게 추가·중지해야 합니다.
  • 여러 리전에 걸쳐 실행되거나 네트워크가 단절된다. 이 글의 예제는 리전 간 지연이나 네트워크 분할에서 각 백엔드가 어떤 결과를 내는지 검증하지 않습니다. 백엔드 연결이 끊겼을 때 실행 중인 작업을 중단할지, 새로운 획득 시도를 실패로 처리할지, 복구 뒤 언제 다시 시도할지를 정하고 실제 장애 조건에서 확인해야 합니다.
  • 서로 다른 버전의 작업자가 함께 실행된다. 롤링 배포 중 v1과 v2가 같은 키를 사용해도 한 번에 한 소유자만 선택될 뿐, 두 버전이 마커와 페이로드를 같은 의미로 해석한다는 보장은 없습니다. 그렇다고 버전마다 키를 나누면 두 버전이 동시에 실행될 수 있습니다. 스키마 호환성, 마커 버전, 드레인 순서를 배포 계약에 포함해야 합니다.
  • 외부 효과가 펜싱 토큰을 받지 않는다. 결제 API, 메일 서버, 외부 웹훅은 보통 내부 리더 토큰을 검증해 주지 않습니다. 이전 소유자가 늦게 요청을 보내면 리스가 끝난 뒤에도 효과가 남을 수 있습니다. 이 경계에는 멱등 키, 영속 아웃박스, 결과 조회와 정합성 복구가 필요합니다.

배포 전에는 다음 질문을 확인하는 편이 좋습니다.

  • 이름이 다른 작업이 같은 상태를 갱신하지 않는가?
  • 테넌트나 샤드 목록이 실행 중 바뀌는가?
  • GC 일시 정지와 네트워크 지연까지 포함한 최악 실행 시간이 리스보다 짧은가?
  • 중복 실행된 외부 호출의 결과를 식별하고 정합성을 복구할 수 있는가?
  • 구버전과 신버전이 키, 마커, 페이로드를 같은 의미로 해석하는가?
  • 선택한 백엔드의 지연, 연결 단절, 복구 조건을 실제로 시험했는가?

운영에서 보고 싶은 것은 한 번의 선출 성공이 아닙니다. 획득 실패가 이어지는가, 리더가 예상보다 자주 바뀌는가, 작업 시간이 리스 시간 예산을 침범하는가, 인계 후 같은 외부 효과가 반복되는가를 함께 봐야 합니다.

리더 선출은 여러 서버가 같은 순간에 소유권을 주장하는 문제를 줄여 줍니다. 그다음 안전성은 조정 키의 범위, 리스보다 긴 작업의 처리, 영속 마커, 멱등성, 관측 지표가 함께 만듭니다. 구현을 시작할 때 공급자 목록부터 열기보다 “같이 실행되면 안 되는 작업은 무엇이고, 실패 뒤 무엇이 남아야 하는가”를 먼저 파악하면 설계가 훨씬 구체적으로 좁혀집니다.

이 글은 위 상황에서 확인해야 할 경계까지만 다룹니다. 후속 예제 요구사항은 bluetape4k-workshop 이슈 #548에 기록돼 있습니다. 다만 이 이슈는 현재 종료됐고 해당 통합 예제는 구현되지 않았습니다. 작업 간 충돌, 리스 만료 후 중복 실행, 동적인 테넌트 변경, 네트워크 분할, 혼합 버전 배포, 외부 시스템의 중복 효과는 여전히 별도 검증이 필요한 범위입니다.

댓글

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