22 KiB
하나의 설정에서 세 topology로: RedisTopologyClientFactory 코드 읽기
Redis 코드 상세 시리즈 05/20 · 전체 지도 · 이전: Redis 설정은 어떻게 실패하는가: 바인딩·검증·Secret·Credential 추적 · 다음: Redis 연결을 여섯 lane으로 나눈 이유: Pool과 RuntimeOwner 생명주기
이 글이 답하는 코드 질문
동일한 app.redis.* 설정 객체가 standalone, Sentinel, Cluster에서 어떤 client와 URI로 바뀔까요? topology와 TLS는 왜 같은 enum의 네 번째 값이 아니며, ACL role이 여러 개면 client 수가 왜 늘어날까요? 이 글은 RedisTopologyClientFactory.create()부터 lane connection이 열리는 지점까지 따라갑니다.
코드 지도
| 코드 | 입력 | 출력 | 핵심 분기 |
|---|---|---|---|
RedisTopologyClientFactory |
validated settings, role credentials, TLS material source | RedisRuntimeClient |
mode와 role 수 |
RedisRuntimeClient |
lane kind, optional routing key | topology-agnostic lane connection | Cluster transaction pinning |
RedisCredentialRole |
configured account | application/advanced/pubsub/admin/raw role | role router |
RedisConnectionKind |
command/lifecycle 성격 | connection lane + credential role | client delegate 선택 |
RedisSdkAutoConfiguration.redisRuntimeClient() |
Spring beans | factory 호출 | runtime owner |
create()는 topology보다 먼저 role 수를 봅니다
create()는 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을 막습니다.
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()는 settings.nodes를 RedisURI 목록으로 바꾼 뒤 크기가 정확히 1인지 검사합니다. 여러 node 중 하나를 임의로 고르지 않습니다. 두 개 이상이면 Sentinel 또는 Cluster mode를 쓰라는 startup failure를 냅니다.
정상 경로는 다음과 같습니다.
endpoint()가host:port를 분리합니다.- database, connect timeout, client name, SSL, peer verification, credential provider를 URI에 설정합니다.
- factory가
ClientResources를 만듭니다. RedisClient.create(resources, uri)를 호출합니다.- 공통
ClientOptions를 적용합니다. - mode가
STANDALONE인StandaloneRuntimeClient를 반환합니다.
이 시점에는 client와 resources만 생깁니다. StandaloneRuntimeClient.openLane()이 호출될 때 client.connect(ByteArrayCodec.INSTANCE)로 실제 connection을 엽니다.
Sentinel 분기
sentinel()는 첫 Sentinel endpoint와 masterName으로 builder를 만들고 나머지를 withSentinel()로 추가합니다.
Sentinel node 목록은 app.redis.sentinel.nodes가 비어 있으면 app.redis.nodes로 fallback합니다. 이 fallback은 sentinelNodes()에만 있습니다. 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()는 모든 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()은 transaction일 때 routing key를 요구합니다.
- routing key의 slot을 계산합니다.
- 현재 partition view에서 slot master를 찾습니다.
- cluster connection에서 그 node의 connection을 얻습니다.
- transaction gateway를 해당 node async command에 고정합니다.
routing key가 없거나 slot owner가 없으면 connection을 닫고 실패합니다. MULTI/EXEC window가 node 여러 개로 흩어지는 것을 허용하지 않는 분기입니다.
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()은 마지막 :을 기준으로 host와 port를 나눕니다. separator가 없거나 port가 비어 있거나 숫자가 아니면 startup failure입니다.
이 parser는 bracketed IPv6를 별도로 정규화하지 않습니다. [::1]:6379가 Lettuce에서 기대한 host로 처리되는지는 이 코드와 현재 테스트만으로 확정하기 어렵습니다. production 설정 계약은 실질적으로 host:port 문자열입니다.
clientOptions()은 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(...)의 실제 입력은 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은 tls -> standalone으로 client mode를 전달합니다.
factory는 모든 endpoint/Sentinel root URI에 SSL과 peer verification flag를 설정합니다. sslOptions()은 JDK SSL provider를 사용합니다.
- trust material이 있으면 trust manager에 넣습니다.
- client certificate가 있으면 certificate와 private key로 key manager를 만듭니다.
- material은 startup에 한 번 열어 가독성을 확인하고 Lettuce가 SSL context를 만들 때 다시 엽니다.
Spring bridge의 tlsMaterial()는 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()는 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()는 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()는 timeout 또는 execution failure를 warning으로 기록하며 interrupted 상태는 복원합니다.
이 순서는 close() 한 번의 내부 순서입니다. Spring production graph에서는 explicit destroy method를 가진 owner가 먼저 이 client를 닫고, 일반 @Bean으로 등록된 AutoCloseable runtime client의 inferred destroy가 같은 close()를 다시 호출할 수 있습니다. 이 구현에는 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는 standalone multi-node 거절을, clusterBuildsAClusterClient()는 Cluster mode client 생성을 고정합니다. configuredAccountsAreResolvedPerRole()는 role map을 확인하지만 role별 실제 ACL command 성공까지는 확인하지 않습니다.
LiveRedisCompositionTest는 wrong password 거절, mode 일치, PING, lease 반환, context close 후 thread 정리를 real server에서 확인하도록 작성되어 있습니다.
LiveRedisTlsTest는 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를 보장하지 않습니다.
RedisStartupProbeproduction 조립이 없어 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(), 세 topology method, clientOptions(), openLane() 구현 순서로 읽으면 됩니다.
관련 시리즈 주제는 lane pool과 runtime owner lifecycle입니다.