# 하나의 설정에서 세 topology로: RedisTopologyClientFactory 코드 읽기 > **Redis 코드 상세 시리즈 05/20** · [전체 지도](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-backend-policy-boundary.md) · 이전: [Redis 설정은 어떻게 실패하는가: 바인딩·검증·Secret·Credential 추적](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-settings-secrets-credentials.md) · 다음: [Redis 연결을 여섯 lane으로 나눈 이유: Pool과 RuntimeOwner 생명주기](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-connection-lanes-lifecycle.md) ## 이 글이 답하는 코드 질문 동일한 `app.redis.*` 설정 객체가 standalone, Sentinel, Cluster에서 어떤 client와 URI로 바뀔까요? topology와 TLS는 왜 같은 enum의 네 번째 값이 아니며, ACL role이 여러 개면 client 수가 왜 늘어날까요? 이 글은 `RedisTopologyClientFactory.create()`부터 lane connection이 열리는 지점까지 따라갑니다. ## 코드 지도 | 코드 | 입력 | 출력 | 핵심 분기 | |---|---|---|---| | [`RedisTopologyClientFactory`](/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:40) | validated settings, role credentials, TLS material source | `RedisRuntimeClient` | mode와 role 수 | | [`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) | lane kind, optional routing key | topology-agnostic lane connection | Cluster transaction pinning | | [`RedisCredentialRole`](/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/RedisCredentialRole.java:3) | configured account | application/advanced/pubsub/admin/raw role | role router | | [`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) | command/lifecycle 성격 | connection lane + credential role | client delegate 선택 | | [`RedisSdkAutoConfiguration.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) | Spring beans | factory 호출 | runtime owner | ## `create()`는 topology보다 먼저 role 수를 봅니다 [`create()`](/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:121)는 application account용 client를 먼저 만듭니다. 그 뒤 configured `RedisCredentialRole`마다 같은 topology의 client를 하나씩 더 만듭니다. 이유는 Redis ACL account가 connection authentication 시점에 고정되기 때문입니다. command 하나만 다른 account로 실행할 수 없으므로 script/admin/pubsub privilege를 분리하려면 별도 client와 connection이 필요합니다. account map에 application만 있으면 application client 자체를 반환합니다. 두 개 이상이면 `RoleRoutingRuntimeClient`를 반환합니다. 중간 client 생성이 실패하면 이미 만든 client를 `closeQuietly()`로 닫아 event-loop leak을 막습니다. ```mermaid flowchart TD A[create] --> B[application clientFor] B --> C{추가 configured role?} C -->|없음| D[application client 반환] C -->|있음| E[role별 clientFor] E -->|모두 성공| F[RoleRoutingRuntimeClient 반환] E -->|중간 실패| X[이미 만든 client close 후 예외] ``` `clientFor()`의 mode switch는 [`153행](/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:153)에 있습니다. fallback은 없고 `STANDALONE`, `SENTINEL`, `CLUSTER` 중 정확히 하나를 고릅니다. ## Standalone 분기 [`standalone()`](/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:161)는 `settings.nodes`를 `RedisURI` 목록으로 바꾼 뒤 크기가 정확히 1인지 검사합니다. 여러 node 중 하나를 임의로 고르지 않습니다. 두 개 이상이면 Sentinel 또는 Cluster mode를 쓰라는 startup failure를 냅니다. 정상 경로는 다음과 같습니다. 1. `endpoint()`가 `host:port`를 분리합니다. 2. database, connect timeout, client name, SSL, peer verification, credential provider를 URI에 설정합니다. 3. factory가 `ClientResources`를 만듭니다. 4. `RedisClient.create(resources, uri)`를 호출합니다. 5. 공통 `ClientOptions`를 적용합니다. 6. mode가 `STANDALONE`인 `StandaloneRuntimeClient`를 반환합니다. 이 시점에는 client와 resources만 생깁니다. [`StandaloneRuntimeClient.openLane()`](/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:381)이 호출될 때 `client.connect(ByteArrayCodec.INSTANCE)`로 실제 connection을 엽니다. ## Sentinel 분기 [`sentinel()`](/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:178)는 첫 Sentinel endpoint와 `masterName`으로 builder를 만들고 나머지를 `withSentinel()`로 추가합니다. Sentinel node 목록은 `app.redis.sentinel.nodes`가 비어 있으면 `app.redis.nodes`로 fallback합니다. 이 fallback은 [`sentinelNodes()`](/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:228)에만 있습니다. topology fallback이 아니라 seed 설정 fallback입니다. Sentinel에는 credential이 두 종류입니다. - data account: 발견된 primary에 명령을 보냅니다. - Sentinel control account: Sentinel에게 primary 위치를 묻습니다. factory는 data credential을 root Sentinel URI에, control credential을 각 Sentinel URI에 따로 설정합니다. database, timeout, TLS flag, peer verification도 root URI에 설정합니다. 반환 type은 Lettuce `RedisClient`를 감싼 `StandaloneRuntimeClient`이지만 `mode()`는 `SENTINEL`입니다. “StandaloneRuntimeClient”라는 내부 class 이름이 deployment mode까지 standalone이라는 뜻은 아닙니다. Lettuce가 standalone과 Sentinel 모두 `RedisClient` type을 사용하기 때문에 구현을 공유합니다. ## Cluster 분기 [`cluster()`](/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:207)는 모든 seed URI로 `RedisClusterClient`를 만듭니다. 적용되는 Cluster option은 다음과 같습니다. - periodic topology refresh: `settings.cluster.topologyRefreshPeriod` - adaptive refresh trigger: MOVED 등을 포함한 모든 trigger - maximum redirects: `settings.cluster.maximumRedirects` - cluster node membership validation: true - 공통 socket/timeout/disconnected/request queue option 일반 lane은 slot-routing cluster connection을 씁니다. 예외는 transaction lane입니다. [`ClusterRuntimeClient.openLane()`](/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:430)은 transaction일 때 routing key를 요구합니다. 1. routing key의 slot을 계산합니다. 2. 현재 partition view에서 slot master를 찾습니다. 3. cluster connection에서 그 node의 connection을 얻습니다. 4. transaction gateway를 해당 node async command에 고정합니다. routing key가 없거나 slot owner가 없으면 connection을 닫고 실패합니다. MULTI/EXEC window가 node 여러 개로 흩어지는 것을 허용하지 않는 분기입니다. ```mermaid sequenceDiagram participant O as RedisRuntimeOwner participant C as ClusterRuntimeClient participant P as Partitions participant N as Slot owner node O->>C: openLane(TRANSACTION, routingKey) C->>C: slot 계산 C->>P: getMasterBySlot(slot) alt owner 존재 C->>N: node connection/gateway 고정 C-->>O: LaneConnection else owner 없음 또는 key 없음 C->>C: parent connection close C-->>O: IllegalStateException end ``` ## URI parsing과 공통 option [`endpoint()`](/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:246)은 마지막 `:`을 기준으로 host와 port를 나눕니다. separator가 없거나 port가 비어 있거나 숫자가 아니면 startup failure입니다. 이 parser는 bracketed IPv6를 별도로 정규화하지 않습니다. `[::1]:6379`가 Lettuce에서 기대한 host로 처리되는지는 이 코드와 현재 테스트만으로 확정하기 어렵습니다. production 설정 계약은 실질적으로 `host:port` 문자열입니다. [`clientOptions()`](/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:290)은 topology 공통 정책을 만듭니다. - socket connect timeout과 TCP keepalive - batch timeout profile을 사용하는 Lettuce timeout option - disconnected 상태에서 `REJECT_COMMANDS` 또는 driver default - request queue size = `capacity.maximumInFlightCommands` - auto reconnect = true `maximumInFlightBytes`, `maximumReplyBytes`, lifecycle `acquireTimeout`, `tlsHandshakeTimeout`은 이 factory에서 적용되지 않습니다. `limits.offlineQueueCommands`도 settings에서 binding·validation되지만 client option에는 쓰이지 않습니다. [`requestQueueSize(...)`](/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)의 실제 입력은 `capacity.maximumInFlightCommands`입니다. 설정 존재와 runtime enforcement를 구분해야 합니다. ## TLS는 topology가 아니라 transport 축입니다 deployment mode enum은 standalone/Sentinel/Cluster 세 개입니다. TLS는 이들 각각의 connection transport에 적용할 수 있는 boolean과 material 설정입니다. 그래서 topology test task도 `tls`를 deployment mode가 아닌 별도 qualification lane으로 다룹니다. [`cache-redis/build.gradle`의 lane mapping](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/build.gradle:68)은 `tls -> standalone`으로 client mode를 전달합니다. factory는 모든 endpoint/Sentinel root URI에 SSL과 peer verification flag를 설정합니다. [`sslOptions()`](/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:315)은 JDK SSL provider를 사용합니다. - trust material이 있으면 trust manager에 넣습니다. - client certificate가 있으면 certificate와 private key로 key manager를 만듭니다. - material은 startup에 한 번 열어 가독성을 확인하고 Lettuce가 SSL context를 만들 때 다시 엽니다. Spring bridge의 [`tlsMaterial()`](/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:253)는 `classpath:`, URL/`file:`, prefix 없는 filesystem path를 구분합니다. unreadable material은 첫 handshake가 아니라 client bean 생성 중 실패합니다. 현재 TLS option은 공통 `ClientOptions` builder에서 만들어져 `ClusterClientOptions.builder(clientOptions())`로 Cluster에도 전달됩니다. 다만 historical real-server certification은 Redis 7.4의 세 topology이며 TLS 7.4는 infra 기록/별도 transport lane입니다. 7.2와 8.2는 declared-only입니다. ## role routing [`RoleRoutingRuntimeClient.delegate()`](/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:507)는 `RedisConnectionKind.credentialRole()`로 client를 고릅니다. | lane | credential role | |---|---| | REGULAR, BLOCKING, TRANSACTION | APPLICATION | | SCRIPT | ADVANCED | | PUBSUB | PUBSUB | | ADMIN | ADMIN | role client가 없으면 application client로 fallback합니다. raw credential role은 enum과 factory account map에는 있지만 `RedisConnectionKind`에는 RAW lane이 없습니다. raw gateway가 실제로 어느 client를 사용하는지 production DI도 확인되지 않습니다. raw 전용 credential을 resolve하고 client를 만들 수 있다는 사실과 raw command path가 그 client에 연결됐다는 사실은 다릅니다. close 시에는 중복 client instance를 제거하고 application 이외 client를 먼저 닫은 뒤 application client를 마지막에 닫습니다. 여러 close 중 첫 RuntimeException을 기억해 마지막에 던집니다. ## resource 소유와 shutdown [`resources()`](/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:275)는 client마다 `DefaultClientResources`를 만듭니다. io thread pool size는 `max(2, availableProcessors)`입니다. configured role client가 늘면 event loop resource도 늘어납니다. caller가 만든 resources를 Lettuce client에 넘겼으므로 client shutdown만으로 resources가 닫히지 않습니다. standalone/cluster runtime client의 `close()`는 client를 먼저 shutdown하고 resources shutdown future를 bounded wait합니다. [`ShutdownBudget.await()`](/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:578)는 timeout 또는 execution failure를 warning으로 기록하며 interrupted 상태는 복원합니다. 이 순서는 `close()` 한 번의 내부 순서입니다. Spring production graph에서는 explicit destroy method를 가진 owner가 먼저 이 client를 닫고, 일반 `@Bean`으로 등록된 `AutoCloseable` runtime client의 inferred destroy가 같은 [`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)를 다시 호출할 수 있습니다. 이 구현에는 closed guard가 없으므로 정확히 한 번 닫힌다는 보장은 factory 자체에 없습니다. ## 정상·실패 분기 | 분기 | 정상 | 실패 | |---|---|---| | mode | 정확히 한 topology strategy 선택 | fallback 없음 | | standalone | node 1개 | node 0/2개 이상, invalid port | | Sentinel | master name + seed, data/control credential 분리 가능 | master name 없음은 settings 단계, seed 없음은 factory 단계 | | Cluster | seed 목록, refresh/redirect option | non-zero DB는 settings 단계, transaction routing key/owner 없음은 borrow 시점 | | TLS | readable trust/key material | unreadable material은 startup failure, wrong trust/hostname은 handshake failure 가능 | | role clients | configured account별 client | 중간 생성 실패 시 기존 client close | | connection | first borrow에 lazy open | wrong endpoint/password는 context 뒤 borrow에서 드러날 수 있음 | timeout 전/후 구분도 필요합니다. client factory에서 endpoint parse나 material open이 실패하면 command는 전송되지 않았습니다. connect/handshake failure도 command 이전입니다. 반면 connection이 열린 뒤 executor timeout은 write가 server에 도달했는지 불명확할 수 있으며 이 factory의 소유 범위 밖입니다. ## 테스트가 고정하는 계약 [`RedisSdkAutoConfigurationTest`](/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:262)는 standalone multi-node 거절을, [`clusterBuildsAClusterClient()`](/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:401)는 Cluster mode client 생성을 고정합니다. [`configuredAccountsAreResolvedPerRole()`](/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:330)는 role map을 확인하지만 role별 실제 ACL command 성공까지는 확인하지 않습니다. [`LiveRedisCompositionTest`](/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:67)는 wrong password 거절, mode 일치, PING, lease 반환, context close 후 thread 정리를 real server에서 확인하도록 작성되어 있습니다. [`LiveRedisTlsTest`](/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/LiveRedisTlsTest.java:20)는 filesystem/classpath CA, unreadable material startup failure, TLS-only server에 plaintext 접속 실패를 고정합니다. 두 class는 `redis-topology` tag가 붙은 opt-in real-server lane입니다. 이번 문서 작업에서는 standalone/Sentinel/Cluster/TLS lane을 실행하지 않았습니다. ## 현재 구현 공백과 다음 source 순서 - client 생성은 lazy connection이므로 startup reachability를 보장하지 않습니다. - `RedisStartupProbe` production 조립이 없어 version, command capability, replicated write durability 확인이 factory 뒤에 이어지지 않습니다. - role별 client 생성은 구현됐지만 raw gateway/admin/aggregate operations의 production DI가 확인되지 않아 모든 role client가 request path에 쓰인다고 확정할 수 없습니다. - TLS는 별도 transport 축이며 세 topology 각각의 TLS 조합을 모두 real-server로 인증한 기록은 확인되지 않습니다. - maximum in-flight bytes/reply bytes, acquire timeout, TLS handshake timeout, `limits.offlineQueueCommands`는 factory enforcement가 확인되지 않습니다. 실제 request queue는 `capacity.maximumInFlightCommands`를 사용합니다. - owner close 뒤 runtime client bean inferred destroy가 같은 client를 다시 닫을 수 있습니다. context-level exactly-once shutdown test는 확인되지 않습니다. 다음에는 [`create()`](/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:121), 세 topology method, [`clientOptions()`](/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:290), `openLane()` 구현 순서로 읽으면 됩니다. 관련 시리즈 주제는 lane pool과 runtime owner lifecycle입니다. ## 시리즈에서 이어 읽기 - 이전 글: [Redis 설정은 어떻게 실패하는가: 바인딩·검증·Secret·Credential 추적](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-settings-secrets-credentials.md) - 다음 글: [Redis 연결을 여섯 lane으로 나눈 이유: Pool과 RuntimeOwner 생명주기](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-connection-lanes-lifecycle.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)