분산 락에서 더 비싼 실패는 락을 못 잡는 순간이 아닙니다. 이미 끝난 줄 알았던 요청이, 지금 막 실행 중인 다른 요청의 락을 지워버리는 순간입니다.

custom Redis spin lock이 정확히 그랬습니다. 크레딧 차감과 quota 등록처럼 동시에 하나만 실행돼야 하는 경로에서, TTL 만료와 재획득이 겹치면 먼저 끝난 요청이 뒤 요청의 락을 지울 수 있었습니다. 겉으로는 드문 race condition처럼 보여도, 한 번만 발생하면 임계 구역의 의미가 바로 무너집니다.

중요했던 건 “락이 조금 이상했다”가 아니라, 이 계약이 여러 임계 구역에 동시에 퍼져 있었다는 점이었습니다. 한 번의 잘못된 unlock이 크레딧, quota, 배치 같은 서로 다른 경로에서 같은 방식으로 반복될 수 있었습니다.

이 글은 Redis 전체를 다시 설계한 이야기가 아닙니다. cache·session·streams는 그대로 둔 채, correctness가 문제였던 lock path만 어떻게 정리했는지에 더 가깝습니다.

이 경로도 한 번에 여기까지 온 건 아니었습니다. 예전에는 경합 상황에서 무조건 실패하지 않게 timeout을 붙이는 식의 보강이 먼저 들어갔고, 그 뒤에는 실제로 쓰이지 않는 auto-renewing helper가 정리되면서 lock wrapper의 의미가 한 번 더 단순해졌습니다. 결국 owner, lease, wait strategy를 같은 계약으로 다시 맞추려면 custom path를 조금씩 땜질하는 것보다 lock 경로 자체를 바꾸는 편이 더 낫다는 쪽으로 결론이 모였습니다.

위험은 조용히 숨어 있었습니다

공통 helper 하나의 계약이 여러 임계 구역에 동시에 전파되는 구조
공통 helper 하나의 계약이 여러 임계 구역에 동시에 전파되는 구조

RedisSpinLock은 한 군데에서만 쓰이는 특수 구현이 아니었습니다. 크레딧 차감, 드라이브 정리 배치, 자동화 타임아웃, 터널 quota 등록처럼 동시에 하나만 실행돼야 하는 경로에 공통 helper로 묶여 있었습니다.

그래서 이 문제는 “락 하나가 조금 불편하다”는 수준이 아니었습니다. owner를 어떻게 증명하는지, lease를 어떻게 유지하는지, 기다리는 요청을 어떤 방식으로 깨우는지 같은 계약이 helper 하나를 통해 여러 임계 구역에 동시에 전파되고 있었습니다.

현재 구현은 세 단계로 끝났습니다.

  • 획득: SET key value NX EX ttl
  • 해제: DEL key
  • 대기: sleep(100ms) 기반 polling loop

겉보기에 단순하다는 사실이 문제는 아니었습니다. 문제는 owner, lease, wait strategy가 서로 다른 의미로 분리돼 있었다는 점이었습니다. owner 확인 없이 DEL로 해제하는 순간부터 분산 락은 “내 락을 푼다”보다 “그 key에 있는 락을 지운다”에 가까워졌습니다. 락의 주인을 끝까지 증명하지 못하는 구조였던 셈입니다.

실제로 가장 위험했던 건 blind unlock이었습니다

blind unlock 시간축
blind unlock 시간축

가장 위험한 시나리오는 blind unlock이었습니다.

  1. Thread A가 락을 획득합니다.
  2. TTL이 먼저 만료됩니다.
  3. Thread B가 같은 key로 다시 락을 획득합니다.
  4. Thread A가 뒤늦게 unlock()을 수행합니다.
  5. Thread B가 가진 락이 삭제됩니다.

이 경로에서는 락을 오래 잡은 쪽이 아니라, 늦게 정리한 쪽이 다음 요청의 락을 지우게 됩니다. 금전 처리나 quota 제어 경로에서는 이 한 번의 엇갈림이 곧 임계 구역의 의미를 무너뜨립니다.

이 지점에서 중요한 개념은 unlock도 lock의 일부라는 점입니다. 락을 획득할 때 owner를 기록해도, 해제할 때 그 owner를 다시 확인하지 않으면 락 계약은 절반만 구현한 것과 비슷합니다. 분산 락이 어려운 이유도 여기에 있습니다. lock acquisition 자체보다, lease가 끝나거나 요청이 지연되는 예상 밖의 타이밍에서 의미가 유지돼야 하기 때문입니다.

