분산 락은 여러 애플리케이션 인스턴스가 같은 자원을 동시에 바꾸지 못하도록 순서를 정합니다. 한 번만 반영돼야 하는 작업에서 이 경계가 무너지면 단순한 요청 실패를 넘어 데이터 정합성까지 흔들릴 수 있습니다.

당시 크레딧 차감, 사용량 한도인 quota 등록, 드라이브 정리 배치와 자동화 작업의 timeout 처리가 공통 RedisSpinLock helper를 사용하고 있었습니다. 락은 Redis에 key가 없을 때만 값을 저장하고 만료 시간도 함께 두는 SET NX EX로 획득했습니다. 작업이 끝나면 DEL로 key를 삭제했습니다. 모든 작업이 만료 시간 안에 끝나는 평상시에는 별다른 문제가 보이지 않았습니다.

위험은 작업 시간이 예상보다 길어질 때 드러났습니다. 먼저 시작한 요청의 TTL이 만료된 사이 다른 요청이 같은 key로 락을 다시 얻을 수 있었고, 이전 요청이 뒤늦게 unlock()을 호출하면 새 요청의 락까지 삭제될 수 있었습니다. 락을 얻지 못해 실패한 것이 아니라, 락을 갖고 있지 않은 실행 흐름이 현재 owner의 락을 지우는 문제였습니다.

이 글에서는 이 실패의 시간 순서와 공통 helper가 만든 영향 범위를 먼저 살펴봅니다. 이어서 Redis 전체가 아닌 락 획득과 해제 경로만 Redisson으로 옮긴 이유, 검증 방법과 운영 기준을 정리하고, 현재 owner만 해제하도록 바꾼 뒤에도 남는 stale owner의 write 문제를 구분합니다.

본문에서 사용하는 기준

  • owner: 현재 락을 보유한 실행 흐름
  • lease: 락이 유효하게 유지되는 시간과 갱신 방식
  • TTL: Redis key가 자동으로 만료되기까지 남은 시간
  • wait: 다른 요청이 락을 기다리는 방식
  • 임계 구역: 동시에 하나의 실행 흐름만 들어가야 하는 작업 구간

정상 경로에서는 보이지 않던 문제

처음에는 중복 실행, timeout, 남아 있는 Redis key와 긴 대기 시간이 하나의 문제처럼 보였습니다. 하지만 증상을 한꺼번에 묶으면 무엇을 고쳐야 하는지 흐려집니다. 가장 먼저 막아야 할 실패를 시간 순서로 좁혔습니다.

  1. 요청 A가 lock을 획득합니다.
  2. A의 작업이 예상보다 길어집니다.
  3. lock TTL이 먼저 만료됩니다.
  4. 요청 B가 같은 key로 새 lock을 획득합니다.
  5. A가 뒤늦게 finally에서 unlock()을 호출합니다.
  6. owner 확인 없이 DEL key를 실행하면 B의 lock이 사라집니다.
소유자 검증 없는 unlock 실패 순서원본 보기

A는 자신이 잡았던 락을 정리한다고 생각합니다. 그러나 해제 시점의 Redis key는 이미 B가 소유하고 있습니다. 과거에 락을 획득했다는 사실과 지금도 owner라는 사실은 다릅니다. 이 차이를 확인하지 않는 unlock()은 cleanup이 아니라 다른 요청의 상태를 바꾸는 작업이 됩니다.

당시 동작을 단순화하면 다음과 같습니다.

text
acquire: SET lock:key ownerA NX EX 10
release: DEL lock:key

획득할 때는 value를 저장했지만 해제할 때는 그 값을 확인하지 않았습니다. TTL 만료 뒤 owner가 바뀌어도 key 이름만 같으면 삭제할 수 있었습니다.

TTL과 polling도 같은 구현에서 문제를 키웠지만 역할은 달랐습니다. 고정 TTL은 작업이 끝나기 전에 owner가 바뀔 수 있는 조건을 만들었고, sleep(100ms) 기반 polling은 경합이 길어질수록 Redis 재시도를 늘렸습니다. 직접적인 계약 위반은 현재 owner가 아닌 흐름도 unlock할 수 있었다는 점이었습니다.

