# Redis Lease는 왜 Lock이 아닌가: Acquire·Renew·Release 코드 읽기 > **Redis 코드 상세 시리즈 15/20** · [전체 지도](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-backend-policy-boundary.md) · 이전: [세 가지 Redis Rate Limit Lua를 코드로 추적하기](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-rate-limit-code-walkthrough.md) · 다음: [Redis Idempotency V2 상태 머신: Claim에서 Replay까지](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-idempotency-v2-code-walkthrough.md) ## 이 글이 답하는 코드 질문 Redis lease가 같은 resource의 중복 작업을 어떻게 줄이며, 왜 domain invariant를 보호하는 lock으로 사용할 수 없습니까? acquire reply가 사라지거나 renew가 timeout일 때 handle state는 어떻게 바뀝니까? `LeaseRequest.waitTimeout`은 실제로 기다리는 데 쓰입니까? 현행 구현의 이름 그대로 이 capability는 `EFFICIENCY_ONLY`입니다. owner 확인은 제공하지만 fencing token이 없습니다. `tryAcquire`는 한 번만 Redis에 보내며 wait loop도 없습니다. 더 직접적인 현재 위험도 있습니다. same-attempt replay가 받은 Redis `PTTL`을 버리고 요청 TTL 전체로 local validity를 다시 만들기 때문에, replay handle은 실제 lease보다 오래 `ACTIVE`라고 판단할 수 있습니다. ## 먼저 보는 클래스 지도 | 코드 | 입력 | 출력 | 다음 호출 | | --- | --- | --- | --- | | [`DistributedLeasePort`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/lease/DistributedLeasePort.java:9) | operation ID, lease request, inspection request | attempt, acquire/inspect outcome | Redis adapter | | [`LeaseRequest`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseRequest.java:7) | purpose, resource digest, wait timeout, TTL, attempt | bounded request | `tryAcquire` | | [`RedisDistributedLeaseAdapter.tryAcquire`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/RedisDistributedLeaseAdapter.java:134) | request | acquired/replayed/contended/conflict/indeterminate | acquire Lua | | [`LeaseScripts`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/LeaseScripts.java:25) | key, `ownerToken:operationId`, TTL | status, PTTL, holder | `SCRIPT LOAD`, `EVALSHA` | | [`RedisLeaseHandle`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/RedisDistributedLeaseAdapter.java:237) | confirmed ownership | local validity와 ACTIVE/LOST/RELEASED/UNKNOWN | renew/release Lua | | [`LeaseWatchdog`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseWatchdog.java:18) | handle, TTL, cadence, deadline, callbacks | bounded renewal registration | `handle.renew` | ## production 조립 `ca-skeleton.capabilities.lease.provider=redis`이고 `app.redis.enabled=true`일 때 `RedisCapabilityConfig.redisDistributedLeasePort`가 `DistributedLeasePort` bean을 만듭니다. 공통 namespace와 key version, `LeaseScripts`, wall clock, `System::nanoTime`, command timeout, contention retry-after, drift budget을 adapter에 전달합니다. [`redisDistributedLeasePort`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java:227) 기본값은 command timeout 200ms, contention retry-after 50ms, drift budget 10ms입니다. [`RedisCapabilitySettings.Lease`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilitySettings.java:334) resource의 raw ID는 port contract가 허용하지 않습니다. `resourceDigest`는 versioned lowercase SHA-256 형태로 validation되고 Redis key는 namespace/capability `lease`/key version/purpose/digest 아래에 생깁니다. [`LeaseKeys.leaseKey`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/RedisDistributedLeaseAdapter.java:392) ## attempt를 send 전에 만드는 이유 caller는 첫 provider call 전에 `newAttempt(operationId)`를 호출합니다. adapter는 `SecureRandom` 24바이트를 Base64URL without padding으로 바꿔 owner token을 만들고 caller operation ID와 묶습니다. [`newAttempt`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/RedisDistributedLeaseAdapter.java:123) Redis value는 `ownerToken:operationId`입니다. 같은 attempt를 유지하면 reply-loss 뒤 재호출을 새 acquisition과 구분할 수 있습니다. 같은 owner라도 operation ID가 다르면 이전 작업의 lease를 새 작업이 상속하지 못합니다. ## acquire 호출 순서와 상태 ```mermaid sequenceDiagram participant C as Caller participant A as RedisDistributedLeaseAdapter participant L as LeaseScripts participant R as Redis C->>A: newAttempt(operationId) A-->>C: ownerToken + operationId C->>A: tryAcquire(request) A->>A: startedAt = nanoTime A->>L: acquire(key, ownership, ttl) L->>R: SCRIPT LOAD / EVALSHA R->>R: GET; SET PX if absent; PTTL alt status 1 A-->>C: Acquired(handle) else status 2 R-->>A: current PTTL A-->>C: ReplayedSameOperation(handle=request TTL - drift) Note over A,C: reply PTTL은 handle 생성에 쓰이지 않음 else same owner, other operation A-->>C: OwnerOperationConflict else other holder A-->>C: Contended(retryAfter) else reply uncertain A-->>C: Indeterminate(operationId) end ``` acquire Lua는 `GET` 후 값이 없으면 `SET key ownership PX ttl`을 같은 server execution에서 실행하고 status 1을 반환합니다. 같은 ownership이면 TTL을 연장하지 않고 status 2와 현재 `PTTL`을 반환합니다. 다른 holder면 status 0입니다. [`ACQUIRE`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/LeaseScripts.java:27) status 2에서 server가 반환한 남은 시간과 adapter가 만든 handle의 시간이 다릅니다. `tryAcquire`는 status 1과 2에 모두 같은 `handle(request, ownership, startedAt)`을 호출하고, 이 helper는 reply의 `remainingMillis`를 받지 않습니다. replay Lua는 TTL을 갱신하지 않았는데 새 handle은 다시 `request.leaseTtl() - driftBudget`을 부여받습니다. [`tryAcquire`의 replay mapping](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/RedisDistributedLeaseAdapter.java:134), [`handle`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/RedisDistributedLeaseAdapter.java:223) 가령 30초 lease를 얻고 29초 뒤 같은 attempt로 다시 호출하면 Redis에는 약 1초가 남아 있어도 replay handle은 약 `30초 - drift`를 유효하다고 봅니다. 그 사이 key가 만료되어 새 owner가 획득해도 이전 replay handle은 local budget만으로 `ACTIVE`를 반환할 수 있습니다. 이는 fencing 부재를 논하기 전부터 handle의 local-validity 판단이 server lease와 어긋나는 경로입니다. `tryAcquire`는 SCRIPT lane에서 이를 한 번 호출합니다. status 1은 `Acquired`, 2는 `ReplayedSameOperation`입니다. 다른 holder value가 같은 owner token prefix를 가지면 `OwnerOperationConflict`, 아니면 `Contended`입니다. Redis PTTL이 양수면 그대로 retry-after를 쓰고 아니면 configured 50ms fallback을 씁니다. [`contendedOrConflicting`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/RedisDistributedLeaseAdapter.java:169) interruption을 포함한 모든 exception은 `Indeterminate(operationId)`입니다. request가 Redis에 도달했는지 adapter가 구분하지 않기 때문에 definite unavailable을 만들지 않습니다. caller는 같은 attempt로 `inspect`해야 합니다. ## `waitTimeout`은 소비되지 않습니다 `LeaseRequest`는 0 이상 bounded `waitTimeout`을 받습니다. 그러나 `RedisDistributedLeaseAdapter.tryAcquire`는 `request.waitTimeout()`을 읽지 않습니다. sleep, poll, retry loop도 없습니다. 따라서 현재 의미는 “try once”이며 `Contended.retryAfter`는 caller가 바깥에서 재시도 정책을 만들 때 쓸 정보입니다. `waitTimeout` 필드가 존재한다고 해서 adapter가 그 시간 동안 기다린다고 설명하면 잘못입니다. 테스트도 모두 `Duration.ZERO`로 adapter를 호출합니다. [`RedisDistributedLeaseAdapterTest.request`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/lease/RedisDistributedLeaseAdapterTest.java:81) ## local validity는 server PTTL이 아닙니다 status 1로 새 lease를 만든 acquisition handle의 `grantedValidity`는 `leaseTtl - driftBudget`입니다. 이 계산은 status 2 replay에도 그대로 재사용되지만, replay에는 새 TTL이 부여되지 않았으므로 안전한 근거가 아닙니다. [`localValidityOf`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/RedisDistributedLeaseAdapter.java:108) TTL이 drift budget 이하이면 `localValidityOf`가 `IllegalArgumentException`을 던집니다. 이는 Redis 호출 전 validation이 아닙니다. acquire 또는 renew script가 성공한 뒤 local budget을 만들 때 발생하고 enclosing catch가 `Indeterminate`로 바꾸므로, server mutation은 이미 적용됐을 수 있습니다. budget 기준점은 reply 수신 시각이 아니라 send 직전 `startedAt = nanoTime`입니다. round trip에 걸린 시간까지 차감하는 보수적 계산입니다. `remainingValidity`는 monotonic elapsed를 빼고 0 아래로 내리지 않습니다. ACTIVE handle의 remaining이 0이면 `state()`는 서버 조회 없이 LOST를 반환합니다. [`remainingValidity`와 `state`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/RedisDistributedLeaseAdapter.java:282) `observedServerExpiry`라는 이름과 달리 adapter는 acquire reply의 PTTL을 handle에 넣지 않습니다. `acquiredAt + grantedValidity`를 반환합니다. status 1에서는 요청 TTL과 drift budget으로 계산한 local 진단값이고, status 2에서는 오래된 lease의 현재 PTTL과 무관한 값입니다. ## renew와 release의 owner check renew Lua는 `GET`한 값이 없으면 0, ownership이 다르면 -1, 같으면 `PEXPIRE` 후 1을 반환합니다. release Lua도 같은 비교를 거쳐 owner일 때만 `DEL`합니다. check와 mutation은 각 Lua 안에서 원자적으로 실행됩니다. [`RENEW`와 `RELEASE`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/LeaseScripts.java:43) ```mermaid stateDiagram-v2 state "ACTIVE field" as ACTIVE state "state() returns LOST
field remains ACTIVE" as LOCAL_EXPIRED state "LOST field" as LOST [*] --> ACTIVE: acquire/replay handle ACTIVE --> ACTIVE: renew status 1 ACTIVE --> LOCAL_EXPIRED: local budget 0 LOCAL_EXPIRED --> ACTIVE: renew status 1, budget reset ACTIVE --> LOST: renew absent/not owner ACTIVE --> RELEASED: release/release already absent ACTIVE --> UNKNOWN: renew/release indeterminate UNKNOWN --> UNKNOWN: renew status 1, field는 복구되지 않음 LOST --> LOST: renew status 1, field는 복구되지 않음 RELEASED --> RELEASED: renew status 1, field는 복구되지 않음 ``` 이 그림에서 `LOCAL_EXPIRED`는 `LeaseState` field가 아니라 `state()`의 계산 결과입니다. `state()`는 ACTIVE field와 0인 budget을 보고 `LOST`를 반환할 뿐 field를 바꾸지 않습니다. `renew`에는 현재 state나 remaining-validity precondition이 없어 local expiry 뒤에도 script를 보냅니다. server key가 아직 같은 ownership이면 성공해 budget을 교체하고 다시 ACTIVE로 보일 수 있습니다. [`state`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/RedisDistributedLeaseAdapter.java:294), [`renew`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/RedisDistributedLeaseAdapter.java:304) renew 성공은 local budget을 새 TTL minus drift로 교체하지만 `state = ACTIVE`를 쓰지 않습니다. 따라서 field가 이미 `UNKNOWN`, `LOST`, `RELEASED`인 handle도 renew 호출 자체는 가능하고, Redis가 status 1을 반환하면 outcome은 `Renewed`이면서 `state()`는 기존 field를 계속 반환할 수 있습니다. absent/not owner는 field를 LOST로, exception은 UNKNOWN으로 바꾸며 ambiguous renew에서는 budget을 연장하지 않습니다. LOST/UNKNOWN/RELEASED를 terminal state로 막는 precondition이나 일관된 복구 transition은 현행 method에 없습니다. release 성공과 already absent는 RELEASED, not owner는 LOST, exception은 UNKNOWN입니다. `close()`는 `release()` 결과를 버리므로 release certainty가 필요한 caller는 먼저 명시적으로 호출하고 typed outcome을 검사해야 합니다. [`LeaseHandle.close`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseHandle.java:32) `LeaseWatchdog`는 별도 application-core utility입니다. bounded registration과 scheduled renew를 제공하고 renew가 unknown/lost가 되면 cancellation callback을 한 번 호출합니다. Redis lease bean과 watchdog을 자동으로 묶는 production bean은 확인되지 않습니다. ## 왜 lock이 아닌가 owner check는 다른 caller가 현재 Redis value를 renew/delete하지 못하게 합니다. 하지만 expiry 뒤 새 owner가 획득한 다음, 오래 멈췄던 이전 process가 외부 DB나 API에 effect를 쓰는 것을 Redis lease가 막지는 못합니다. effect target에 제시할 monotonically increasing fencing token이 없기 때문입니다. `LeaseHandle.guarantee()`와 adapter의 static `guarantee()`는 모두 [`LeaseGuarantee.EFFICIENCY_ONLY`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/lease/LeaseGuarantee.java:3)를 반환합니다. 이 계약은 correctness-sensitive write를 보호하지 않습니다. DB revision, conditional update 같은 effect-point guard가 따로 필요합니다. 또한 single Redis/Sentinel/Cluster deployment 하나에 Lua를 실행할 뿐 quorum lock이나 Redlock 구현이 아닙니다. 이 글은 Redis topology 자체의 availability를 mutual exclusion 증명으로 바꾸지 않습니다. ## NOSCRIPT와 ambiguous 분기 네 script는 digest를 cache하고 `EVALSHA`를 사용합니다. `NOSCRIPT`일 때만 `SCRIPT LOAD` 후 한 번 다시 시도합니다. [`LeaseScripts.run`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/LeaseScripts.java:107) `NOSCRIPT` 이외의 exception은 adapter로 올라가 typed `Indeterminate`가 됩니다. acquire/renew/release는 mutation 가능성이 있으므로 clean failure로 바꾸지 않는 선택입니다. inspect는 read-only이지만 exception 역시 `Indeterminate`입니다. ## 테스트가 고정하는 계약 - [`DistributedLeaseV2ContractTest`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/test/java/dev/caskeleton/application/lease/DistributedLeaseV2ContractTest.java:17)는 bounded/redacted attempt, digest-only request, response-loss outcome, `EFFICIENCY_ONLY`, usable budget을 provider-neutral type 수준에서 검사합니다. - [`RedisDistributedLeaseAdapterTest`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/lease/RedisDistributedLeaseAdapterTest.java:85)는 uncontended acquire, contention, same-operation replay, operation conflict, renew, local expiry, release, inspection과 unreachable indeterminate를 in-memory gateway로 고정합니다. - [`theSameClaimReplays`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/lease/RedisDistributedLeaseAdapterTest.java:111)는 outcome type만 검사합니다. replay handle의 remaining validity가 reply PTTL 이하인지 확인하지 않습니다. - 같은 테스트의 [`anExpiredBudgetIsLost`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/lease/RedisDistributedLeaseAdapterTest.java:170)는 server call 없이 monotonic budget만으로 LOST가 반환됨을 검사합니다. 그 뒤 renew하거나 UNKNOWN 뒤 renew하는 경로는 없습니다. - [`LeaseWatchdogTest`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/test/java/dev/caskeleton/application/lease/LeaseWatchdogTest.java:21)는 registration bound와 indeterminate renew 시 cancel/lost callback을 고정합니다. - [`LiveRedisSemanticPortsTest.theLeaseIsExclusiveUnderTheAdvancedAccount`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LiveRedisSemanticPortsTest.java:296)는 standalone/cluster real-server lane에서 한 holder만 acquire하고 두 번째는 contended이며 release가 성공하는 흐름을 검사하도록 태그되어 있습니다. - [`RedisCapabilityCompositionTest.leaseProviderComposesThePort`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/redis/RedisCapabilityCompositionTest.java:114)는 selector가 port bean을 만드는지만 확인하며 서버에는 연결하지 않습니다. ## 현재 한계와 다음 source 순서 1. fencing token이 없으므로 domain correctness lock이 아닙니다. 2. `waitTimeout`은 request validation에는 있지만 Redis adapter가 소비하지 않습니다. wait loop가 없습니다. 3. same-attempt replay는 reply PTTL을 버리고 요청 TTL로 local budget을 다시 만듭니다. replay handle이 실제 Redis lease보다 오래 ACTIVE라고 판단할 수 있으며 이를 막는 regression test가 없습니다. 4. `state()`의 local-expiry LOST는 field에 저장되지 않고, `renew`는 state precondition 없이 실행됩니다. 성공해도 field를 ACTIVE로 복구하지 않아 `Renewed` outcome과 UNKNOWN/LOST/RELEASED state가 함께 남을 수 있습니다. 5. adapter는 before-send unavailable과 after-send ambiguous를 구분하지 않고 대부분 `Indeterminate`로 보냅니다. port에 있는 `Unavailable`·`Overloaded` variant는 이 adapter에서 생성되지 않습니다. 6. `observedServerExpiry`는 acquire reply의 PTTL을 반영하지 않습니다. replay에서는 진단값도 server expiry보다 길 수 있습니다. 7. watchdog은 구현·unit test되어 있지만 production bean 조립은 확인되지 않습니다. 8. 이번 작성에서는 real-server lane을 재실행하지 않았습니다. `DistributedLeasePort` → adapter `tryAcquire` → 네 Lua → inner handle → adapter test 순으로 읽으면 owner identity와 certainty 경계를 놓치지 않습니다. ## 시리즈에서 이어 읽기 - 이전 글: [세 가지 Redis Rate Limit Lua를 코드로 추적하기](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-rate-limit-code-walkthrough.md) - 다음 글: [Redis Idempotency V2 상태 머신: Claim에서 Replay까지](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-idempotency-v2-code-walkthrough.md) - 전체 흐름: [Redis를 범용 클라이언트가 아니라 정책 경계로 다루기](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-backend-policy-boundary.md) - 운영 흐름: [Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-platform-sre-operations.md)