fixed TTL과 polling도 같은 맥락에서 문제였습니다. 작업 시간이 TTL보다 길어지면 락은 먼저 풀리고, polling wait는 경합이 길어질수록 Redis에 반복 요청을 쌓습니다. 이건 단순히 비효율적이라는 수준을 넘어, lock holder가 아직 작업 중인데도 다른 요청이 임계 구역에 들어갈 수 있게 만든다는 점에서 lease 정책 문제였습니다.

여기서 구분해야 할 것도 있습니다.

  • blind unlock은 owner 검증이 없어서 생기는 직접 문제입니다.
  • TTL 만료는 lease 관리가 고정되어 있어서 생기는 문제입니다.
  • polling wait는 실패를 기다리는 방식이 비효율적이라는 문제입니다.

즉, 세 문제가 서로 연결되어 있긴 했지만, 같은 원인에서 나온 것은 아니었습니다. 그래서 해결도 “락 라이브러리를 바꾼다” 한 줄로 끝날 수 없었습니다.

대안은 세 가지였습니다

무엇을 도입할지보다, 무엇을 버릴지를 먼저 정리해야 했습니다.

대안장점한계이번 선택
custom 구현 보수의존성을 늘리지 않고 owner token, compare-and-delete Lua, TTL 연장을 직접 붙일 수 있습니다.결국 분산 락 라이브러리를 다시 구현하고 검증하는 일에 가까웠습니다.제외
Redisson RLockowner-safe unlock, watchdog, pub/sub wait를 바로 사용할 수 있습니다.별도 client와 운영 표면이 늘어납니다.채택
fenced token 계열 락stale owner까지 더 강하게 통제할 수 있습니다.현재 코드와 호출 계약을 더 크게 바꿔야 했고, 적용 범위가 넓어집니다.이번 범위 밖

여기서 중요한 건 Redisson이 이론적으로 가장 강한 답이어서가 아니었습니다. 현재 시스템에서 가장 비싼 실패 시나리오를, 가장 좁은 범위 안에서 먼저 없애는 선택이었기 때문입니다.

그래서 Redisson으로 lock path만 바꿨습니다

Redis 전체가 아니라 lock path만 교체한 범위
Redis 전체가 아니라 lock path만 교체한 범위

이번에 바꾼 범위는 Redis 전체가 아니었습니다. 기존 Spring Data Redis와 Lettuce 기반 캐시, 세션, Pub/Sub, Streams는 유지하고, withLock() 경로 안쪽만 Redisson RLock으로 교체했습니다.

이 방향을 택한 이유는 분명했습니다. 가장 위험한 시나리오는 lock correctness였고, Redisson은 owner-safe unlock과 watchdog, pub/sub wait처럼 우리가 직접 다시 구현해야 했던 요소를 이미 제공하고 있었습니다. 반대로 redisson-spring-boot-starter까지 넓히면 cache, RedisTemplate, listener, stream 경로까지 다시 검증해야 했습니다. 이 경우 lock fix보다 Redis integration migration에 가까워집니다.

custom 구현을 계속 보강하는 방법도 있었습니다. owner token, compare-and-delete Lua, 자동 연장, pub/sub wait를 직접 붙이면 됩니다. 다만 그 방향은 위험한 경로를 빠르게 안정화하는 작업이라기보다, 분산 락 라이브러리를 다시 만드는 작업에 가까웠습니다.

구현 관점에서 바뀐 계약은 세 가지였습니다.

  • unlock()은 현재 스레드가 실제 owner인지 확인한 뒤에만 수행합니다.
  • explicit leaseTime을 주지 않고 lock을 획득해 watchdog 경로를 사용합니다.
  • lock 전용 Redisson client를 별도 pool 설정과 함께 둡니다.

이 세 가지는 각각 owner 검증, lease 관리, wait 전략을 다시 맞추는 역할을 했습니다. 중요한 건 기능이 늘었다는 사실보다, 서로 다른 타이밍에 흩어져 있던 락의 계약을 한 라이브러리 안에서 같은 의미로 묶었다는 점이었습니다.