공통 helper가 넓힌 영향 범위

RedisSpinLock은 한 기능에만 쓰이는 코드가 아니었습니다. 다음과 같이 동시에 하나만 실행돼야 하는 여러 경로가 같은 helper에 의존했습니다.

  • 잔액이나 quota처럼 중복 변경을 막아야 하는 경로
  • 동일한 정리 작업의 중복 실행을 막아야 하는 배치
  • timeout과 같이 상태 전이가 한 번만 일어나야 하는 처리
  • 이후 새로 helper를 사용하는 모든 임계 구역

따라서 위험은 특정 lock key에 머물지 않았습니다. helper 하나의 잘못된 계약이 여러 중요 업무 경로로 그대로 전파될 수 있었습니다.

당시 helper의 세 책임은 서로 다른 규칙으로 움직였습니다.

text
owner: SET에 value를 넣지만 unlock에서는 확인하지 않음
lease: 고정 TTL이 끝나면 작업 진행 여부와 관계없이 만료
wait: sleep(100ms) 뒤 SET NX를 반복하는 polling

정상 경로에서 문제가 잘 보이지 않았던 이유도 여기에 있습니다. 작업이 TTL 안에 끝나고 곧바로 해제되면 세 계약이 어긋날 기회가 없습니다. 그러나 분산 락은 긴 작업, 경합, GC pause나 일시적인 네트워크 지연처럼 시간 순서가 흔들릴 때도 의미를 유지해야 합니다.

owner, lease, wait로 다시 나눈 분산 락 계약원본 보기

lock path가 지켜야 할 기준

라이브러리를 고르기 전에 lock path가 만족해야 할 조건부터 다시 세웠습니다.

  • 현재 owner만 lock을 해제할 수 있어야 합니다.
  • 작업이 계속되는 동안 lease가 먼저 끝나 임계 구역이 열리면 안 됩니다.
  • 경합 중 polling 재시도가 Redis 부하를 계속 키우지 않아야 합니다.
  • lock 획득 실패와 임계 구역 안의 비즈니스 실패를 구분할 수 있어야 합니다.
  • 변경은 lock path 안에 머물고 기존 cache와 session 경로로 번지지 않아야 합니다.

목표는 더 유명한 라이브러리를 도입하는 것이 아니었습니다. 가장 위험한 실패를 막으면서 기존 Redis 사용 경로와 호출 계약을 가능한 한 적게 바꾸는 것이었습니다.

Redisson을 선택한 이유

3열 표
대안얻는 점남는 부담
custom 구현 보수owner token, compare-and-delete Lua와 TTL 연장을 현재 코드에 추가할 수 있음owner, lease, wait 계약과 장애 상황을 계속 직접 검증해야 함
Redisson RLockowner 확인, watchdog lease 연장과 pub/sub 기반 wait를 lock path에 적용할 수 있음별도 client와 connection, subscription 운영 지표가 추가됨
fencing tokenstale owner의 후속 write까지 저장소에서 거부할 수 있음lock 호출부뿐 아니라 DB와 외부 시스템의 write 계약까지 바뀜

Redisson은 모든 분산 시스템 반례를 해결해서 선택한 것이 아닙니다. 이번 문제였던 owner-safe unlock과 lease 유지, polling 개선을 lock path 안에서 처리할 수 있었기 때문입니다.

custom 구현을 계속 보수하려면 compare-and-delete만 추가해서 끝나지 않습니다. TTL 연장 실패, 스레드 중단, client 장애와 재시도 폭주까지 직접 검증해야 합니다. 반면 fencing token은 더 강한 안전성을 제공하지만 저장소의 write 권한까지 바꿔야 하므로 이번 작업의 범위를 넘어섰습니다.

Redis 전체가 아니라 lock path만 바꾸기

기존 Spring Data Redis와 Lettuce 기반 cache, session, Streams와 Pub/Sub는 유지했습니다. Redisson은 withLock() 경로에서만 사용하고, lock 전용 client와 connection 설정도 기존 Redis client와 분리했습니다.

