refactor: 문서 개선 중

This commit is contained in:
donghyeon-ka
2026-09-21 14:30:55 +09:00
parent c93cdea150
commit 805a18f486
1497 changed files with 525837 additions and 59152 deletions
+163
View File
@@ -0,0 +1,163 @@
# 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<br/>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)