공용 lock wrapper도 일부러 좁게 만들었습니다. timeout은 0, 양수, 음수 세 경우로만 나눴습니다. 0이면 즉시 획득을 시도하고, 양수면 timed acquisition을 수행하고, 음수면 무기한 대기합니다. 중요한 건 양수 timeout 경로에서도 explicit leaseTime을 넘기지 않았다는 점입니다. 기존 call site가 기대하던 wait semantics는 유지하면서, lease 연장은 watchdog 계약에 맡기는 쪽을 택했습니다.

unlock 쪽 계약도 코드로 더 강하게 고정했습니다. 현재 스레드가 owner가 아니면 바로 실패하게 만들었고, 테스트도 이 동작을 그대로 붙잡아 두고 있습니다. positive timeout path가 explicit leaseTime 없는 timed acquisition이어야 한다는 점, negative timeout은 무기한 대기여야 한다는 점까지 함께 검증합니다. blind unlock을 막는다는 말이 문서 선언에 그치지 않도록, wrapper contract를 테스트 수준에서 고정한 셈입니다.

운영 쪽 경계도 분리했습니다. lock 전용 Redisson client는 기본 Redis client와 별도로 두고, connection과 subscription 자원도 보수적으로 분리했습니다. Redis를 더 넓게 바꾼 것이 아니라, correctness가 문제였던 path만 별도 계약으로 다루려는 쪽에 가깝습니다.

이 변경이 해결한 것과, 아직 남는 것

이번 변경으로 blind unlock은 구조적으로 제거됐고, 장시간 작업 중 TTL 만료로 임계 구역이 겹치는 가능성도 크게 줄었습니다. polling 기반 재시도를 pub/sub 기반 대기로 바꾸면서 경합이 길어질 때의 Redis 부담도 줄었습니다.

다만 여기서 경계를 분명히 해야 합니다.

  • watchdog은 살아 있는 holder의 lease 연장 장치입니다.
  • GC pause, 네트워크 분리, 외부 시스템까지 걸친 stale owner 문제를 모두 없애는 해법은 아닙니다.
  • fenced token 계열은 이런 상황을 더 강하게 다룰 수 있지만, 이번 글의 적용 범위보다 훨씬 큰 계약 변경을 요구합니다.

즉, 이번 변경은 “분산 락의 모든 정확성 문제를 끝냈다”가 아니라, 현재 운영에서 가장 비싼 실패였던 blind unlock과 고정 TTL 문제를 먼저 제거한 작업에 가깝습니다.

검증 기준도 그에 맞춰 잡았습니다

이 글에서 더 중요한 검증은 성능 수치가 아니라, 기존에 위험했던 시나리오를 더 이상 허용하지 않는지였습니다.

검증 기준기존 구현변경 후 기대
TTL 만료 직후 재획득 후 이전 요청 unlock다음 요청의 락을 지울 수 있습니다.현재 owner가 아니면 unlock이 실패해야 합니다.
작업 시간이 TTL보다 길어진 경우holder가 아직 작업 중인데 락이 먼저 풀릴 수 있습니다.watchdog로 lease가 유지돼야 합니다.
경합이 긴 경우SET NX 재시도가 누적됩니다.pub/sub 기반 대기로 전환됩니다.
Redis timeout과 contention 구분운영에서 같은 실패처럼 보일 수 있습니다.예외와 lock wait를 더 분리해서 볼 수 있어야 합니다.

단위 테스트 기준으로는 unlock()이 현재 holder가 아닐 때 예외를 던지는지, timeout/무기한 대기가 의도대로 동작하는지를 먼저 확인했습니다. blind unlock 자체는 결국 owner-safe unlock 계약이 핵심이기 때문에, 이 글에서는 그 계약이 코드 수준에서 어떻게 바뀌었는지를 중심으로 설명했습니다.

특히 공용 lock wrapper 테스트에서는 세 가지를 별도로 고정해 뒀습니다. zero timeout은 즉시 획득을 시도해야 하고, positive timeout은 explicit leaseTime 없는 timed acquisition으로 내려가야 하며, negative timeout은 lock()을 통한 무기한 대기여야 합니다. owner가 아닌 스레드의 unlock()이 바로 실패해야 한다는 점도 같이 확인합니다. “이제 blind unlock은 구조적으로 막혔다”는 말을 테스트가 계속 증명하도록 둔 셈입니다.

운영에서는 비용도 함께 생겼습니다