바뀐 계약은 세 가지입니다.

  • unlock() 전에 현재 스레드가 owner인지 확인
  • explicit leaseTime 없이 획득해 watchdog 경로 사용
  • polling loop 대신 Redisson의 wait 전략 사용

watchdog은 락을 가진 프로세스가 살아 있는 동안 lease를 주기적으로 연장하는 기능입니다. pub/sub 기반 wait는 대기 요청이 짧은 간격으로 Redis를 계속 호출하는 대신, 락이 풀렸다는 신호를 받아 다시 획득을 시도하게 합니다.

공용 wrapper의 timeout 의미는 기존 호출부와 맞췄습니다.

  • 0: 기다리지 않고 즉시 획득 시도
  • 양수: 지정한 시간 동안 획득 대기
  • 음수: 획득할 때까지 대기

양수 timeout 경로에서도 별도의 leaseTime은 넘기지 않았습니다. 호출부가 정하는 것은 락을 얼마나 기다릴지이고, 획득한 락을 얼마나 유지할지는 watchdog 계약에 맡겼습니다.

아래 코드는 실제 구조를 설명하기 위해 단순화한 예시입니다.

kotlin
fun <T> withLock(
    key: String,
    waitTime: Duration,
    action: () -> T,
): T {
    val lock = redissonClient.getLock(key)

    val acquired = when {
        waitTime.isZero -> lock.tryLock()
        waitTime.isNegative -> {
            lock.lock()
            true
        }
        else -> lock.tryLock(waitTime.toMillis(), TimeUnit.MILLISECONDS)
    }

    if (!acquired) {
        throw LockAcquisitionException(key)
    }

    try {
        return action()
    } finally {
        if (lock.isHeldByCurrentThread) {
            lock.unlock()
        }
    }
}

핵심은 finally 블록의 존재가 아닙니다. 해제 시점에도 현재 owner인지 확인한다는 점입니다. 획득과 해제를 같은 key 이름으로만 연결하지 않고 owner 계약으로 묶었습니다.

이 wrapper가 안전하게 동작하려면 호출부에도 전제가 필요합니다.

  • lock 획득, action 실행과 unlock이 같은 스레드에서 이어져야 합니다.
  • coroutine, reactive chain이나 별도 executor로 작업이 넘어가면 thread 기반 owner 계약을 다시 검토해야 합니다.
  • lock key는 보호할 자원을 안정적으로 식별해야 하며, 서로 다른 업무가 우연히 같은 key를 공유해서는 안 됩니다.
  • 음수 timeout의 무기한 대기는 종료 신호나 interrupt 정책이 있는 백그라운드 경로에서만 제한적으로 사용해야 합니다.
  • 대기 중 interrupt는 일반 acquisition timeout과 구분해 상위 호출부에 전달해야 합니다.

검증은 시간 순서를 일부러 어긋나게 했다

성능 테스트만으로는 이번 문제를 확인할 수 없습니다. 정상적으로 락을 잡고 바로 해제하는 테스트는 API가 동작한다는 사실만 보여 줍니다. 실제 반례를 재현하려면 owner가 바뀐 뒤 이전 실행 흐름이 뒤늦게 행동하도록 순서를 고정해야 합니다.

3열 표
검증 흐름기존 구현의 위험변경 후 확인
TTL 만료 뒤 B 재획득, 이후 A unlockA가 B의 lock을 삭제할 수 있음A의 unlock이 B의 lock에 영향을 주지 않음
작업 시간이 기존 TTL보다 길어짐작업 중 임계 구역이 다시 열릴 수 있음holder가 살아 있는 동안 watchdog이 lease 유지
긴 lock 경합SET NX 재시도가 Redis에 누적됨polling 반복이 줄고 대기 경로가 분리됨
lock 획득 실패비즈니스 예외와 같은 작업 실패로 보일 수 있음acquisition timeout과 내부 작업 실패를 별도로 기록

핵심 회귀 시나리오는 다음 순서로 재현했습니다.

text
1. A가 lock을 획득한다.
2. A의 작업을 지연시킨다.
3. lease 경계를 지나 B가 같은 key를 획득하는 조건을 만든다.
4. A가 뒤늦게 unlock을 호출한다.
5. B의 lock이 유지되는지 확인한다.

