Files
document-haness/.run/redis/redis-connection-lanes-lifecycle.md
T

231 lines
27 KiB
Markdown

# Redis 연결을 여섯 lane으로 나눈 이유: Pool과 RuntimeOwner 생명주기
> **Redis 코드 상세 시리즈 06/20** · [전체 지도](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-backend-policy-boundary.md) · 이전: [하나의 설정에서 세 topology로: RedisTopologyClientFactory 코드 읽기](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-topology-client-factory.md) · 다음: [YAML 한 줄이 Redis 명령을 거절하기까지: Policy Loader·Catalog·Guard](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-command-policy-admission.md)
## 이 글이 답하는 코드 질문
Redis connection은 thread-safe하다는 설명만 보면 하나를 공유해도 될 것처럼 보입니다. 하지만 blocking command, transaction, script, Pub/Sub, admin은 connection 상태와 권한이 다릅니다. 이 글은 여섯 `RedisConnectionKind`가 어떻게 account와 pool ceiling을 고르고, `RedisRuntimeOwner`가 borrow·return·invalidate·drain·close를 어떤 순서로 처리하는지 설명합니다.
## 먼저 보는 클래스 지도
| 클래스 | 입력 | 출력 | 다음 호출 |
|---|---|---|---|
| [`RedisConnectionKind`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisConnectionKind.java:6) | command descriptor 또는 explicit lane | lane과 credential role | runtime client role router |
| [`RedisRuntimeOwner`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeOwner.java:21) | runtime client, lane limits, drain timeout | typed `RedisLease` | gateway 또는 return |
| [`RedisLease`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisLease.java:5) | borrowed lane connection | gateway, invalidate, close | owner.release |
| [`RedisRuntimeClient`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeClient.java:7) | kind + optional routing key | 새 driver lane connection | owner idle pool |
| [`RedisConnectionRegistry`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisConnectionRegistry.java:13) | generic factory + limit | untyped legacy lease | 현재 production에서 호출되지 않음 |
| [`RedisSdkAutoConfiguration.redisRuntimeOwner()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfiguration.java:263) | settings + runtime client | Spring destroy method를 가진 owner bean | request-time borrow |
## 여섯 lane과 격리하는 실패 모드
[`RedisConnectionKind`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisConnectionKind.java:21)는 정확히 여섯 값을 가집니다.
| lane | connection 성격 | credential role | 공유했을 때의 문제 |
|---|---|---|---|
| `REGULAR` | 일반 non-blocking command | APPLICATION | 다른 특수 traffic이 일반 요청을 막을 수 있음 |
| `BLOCKING` | block 시간 동안 connection 점유 | APPLICATION | BLPOP/XREAD BLOCK이 일반 명령을 stall시킴 |
| `TRANSACTION` | MULTI~EXEC window 독점 | APPLICATION | 다음 caller command가 열린 transaction에 섞일 수 있음 |
| `SCRIPT` | registered script 실행 | ADVANCED | 일반 request path에 SCRIPT/EVALSHA grant가 퍼짐 |
| `PUBSUB` | subscribe lifecycle 전용 | PUBSUB | subscribed connection은 일반 command 용도로 쓸 수 없음 |
| `ADMIN` | read-only diagnostics | ADMIN | 운영 권한이 application connection에 섞임 |
`forCommand()`는 descriptor가 blocking이면 `BLOCKING`을 먼저 선택하고, `ADMIN_READONLY` access이면 `ADMIN`, application/advanced/raw/extension access이면 `REGULAR`을 반환합니다. SCRIPT, TRANSACTION, PUBSUB은 일반 command descriptor만으로 결정하지 않고 해당 고수준 surface가 explicit하게 borrow합니다.
이 지점에는 오해하기 쉬운 차이가 있습니다. descriptor의 `APPLICATION_ADVANCED`가 자동으로 `SCRIPT` lane을 뜻하지 않습니다. registered script runner가 SCRIPT lane을 선택해야 account isolation이 적용됩니다. aggregate production DI가 확인되지 않으므로 모든 command가 이 경로를 탄다고 확대할 수 없습니다.
## Spring이 계산하는 lane ceiling
[`redisRuntimeOwner()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfiguration.java:273)은 settings에서 limit map을 만듭니다.
| lane | ceiling source | 기본값 |
|---|---|---:|
| REGULAR | `capacity.maximumInFlightCommands` | 64 |
| BLOCKING | `blocking.maxConnections` | 32 |
| TRANSACTION | `transaction.maxConnections` | 16 |
| SCRIPT | `capacity.maximumInFlightCommands` | 64 |
| PUBSUB | `max(1, pubsub.bufferCapacity / 64)` | 16 |
| ADMIN | admin enabled면 2, 아니면 1 | 1 |
각 값은 physical idle connection 수의 선할당이 아닙니다. owner constructor는 lane별 빈 `ArrayDeque`와 outstanding counter를 만들 뿐 connection을 열지 않습니다. limit은 동시에 대여된 lease 수의 ceiling입니다.
PUBSUB connection ceiling이 buffer capacity에서 파생되는 이유는 source에서 별도 설명되지 않습니다. 공식은 분명하지만 `64`의 운영 근거는 코드·테스트만으로 확인되지 않습니다. admin disabled 상태에도 ceiling 1과 pool은 존재하지만 admin surface production 조립은 확인되지 않습니다.
## borrow 호출 순서
[`borrow(kind, routingKey)`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeOwner.java:123)는 admission과 connection acquisition을 나눕니다.
```mermaid
sequenceDiagram
participant C as Caller
participant O as RedisRuntimeOwner
participant P as Idle deque
participant R as RedisRuntimeClient
C->>O: borrow(kind, routingKey)
O->>O: state == OPEN 확인
O->>O: outstanding < limit 확인 후 +1
alt routingKey 없음
O->>P: poll idle connection
P-->>O: connection 또는 null
end
alt idle 없음/죽음/routed lease
O->>R: openLane(kind, routingKey)
R-->>O: lane connection
end
O-->>C: RedisLease
```
monitor lock 안에서 먼저 state와 ceiling을 확인합니다. `OPEN`이 아니면 새 work를 거절합니다. outstanding이 limit에 도달했어도 기다리지 않고 즉시 `RedisCommandRejectedException`을 던집니다. failure metadata는 `notSent("CONNECTION", NONE, false, mode)`입니다. connection을 얻기 전에 거절했으므로 command는 전송되지 않았습니다.
admission을 통과하면 outstanding을 1 올립니다. routing key가 없을 때만 idle deque에서 connection을 꺼냅니다. idle connection의 `open()`이 false면 닫고 새로 엽니다. connection factory가 실패하면 counter를 되돌리고 예외를 그대로 던집니다.
`routingKey`가 있으면 pooled connection을 쓰지 않습니다. Cluster transaction connection은 이전 caller의 slot owner에 고정되어 있을 수 있기 때문입니다. Standalone/Sentinel은 routing key를 무시할 수 있지만 owner는 topology와 상관없이 routed lease를 non-reusable로 다루는 보수적인 정책을 사용합니다.
## return과 invalidate
owner가 반환하는 내부 [`Lease`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeOwner.java:269)는 `kind`, connection, `reusable`, `closed`를 가집니다.
- `gateway()`는 close 전까지만 접근할 수 있습니다.
- `invalidate()``reusable=false`로 바꿉니다.
- `close()`는 synchronized이며 한 번만 `release()`를 호출합니다.
[`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/sdk/lettuce/connection/RedisRuntimeOwner.java:168)는 outstanding을 1 줄입니다. reusable이고 owner가 여전히 OPEN이며 connection도 open이면 idle deque 뒤에 넣습니다. 그 외에는 connection을 닫습니다.
invalidate가 필요한 대표 사례는 transaction cleanup 실패입니다. DISCARD가 server에 도달하지 않았다면 connection에 MULTI window가 남아 있을 수 있습니다. 이를 pool에 돌려보내면 다음 caller command가 이전 transaction에 queue됩니다. Pub/Sub unsubscribe cleanup 실패도 같은 종류입니다.
close를 두 번 호출해도 counter는 한 번만 줄어듭니다. 이미 반환한 lease에서 gateway를 요청하면 `IllegalStateException`입니다. lease 누락은 hard ceiling의 한 자리를 영구 점유하므로 모든 사용자는 try-with-resources 또는 동등한 종료 경로를 가져야 합니다.
## pool의 실제 모양과 queue behavior
`RedisRuntimeOwner`의 pool은 lane별 `ArrayDeque<RedisLaneConnection>`입니다. background replenishment, min-idle, idle eviction, fairness queue는 없습니다.
- 첫 borrow가 connection을 엽니다.
- 정상 return이 idle deque에 connection을 보관합니다.
- 다음 borrow가 FIFO `poll()`로 재사용합니다.
- 죽은 idle connection은 borrow 시 발견해 교체합니다.
- limit 도달 시 대기 queue를 만들지 않습니다.
`app.redis.lifecycle.acquire-timeout`은 settings에 있고 양수 검증도 되지만 owner는 사용하지 않습니다. 현재 queue behavior는 “acquire timeout까지 기다림”이 아니라 즉시 rejection입니다.
`app.redis.limits.offline-queue-commands`도 binding되고 [`Limits.validate()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettings.java:253)에서 양수 여부를 검사합니다. 그러나 production main source에는 [`getOfflineQueueCommands()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettings.java:344)의 호출자가 없습니다. 따라서 이 값을 바꿔도 현행 driver request queue의 runtime ceiling은 바뀌지 않습니다.
Lettuce client 내부의 실제 `requestQueueSize`는 [`capacity.maximumInFlightCommands`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisTopologyClientFactory.java:307)로 설정됩니다. connection lease ceiling과 driver command queue는 다른 층입니다. owner limit을 통과했다고 해서 driver queue가 반드시 여유 있다는 뜻은 아닙니다.
## lifecycle 상태 전이
owner state는 `OPEN`, `DRAINING`, `CLOSED` 세 개입니다.
```mermaid
stateDiagram-v2
[*] --> OPEN
OPEN --> DRAINING: close() CAS 성공 / admission 중지
DRAINING --> DRAINING: outstanding lease bounded wait
DRAINING --> CLOSED: drain 완료 또는 timeout / idle close / client close
CLOSED --> CLOSED: 두 번째 close는 no-op
```
[`close()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeOwner.java:197)의 순서는 다음과 같습니다.
1. atomic CAS로 `OPEN -> DRAINING`을 수행합니다. 실패하면 이미 닫는 중이거나 닫혔으므로 return합니다.
2. 새 borrow는 즉시 거절됩니다.
3. outstanding 합계가 0이 될 때까지 `drainTimeout` 안에서 monitor wait합니다.
4. deadline이 지나면 outstanding 수를 warning으로 남기고 계속 종료합니다.
5. 모든 idle deque를 비우고 pooled connection을 닫습니다.
6. 마지막에 runtime client를 닫습니다.
7. client close 성공 여부와 관계없이 state를 `CLOSED`로 설정합니다.
client가 마지막인 이유는 event loop가 in-flight command completion을 수행하기 때문입니다. 먼저 client를 닫으면 drain이 기다리던 작업 자체를 끊습니다.
outstanding lease가 drain timeout을 넘으면 owner는 해당 lease의 connection을 직접 목록으로 추적해 닫지 않습니다. client shutdown이 최종적으로 underlying connection/resource를 정리하지만 caller가 나중에 lease를 close할 때 owner counter가 CLOSED 상태에서 감소합니다. 상태와 counter는 diagnostic용이며 close 후 재사용은 허용되지 않습니다.
### Spring context에는 client close 경로가 하나 더 있습니다
위 상태 전이는 `RedisRuntimeOwner.close()` 자체에 idempotence가 있음을 보여 줍니다. 그러나 Spring production bean graph 전체에서 `client.close()`가 정확히 한 번만 호출된다는 뜻은 아닙니다.
```mermaid
sequenceDiagram
participant S as Spring context
participant O as RedisRuntimeOwner bean
participant C as RedisRuntimeClient bean
S->>O: explicit destroyMethod close()
O->>C: client.close()
O-->>S: owner CLOSED
S->>C: inferred destroy close()
```
[`redisRuntimeOwner()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfiguration.java:273)은 client bean에 의존하고 explicit `destroyMethod="close"`를 가집니다. 따라서 context는 owner를 먼저 destroy하고, owner는 내부에서 [`client.close()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeOwner.java:227)를 호출합니다. 한편 [`redisRuntimeClient()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfiguration.java:226)는 destroy method inference를 끄지 않은 일반 `@Bean`입니다. 반환 type인 [`RedisRuntimeClient`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeClient.java:19)는 public no-arg `close()`를 가진 `AutoCloseable`입니다. Spring이 이어서 client bean의 inferred destroy method를 실행하면 같은 runtime client에 두 번째 `close()`가 들어갈 수 있습니다.
owner의 `CLOSED -> CLOSED` no-op은 두 번째 `owner.close()`만 막습니다. client bean을 직접 닫는 두 번째 경로에는 적용되지 않습니다. [`StandaloneRuntimeClient.close()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisTopologyClientFactory.java:395)와 [`ClusterRuntimeClient.close()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisTopologyClientFactory.java:470)에는 별도 closed guard가 없습니다. role router도 [`close()`가 호출될 때마다](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisTopologyClientFactory.java:515) 하위 client를 닫습니다. 현재 Lettuce가 반복 shutdown을 받아들일 수 있더라도, 이 구조만으로 lifecycle ownership이 exactly-once라고 말할 수는 없습니다.
## `RedisConnectionRegistry`와 현행 owner를 구분합니다
[`RedisConnectionRegistry`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisConnectionRegistry.java:13)는 문서 주석에서 “five connection lanes”라고 쓰지만 enum은 현재 여섯 개입니다. constructor는 enum 전체에 positive limit을 요구하므로 실행 의미는 여섯 lane입니다. 주석이 drift했습니다.
이 class는 counter를 atomic increment하고 limit 초과 시 즉시 거절하지만 connection을 `Object`로 반환하고 close 시 counter만 0으로 만듭니다. production source에서 `new RedisConnectionRegistry(...)` 호출은 확인되지 않았고 단위 테스트만 생성합니다.
현행 production bean은 `RedisRuntimeOwner`입니다. typed gateway, idle connection 실제 close, invalidate, lifecycle state, bounded drain, client shutdown을 가진 쪽도 owner입니다. `RedisConnectionRegistryTest`의 계약을 production lifecycle 증거로 직접 쓰면 안 됩니다.
## 정상·실패·degraded 분기
### 정상
- OPEN + ceiling 미만: idle connection 재사용 또는 새 connection open
- lease close + healthy reusable connection: 같은 lane idle deque로 return
- routed/invalidate/dead connection: close하고 counter만 반환
- close + 빠른 lease return: drain 완료 후 pool과 client shutdown
### admission 거절
- DRAINING/CLOSED에서 borrow
- 해당 lane outstanding이 ceiling 이상
둘 다 Redis에 command를 보내기 전 `RedisCommandRejectedException`입니다. 다른 lane counter는 소비하지 않으므로 blocking saturation이 regular lane을 직접 줄이지 않습니다.
### connection open 실패
endpoint, authentication, TLS handshake가 실패하면 outstanding을 되돌리고 예외를 전달합니다. command 실행 이전일 수 있지만 driver failure 번역은 이 owner가 하지 않습니다.
### shutdown timeout
drain timeout은 startup/runtime availability 상태를 failure로 바꾸지 않고 warning을 남긴 뒤 close를 계속합니다. 종료 과정의 degraded branch이며 요청 결과의 execution certainty를 판정하지 않습니다.
## 테스트가 고정하는 계약
[`RedisRuntimeOwnerTest`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeOwnerTest.java:20)는 server 없이 lifecycle을 직접 검사합니다.
- [`aLeaseIsReturnedAndPooled()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeOwnerTest.java:44): close once, double-close no-op, pool reuse
- [`anInvalidatedConnectionIsNotReused()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeOwnerTest.java:76): invalidated transaction connection close
- [`anExhaustedLaneRefuses()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeOwnerTest.java:92): queue 대신 즉시 rejection
- [`closingStopsAdmissionFirst()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeOwnerTest.java:106): DRAINING에서 새 lease 거절
- [`theClientShutsDownLast()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeOwnerTest.java:136): connection close 뒤 client shutdown
- [`aDeadPooledConnectionIsReplaced()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeOwnerTest.java:163): idle-dead replacement
[`RedisConnectionRegistryTest`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisConnectionRegistryTest.java:18)는 lane routing과 counter 격리를 고정하지만 legacy/non-production class의 단위 계약입니다.
[`LiveRedisCompositionTest.aLeaseReachesTheServer()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/LiveRedisCompositionTest.java:104)는 PING 뒤 outstanding이 0인지 확인합니다. [`closingTheContextTearsEverythingDown()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/LiveRedisCompositionTest.java:135)는 context 종료 뒤 Lettuce thread 수가 원래 수준으로 돌아오는지 확인합니다. 이들은 opt-in real-server lane이며 이번 문서 작업에서는 실행하지 않았습니다.
직접 owner test의 [`theClientShutsDownLast()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeOwnerTest.java:136)는 fake client가 connection 뒤에 닫히는 순서를, [`closingTwiceIsIdempotent()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeOwnerTest.java:152)는 `owner.close()`를 두 번 불러도 fake client shutdown이 한 번임을 고정합니다. 둘 다 Spring이 client bean을 별도로 destroy하는 경로는 포함하지 않습니다. auto-configuration test의 [`theRuntimeOwnerFollowsTheContext()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfigurationTest.java:418)는 owner state만, live test는 남은 Lettuce thread만 확인합니다. Spring context에서 runtime client의 `close()` 호출 횟수를 세는 테스트는 없어 exactly-once ownership은 검증되지 않았습니다.
## 현재 구현 공백과 다음 source 순서
- `RedisConnectionRegistry`는 production 미사용이며 주석의 five-lane 표기도 enum과 drift했습니다.
- acquire timeout, min-idle, fairness queue, idle eviction은 구현되지 않았습니다.
- `limits.offlineQueueCommands`는 binding·validation만 되고 production queue 구성에는 쓰이지 않습니다. 실제 Lettuce `requestQueueSize``capacity.maximumInFlightCommands`를 사용합니다.
- connection limit은 concurrent lease 수이고 command in-flight byte/reply byte ceiling enforcement와 같지 않습니다.
- aggregate command executor production DI가 없어 모든 typed operation이 owner admission과 observation path를 일관되게 거친다고 확인할 수 없습니다.
- PUBSUB ceiling의 `/64` 근거와 admin disabled 상태의 limit 1 이유는 source에서 설명되지 않습니다.
- drain timeout을 넘긴 outstanding command의 실행 결과는 owner가 판정하지 않습니다.
- Spring context에는 owner를 통한 close와 client bean inferred destroy가 겹치는 경로가 있습니다. 별도 source 변경에서 lifecycle authority를 owner 하나로 모으려면 client bean에 `@Bean(destroyMethod = "")`를 명시하되 생성 실패 cleanup을 보존해야 합니다. 두 경로를 유지한다면 runtime client close를 idempotent하게 만들어 반복 shutdown을 안전하게 처리할 수 있습니다. 어느 선택이든 context-level close-count test가 필요하며 현행 구현에는 없습니다.
다음에는 [`RedisConnectionKind`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisConnectionKind.java:21), [`RedisRuntimeOwner.borrow()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeOwner.java:123), [`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/sdk/lettuce/connection/RedisRuntimeOwner.java:168), [`close()`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeOwner.java:197) 순서로 읽으면 됩니다.
관련 시리즈 주제는 executor timeout과 execution certainty입니다.
## 시리즈에서 이어 읽기
- 이전 글: [하나의 설정에서 세 topology로: RedisTopologyClientFactory 코드 읽기](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-topology-client-factory.md)
- 다음 글: [YAML 한 줄이 Redis 명령을 거절하기까지: Policy Loader·Catalog·Guard](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-command-policy-admission.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)