lock-only migration은 분명 현실적인 선택이었지만, 운영 표면이 줄어든 것은 아닙니다.

  • Redis client가 이중화됩니다.
  • lock 전용 connection pool과 subscription pool을 별도로 봐야 합니다.
  • lock wait timeout, Redis timeout, subscription 이슈를 다른 실패와 구분해서 봐야 합니다.

또 하나 분명히 할 점도 있습니다. 이번 변경은 전체 Redis migration을 피한 것이지, 락 가용성을 별도 장애 도메인으로 분리한 것은 아닙니다. lock path도 여전히 같은 Redis 인프라 위에 있습니다. 다만 cache·session·streams까지 한 번에 흔드는 변경을 피했다는 점에서 적용 범위를 관리한 것입니다.

그럼에도 이 경로에서 더 중요한 것은 throughput보다 correctness였습니다. 락 경합이 조금 더 복잡해지는 것보다, 금전 처리와 quota 제어 경로에서 락 의미가 깨지는 편이 훨씬 더 비쌌습니다.

여기서 끝났다고 말하면 오히려 더 위험합니다

다만 이 지점에서 한 단계 더 내려가 보면, 이번 변경이 어디까지를 닫았고 어디부터는 아직 남겨 둔 문제인지도 더 분명해집니다.

이번에 실제로 닫은 반례는 위에서 본 blind unlock 시간축에 가깝습니다. owner-safe unlock과 watchdog으로 이 범위의 위험은 줄였지만, 그 사실만으로 stale owner까지 함께 사라지지는 않았습니다. 락 구현이 더 정확해졌다는 사실과, 늦게 깨어난 이전 owner의 write가 저장소에서 거절된다는 사실은 같은 문장이 아닙니다.

watchdog이 lease를 연장해 준다고 해서 시간축 전체가 봉인되는 것도 아닙니다. JVM이 긴 GC pause에 들어가거나, STW가 예상보다 길어지거나, 프로세스는 살아 있는데 Redis와 네트워크가 분리되면 owner는 살아 있는 것처럼 보이면서도 이미 락 세계에서는 죽은 쪽이 될 수 있습니다. 그 사이 lease는 끊기고, 다른 요청이 새 owner가 됩니다. 그 다음에 멈췄던 이전 owner가 돌아오면 문제는 다시 시간축 위에서 시작됩니다.

락이 있다는 사실과, 내가 지금도 여전히 써도 된다는 사실은 같은 문장이 아닙니다. 분산 락을 끝까지 의심해보면 결국 남는 질문도 그겁니다. 지금 Redis key를 내가 잡고 있느냐가 아니라, 지금 이 write를 보내는 내가 아직도 가장 최신 owner냐는 질문입니다.

이 반례는 보통 이렇게 흘러갑니다.

  1. Thread A가 락을 획득하고 외부 저장소 갱신 직전까지 진행합니다.
  2. 긴 GC pause나 STW, 혹은 네트워크 분리로 A의 heartbeat가 멈춥니다.
  3. watchdog 연장이 끊기고 lease가 만료됩니다.
  4. Thread B가 같은 락을 획득하고 더 최신 의도로 작업을 끝냅니다.
  5. pause에서 깨어난 Thread A가 뒤늦게 외부 write를 밀어 넣습니다.

아래 그림은 그다음 층위의 문제를 보여줍니다. 여기서는 unlock이 아니라 늦게 도착한 stale write가 핵심입니다.

이번 변경 이후에도 남는 반례 - stale owner 시간축
이번 변경 이후에도 남는 반례 - stale owner 시간축

여기서 락은 이미 더 이상 A의 것이 아닙니다. 그런데 저장소가 그 사실을 모르면 stale owner의 write는 그냥 들어갑니다. 그러면 lock correctness는 복구됐는데도 write authority는 아직 복구되지 않은 상태가 됩니다.

무서운 지점은 unlock이 아닙니다. blind unlock은 정리됐습니다. 더 깊게 들어가면 정말 남는 문제는 늦게 도착한 이전 owner의 write를 누가 마지막에 거절하느냐입니다. 분산 락을 깊게 파면 결국 문제는 lock service가 아니라 write path의 마지막 문턱으로 이동합니다.

락 correctness와 write authority는 다른 층위입니다

이 구분이 가장 중요합니다. lock correctness는 같은 시점에 누가 owner인가를 lock service가 얼마나 일관되게 판정하느냐의 문제입니다. 반면 write authority는 지금 이 쓰기를 저장소가 받아도 되는가를 downstream contract가 어떻게 판정하느냐의 문제입니다.