검증의 중심은 처리량이 아니라 시간이 어긋나도 owner의 의미가 유지되는가였습니다.

운영에서 새로 봐야 할 신호

Redisson을 도입해도 운영 부담이 사라지지는 않습니다. custom helper 안에 숨어 있던 계약이 library 설정과 지표로 이동합니다.

먼저 lock 획득 실패와 임계 구역 안의 실패를 나눠야 합니다. acquisition timeout은 작업이 시작되지 않았다는 뜻이고, business exception은 락을 얻은 뒤 도메인 처리에서 실패했다는 뜻입니다. 둘을 같은 예외로 묶으면 재시도 여부와 영향 범위를 잘못 판단할 수 있습니다.

watchdog도 항상 lease를 연장할 수 있는 것은 아닙니다. JVM 전체 실행이 잠시 멈추는 긴 STW(Stop-the-World) pause, 실행 가능한 스레드 부족, Redis connection pool 고갈이나 네트워크 단절이 생기면 연장이 늦어질 수 있습니다. 따라서 lock wait와 watchdog 상태는 애플리케이션 executor와 Redis client 지표를 함께 봐야 합니다.

pub/sub 기반 wait는 polling 재시도 부담을 줄이는 대신 subscription 연결을 사용합니다. lock 전용 client의 connection 수와 subscription 사용량, lock 획득 시간, acquisition timeout, owner mismatch와 watchdog 연장 실패를 같은 흐름에서 관찰해야 합니다.

안전한 unlock 뒤에도 stale owner는 남는다

이번 변경 이후에도 남는 반례원본 보기

owner가 아닌 흐름의 unlock은 막았지만 분산 락의 모든 반례가 사라진 것은 아닙니다.

  1. A가 lock을 얻고 DB row나 외부 시스템 상태를 읽습니다.
  2. A가 긴 GC pause 또는 네트워크 분리를 겪습니다.
  3. watchdog 연장이 멈추고 lease가 만료됩니다.
  4. B가 lock을 얻어 더 새로운 상태로 작업을 마칩니다.
  5. A가 다시 실행되며 이전에 읽은 상태를 기준으로 write를 시도합니다.

이때 A가 unlock()을 호출해도 B의 lock은 지워지지 않습니다. 그러나 A의 오래된 write까지 자동으로 막히는 것은 아닙니다. lock correctness와 write authority는 서로 다른 문제이기 때문입니다.

  • lock correctness: 현재 owner가 누구이며 누가 unlock할 수 있는가
  • write authority: 시간이 지난 뒤에도 해당 실행 흐름이 write할 자격이 있는가

stale owner의 write까지 막으려면 저장소가 더 새로운 실행만 받아들이도록 해야 합니다. fencing token은 새 owner에게 계속 증가하는 번호를 부여하고, 저장소가 더 오래된 번호의 write를 거부하도록 만드는 방식입니다. optimistic version check나 conditional update도 읽었던 version이 여전히 유효할 때만 변경을 허용합니다.

이번 작업에서는 저장소 계약까지 넓히지 않았습니다. 우선순위는 owner가 아닌 흐름의 unlock과 고정 TTL에서 생긴 직접적인 계약 위반을 제거하는 데 있었습니다.

분산 락을 다시 볼 때의 순서

비슷한 문제가 생기면 lock key의 존재 여부만 확인하지 않습니다.

  1. 현재 owner가 누구인지
  2. unlock을 호출한 흐름이 같은 owner인지
  3. lease가 watchdog으로 정상 연장됐는지
  4. 대기 요청이 예상한 wait 경로를 탔는지
  5. 획득 실패와 임계 구역 내부 실패가 분리됐는지
  6. 문제가 unlock이 아니라 stale write라면 저장소 계약까지 확인해야 하는지

이번 전환의 핵심은 Redisson이라는 이름이 아니었습니다. custom helper 안에서 따로 움직이던 owner, lease와 wait를 하나의 lock contract로 다시 묶고, 시간 순서가 어긋나는 상황에서도 그 의미가 유지되는지 검증한 데 있습니다.

참고