Redisson으로 바꾼 이번 작업은 첫 번째 층위를 다뤘습니다. owner-safe unlock, watchdog, pub/sub wait를 통해 blind unlock과 fixed TTL의 직접 반례를 줄였습니다. 하지만 두 번째 층위, 즉 stale owner가 늦게 도착한 write를 실행하지 못하게 막는 계약까지는 건드리지 않았습니다.

그 단계로 가면 해법도 락 라이브러리 바깥으로 나갑니다.

  • fencing token은 더 새로운 owner만 write할 수 있게 저장소에 epoch를 같이 전달하는 방식입니다.
  • conditional update는 내가 본 버전이 아직 유효할 때만 쓴다는 compare-and-set 계약입니다.
  • version check는 결국 write path가 lock 외부의 시간축을 명시적으로 검사하게 만드는 장치입니다.

이 셋은 모두 저장소 계약 문제입니다. Redis lock 구현만 바꿔서는 끝나지 않습니다. DB schema, write API, message contract, 재시도 semantics까지 같이 바뀌어야 합니다.

여기서부터는 “락을 뭘 쓰느냐”보다 “저장소가 어떤 쓰기를 거절할 수 있느냐”가 더 중요해집니다. fencing token이 자주 분산 락의 마지막 답처럼 언급되는 이유도 여기에 있습니다. 락은 owner를 정하지만, token은 더 오래된 owner의 write를 저장소가 거절할 수 있게 만듭니다. 그 둘은 역할이 다릅니다.

그래서 이번 변경 범위를 과장하면 안 됩니다

이번 변경은 lock correctness 복구 범위였습니다. 그 표현을 끝까지 유지해야 합니다. blind unlock을 없앴고, fixed TTL 때문에 holder가 일하는 동안 lease가 먼저 죽는 문제를 크게 줄였습니다. 그렇다고 stale owner가 더 이상 존재하지 않는 것은 아닙니다.

정확히는 이렇게 말해야 맞습니다.

  • 이번 변경은 lock service 관점의 correctness를 더 낫게 만들었습니다.
  • 이번 변경은 write authority까지 저장소에 전파하는 단계로 확장되지는 않았습니다.
  • 따라서 GC pause, STW, 네트워크 분리처럼 owner가 시간축 위에서 stale해지는 반례는 여전히 별도 설계 주제입니다.

이 선을 흐리면 설계가 다시 위험해집니다. watchdog이 있으니 이제 안전하다는 말은 blind unlock 시절의 과신과 다른 종류의 과신일 뿐입니다. 락이 더 정확해졌다는 사실과, 늦게 깨어난 owner의 write가 저장소에서 거절된다는 사실은 같은 문장이 아닙니다.

이번 작업은 거기까지 가지 않았습니다. 일부러 거기까지만 갔습니다. 지금 운영에서 가장 비쌌던 실패를 먼저 줄이는 데 집중했기 때문입니다. 다음 단계가 있다면 그것은 Redisson 옵션 조정이 아니라, fencing token을 어떻게 전파할지, conditional update를 어디에 둘지, version check를 어떤 저장소 계약으로 강제할지의 문제로 넘어가야 합니다. 그건 lock migration의 다음 장이지, 이번 복구 범위 안의 결론은 아닙니다.

정리

분산 락에서 먼저 확인해야 할 것은 락을 얼마나 빨리 잡느냐가 아닙니다. 다른 요청의 락을 잘못 지울 수 있는지, 작업이 길어졌을 때도 임계 구역의 의미가 유지되는지가 더 중요합니다.

이번 변경도 같은 기준에서 시작했습니다. Redis를 더 많이 쓰게 된 것이 아니라, lock path 하나를 더 조심스럽게 다루게 된 것입니다. blind unlock을 없애고, lease를 더 일관되게 관리하고, 기다림의 방식을 바꿨습니다. 다만 여기까지가 이번 작업의 정확한 범위였습니다. stale owner를 저장소 단계에서 거절하는 문제는 다음 층위의 설계로 남겨 두었습니다.

이 글에서 더 중요했던 결론은 Redisson이 만능이라는 점이 아닙니다. lock correctness와 write authority를 같은 문제로 다루면, blind unlock을 없앤 뒤에도 다시 과신하게 된다는 점이었습니다.

참고