537 KiB
Redis를 정책 경계로 다루는 코드 — clean-architecture-backend-template
이 글은
clean-architecture-backend-template의 Redis 모듈을 2026년 8월 13일의 production source 기준으로 정적으로 읽은 결과입니다. 검토 세션에서./gradlew :adapter:outbound:cache-redis:test --console=plain을 실행해 성공을 확인했습니다. 실제 standalone·Sentinel·Cluster deployment topology lane과 별도 TLS transport qualification lane은 실행하지 않았습니다.
원래 20편으로 나눠 쓴 글을 한 파일로 합쳤습니다. 아래 차례가 그 스무 편입니다.
- Redis를 범용 클라이언트가 아니라 정책 경계로 다루기
- Redis 모듈 해부: Gradle leaf에서 app-bootstrap까지
- app.redis.enabled에서 capability bean까지: Spring 조립 코드 읽기
- Redis 설정은 어떻게 실패하는가: 바인딩·검증·Secret·Credential 추적
- 하나의 설정에서 세 topology로: RedisTopologyClientFactory 코드 읽기
- Redis 연결을 여섯 lane으로 나눈 이유: Pool과 RuntimeOwner 생명주기
- YAML 한 줄이 Redis 명령을 거절하기까지: Policy Loader·Catalog·Guard
- Raw key와 영구 쓰기를 막는 코드: Namespace·Hash Slot·TTL
- Redis 값의 스키마를 코드로 고정하기: Registry·Envelope·Version
- 문자열 명령 대신 타입을 노출하는 RedisOperations 코드 지도
- Batch·Transaction·Script·Function·Pub/Sub·Admin·Raw를 분리한 이유
- Timeout 뒤 쓰였는지 모를 때: Executor와 실행 확실성 모델
- Redis 캐시 한 요청의 전 생애: Generation·Envelope·Soft/Hard TTL
- 세 가지 Redis Rate Limit Lua를 코드로 추적하기
- Redis Lease는 왜 Lock이 아닌가: Acquire·Renew·Release 코드 읽기
- Redis Idempotency V2 상태 머신: Claim에서 Replay까지
- Redis Session 요청은 어디에서 멈추는가: Web 설정과 미완성 Repository
- 같은 Redis 장애가 DEGRADED와 DOWN으로 갈리는 코드
- Redis 테스트가 증명하는 것과 증명하지 않는 것
- Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지
Redis를 범용 클라이언트가 아니라 정책 경계로 다루기
이 글은
document-haness/docs/clean-architecture-backend-template/redis/redis-backend-policy-boundary.md에 보관되어 있으며, 저장소 링크는 분석 대상인clean-architecture-backend-template의 절대 경로를 가리킵니다. 내용은 2026년 8월 13일의 production source를 정적으로 확인한 결과를 기준으로 합니다. 이 문서를 검토한 root 세션에서는./gradlew :adapter:outbound:cache-redis:test --console=plain을 실행해 성공을 확인했습니다. 실제 standalone, Sentinel, Cluster deployment topology lane과 별도 TLS transport qualification lane은 이 세션에서 실행하지 않았습니다.
Redis를 애플리케이션에 붙이는 가장 짧은 방법은 문자열 키와 값을 받는 클라이언트를 주입하는 것입니다. 그러나 Redis가 커지면 키 namespace를 누가 보장할지, TTL 없는 쓰기를 허용할지, Cluster multi-key 작업을 어떻게 제한할지를 호출부가 결정하게 됩니다. timeout 뒤의 쓰기 재시도와 관리 명령·일반 명령의 계정 분리도 마찬가지입니다.
이 템플릿의 Redis 모듈은 이 문제를 “편리한 Redis 접근”이 아니라 “허용된 Redis 사용법”의 문제로 다룹니다. Spring Data Redis를 거치지 않고 자체 typed SDK, 닫힌 command catalog, command guard, capability별 semantic port를 둔 이유도 여기에 있습니다. 애플리케이션 use case는 Redis 명령을 직접 선택하지 않고 캐시, 레이트리밋, 리스, 멱등성이라는 의미 단위의 port를 사용합니다. typed SDK 경로도 문자열 명령과 raw key를 그대로 받지 않도록 설계했지만, 이 경로의 production Spring 조합은 현재 확인되지 않습니다.
다만 모든 표면이 같은 완성도에 있지는 않습니다. 현재 소스를 기준으로 먼저 상태를 구분하면 다음과 같습니다.
| 영역 | 현재 상태 | 해석 |
|---|---|---|
| topology client, connection owner, health | 구현 및 자동 구성 존재 | standalone, Sentinel, Cluster 분기와 lane별 connection 수명주기 코드가 있습니다. |
| command policy, guard, executor, 개별 typed operation | 구현·테스트, production 조합 미확인 | CommandPolicyGuard와 Sync/Reactive executor, LettuceExceptionTranslator의 동작과 테스트는 존재하지만 이를 만드는 production Spring bean은 확인되지 않습니다. |
| RedisOperations, ReactiveRedisOperations aggregate facade | 부분 구현 | 공개 interface와 개별 operation 구현은 있지만 aggregate facade 구현과 Spring bean 조합은 production source에서 확인되지 않습니다. |
| semantic cache | 구현 및 조건부 bean 존재 | RedisRuntimeOwner의 REGULAR lane을 직접 사용합니다. soft/hard/negative TTL, generation invalidation, typed outcome을 제공하지만 typed command guard 경로를 통과한다고 볼 근거는 없습니다. |
| distributed rate limit | 구현 및 조건부 bean 존재 | RedisRuntimeOwner의 SCRIPT lane을 직접 사용합니다. fixed window, sliding counter, token bucket을 Lua로 평가하며 fail-closed만 허용합니다. 일부 설정은 현재 Lua에 반영되지 않습니다. |
| distributed lease | 제한적으로 구현 | RedisRuntimeOwner의 SCRIPT lane을 직접 사용하는 efficiency-only lease입니다. fencing과 내부 대기 루프는 없습니다. |
| Redis idempotency V2 | store와 executor 조합 존재 | store는 RedisRuntimeOwner의 SCRIPT lane을 직접 사용합니다. owner-safe state machine은 있으나 기존 inbound V1 key 지원 코드와의 production bridge는 확인되지 않습니다. |
| Redis HTTP session | 미완성 | web 설정과 보안 context codec은 있지만 Redis SessionRepository 구현 bean은 확인되지 않습니다. |
| cache L1, invalidation Pub/Sub, TTL jitter, distributed refresh coordination | 미구현 | 과거 README의 설계 설명을 현재 기능으로 보면 안 됩니다. |
현재 조합은 RedisSdkAutoConfiguration.java, RedisCapabilityConfig.java, cache-redis build.gradle에서 확인할 수 있습니다. 반면 모듈의 기존 README.md는 여러 세대의 설계가 섞여 있으므로 현행 구현의 SSOT로 사용하지 않는 편이 안전합니다.
Redis 코드 상세 시리즈 20편
이 글은 20편의 출발점이자 전체 지도입니다. 처음 읽는다면 01→06에서 모듈과 런타임 조립을 잡고, 07→12에서 SDK의 정책 경계를 따라간 뒤, 13→19에서 capability와 검증 코드를 읽는 순서가 자연스럽습니다. 특정 문제를 조사하는 중이라면 아래 표에서 바로 해당 글로 이동해도 됩니다.
| 순서 | 문서 | 코드에서 확인할 경계 |
|---|---|---|
| 01 | 현재 글 — Redis를 범용 클라이언트가 아니라 정책 경계로 다루기 | 전체 구조, 구현 상태, 정책의 출발점 |
| 02 | 「Redis 모듈 해부: Gradle leaf에서 app-bootstrap까지」 | Gradle leaf, package, bootstrap 의존 방향 |
| 03 | 「app.redis.enabled에서 capability bean까지: Spring 조립 코드 읽기」 | auto-configuration, 조건부 bean, 4/5 capability |
| 04 | 「Redis 설정은 어떻게 실패하는가: 바인딩·검증·Secret·Credential 추적」 | 설정 검증, secret 해석, 역할별 credential |
| 05 | 「하나의 설정에서 세 topology로: RedisTopologyClientFactory 코드 읽기」 | standalone, Sentinel, Cluster 생성 분기 |
| 06 | 「Redis 연결을 여섯 lane으로 나눈 이유: Pool과 RuntimeOwner 생명주기」 | lane별 pool, borrow·drain·close, capacity |
| 07 | 「YAML 한 줄이 Redis 명령을 거절하기까지: Policy Loader·Catalog·Guard」 | command SSOT, default-deny, admission 순서 |
| 08 | 「Raw key와 영구 쓰기를 막는 코드: Namespace·Hash Slot·TTL」 | typed key, namespace, same-slot, expiration |
| 09 | 「Redis 값의 스키마를 코드로 고정하기: Registry·Envelope·Version」 | codec registry, framing, version 실패 |
| 10 | 「문자열 명령 대신 타입을 노출하는 RedisOperations 코드 지도」 | operation 요청 모델, driver 변환, reply 한계 |
| 11 | 「Batch·Transaction·Script·Function·Pub/Sub·Admin·Raw를 분리한 이유」 | 고급 surface별 권한·연결·budget 경계 |
| 12 | 「Timeout 뒤 쓰였는지 모를 때: Executor와 실행 확실성 모델」 | guard→driver→translator, retryable·ambiguous |
| 13 | 「Redis 캐시 한 요청의 전 생애: Generation·Envelope·Soft/Hard TTL」 | lookup·record·invalidate, stale와 generation 공백 |
| 14 | 「세 가지 Redis Rate Limit Lua를 코드로 추적하기」 | fixed·sliding·token bucket 원자 연산 |
| 15 | 「Redis Lease는 왜 Lock이 아닌가: Acquire·Renew·Release 코드 읽기」 | efficiency lease, 불확실 상태, fencing 부재 |
| 16 | 「Redis Idempotency V2 상태 머신: Claim에서 Replay까지」 | Lua 상태 전이, owner·operation, 중복 실행 위험 |
| 17 | 「Redis Session 요청은 어디에서 멈추는가: Web 설정과 미완성 Repository」 | web·security 조립과 repository·인증 공백 |
| 18 | 「같은 Redis 장애가 DEGRADED와 DOWN으로 갈리는 코드」 | optional·required health, readiness, 관측 공백 |
| 19 | 「Redis 테스트가 증명하는 것과 증명하지 않는 것」 | 단위·계약·실서버 lane, 지원 근거의 범위 |
| 20 | 「Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지」 | 운영 계약, topology, durability, 배포 공백 |
1. 모듈 경계부터 Redis 사용법을 제한합니다
아키텍처 registry에서 Redis leaf의 id는 adapter-outbound-cache-redis이고 Gradle 경로는 :adapter:outbound:cache-redis입니다. 이 leaf가 참조할 수 있는 내부 모듈은 domain-core, application-core, shared-contract, adapter-outbound-support로 제한됩니다. 실제 실행 조합은 app-bootstrap이 소유합니다.
관련 정의는 modules.json과 app-bootstrap build.gradle에 있습니다.
구조를 호출 방향으로 정리하면 다음과 같습니다.
inbound web
│
├─ CacheRegionPort / EdgeRateLimitPort
├─ DistributedLeasePort
└─ IdempotencyStorePortV2 / IdempotencyExecutorV2
│
▼
app-bootstrap RedisCapabilityConfig
│
▼
adapter-outbound-cache-redis
├─ semantic adapter
│ ├─ cache
│ ├─ ratelimit
│ ├─ lease
│ └─ idempotency
└─ typed SDK
├─ api / command policy / key / codec
├─ Lettuce operation / connection / topology
├─ programmability
├─ extensions
├─ raw
└─ admin
│
▼
Redis
핵심은 application-core와 shared-contract가 Redis를 모른다는 점입니다. 예를 들어 캐시 use case는 CacheRegionPort.java, HTTP edge 제한은 EdgeRateLimitPort.java, 리스는 DistributedLeasePort.java, owner-safe 멱등성은 IdempotencyStorePortV2.java를 기준으로 호출합니다.
실제 공개 시그니처도 provider 명령보다 업무 의미를 먼저 드러냅니다.
public interface CacheRegionPort<K, V> {
CacheLookup<V> lookup(K key);
CacheRecordOutcome record(K key, V value, CacheRecordMetadata metadata);
CacheRecordOutcome recordAbsent(
K key, AuthoritativeAbsence reason, CacheRecordMetadata metadata);
CacheInvalidationOutcome invalidate(K key);
CacheInvalidationOutcome invalidateRegion();
}
@FunctionalInterface
public interface EdgeRateLimitPort {
RateLimitOutcome evaluate(RateLimitRequest request);
}
use case가 GET, SET, EVALSHA를 고르지 않기 때문에 Redis를 다른 provider로 바꾸더라도 application 계약은 유지할 수 있습니다. 또한 Redis 특유의 실패를 단순한 null이나 boolean으로 지우지 않습니다. capability별 결과 타입은 서로 다른 상태를 보존합니다. cache는 fresh·stale·unavailable, lease는 indeterminate, rate limit은 incompatible 같은 상태를 각 결과 타입에서 구분합니다.
2. 왜 Spring Data Redis를 사용하지 않았는가
이 선택을 Spring Data Redis의 일반적인 품질 문제로 해석하면 안 됩니다. 이 템플릿이 요구하는 경계와 Spring Data Redis가 제공하는 범용성이 맞지 않았기 때문입니다. cache-redis build.gradle은 spring-data-redis 의존을 의도적으로 제외하고, 자체 typed API와 command policy를 우회하는 untyped command surface를 만들지 않겠다고 기록합니다.
이 모듈이 해결하려는 제약은 다음과 같습니다.
- 모든 물리 키에 같은 namespace와 크기 제한을 적용해야 합니다.
- ordinary value
SET계열처럼 정책이 적용된 쓰기에서는 expiration을 생략하지 못하게 해야 합니다. - R2 수준 명령은 permit과 request/reply budget이 있을 때만 실행해야 합니다.
- Cluster의 multi-key 작업은 전송 전에 same-slot을 확인해야 합니다.
- blocking, transaction, Pub/Sub, script, admin은 connection과 ACL 경계를 분리해야 합니다.
- timeout 또는 연결 손실 이후 mutation의 실행 여부를 함부로 성공이나 실패로 바꾸지 않아야 합니다.
- 모듈 명령과 raw 명령을 같은 escape hatch로 노출하지 않아야 합니다.
범용 template 위에 이 정책을 매번 덧붙이는 대신, SDK의 operation별 요청 타입이 필요한 key, codec, expiration, permit, budget을 표현하도록 만들었습니다. 모든 요청이 이 요소를 전부 요구하는 것은 아닙니다. SyncRedisCommandExecutor 또는 ReactiveRedisCommandExecutor를 CommandPolicyGuard와 함께 조합한 SDK 경로에서는 guard가 driver 호출 직전에 요청에 포함된 요소를 다시 검증합니다. 이 class 경로는 구현되어 있고 모듈 테스트 대상이지만 production Spring 조합은 확인되지 않습니다.
대가도 큽니다. Redis 명령 지원 범위, Lettuce 변환, codec, transaction, extension을 직접 유지해야 합니다. 현재 aggregate facade가 자동 조합되지 않은 상태도 이 비용의 한 사례입니다. 따라서 “자체 SDK가 있으므로 모든 Redis 기능을 바로 주입해 쓸 수 있다”가 아니라 “정책이 구현된 개별 표면은 있으나 application에 노출되는 조합은 별도로 확인해야 한다”가 정확한 설명입니다.
3. 두 단계 선택으로 Redis를 활성화합니다
Redis는 전역 활성화와 capability 선택을 분리합니다. 전역 스위치는 app.redis.enabled입니다. false이면 Redis settings binding, credential resolution, TLS material, client, connection, thread, health contributor를 만들지 않습니다. 이 조건은 RedisSdkAutoConfiguration.java에 있습니다.
전역 스위치만 켠다고 semantic port가 모두 생기지는 않습니다. 각 기능은 다음 selector로 따로 선택합니다.
| 기능 | selector |
|---|---|
| cache | ca-skeleton.capabilities.cache.bindings.default=redis |
| rate limit | ca-skeleton.capabilities.rate-limit.provider=redis |
| lease | ca-skeleton.capabilities.lease.provider=redis |
| idempotency | ca-skeleton.capabilities.idempotency.provider=redis |
| HTTP session 모드 | ca-skeleton.security.auth-mode=redis-session |
RedisActivationValidator.java는 전역 Redis가 꺼진 상태에서 Redis provider를 선택하면 startup을 실패시킵니다. selector가 전역 스위치를 암묵적으로 켜지 않으므로, 설정 누락이 첫 요청의 bean 부재나 연결 오류로 늦게 나타나지 않습니다.
개념을 보여 주는 최소 설정은 다음과 같습니다. credential 값이 아니라 secret reference를 설정한다는 점이 중요합니다.
app:
redis:
enabled: true
mode: standalone
nodes:
- redis.internal:6379
namespace:
environment: prod
service: order-api
domain: shared
authentication:
credential-reference: secret://order-api@environment/APP_REDIS_PASSWORD
ca-skeleton:
capabilities:
cache:
bindings:
default: redis
semantic-region: default
key-version: 1
key-hmac-secret-reference: secret://environment/APP_CACHE_REDIS_KEY_HMAC_SECRET
command-timeout: 200ms
positive-soft-ttl: 30s
positive-hard-ttl: 5m
negative-ttl: 10s
minimum-hard-ttl: 1s
credential reference 형식과 startup resolution은 RedisCredentialResolver.java, 전체 설정 검증은 RedisSdkSettings.java, 기본 capability 설정은 application.yml에서 확인할 수 있습니다.
애플리케이션 계정 외에 advanced, Pub/Sub, raw, admin 계정을 별도로 지정할 수 있습니다. 설정된 계정은 client 생성 전에 해결됩니다. raw와 admin을 활성화했는데 전용 credential reference가 없으면 startup이 실패합니다. advanced account가 없으면 application account가 script 권한까지 가져야 한다는 경고가 남습니다.
4. topology와 connection lane을 한 client처럼 다루지 않습니다
RedisTopologyClientFactory.java는 standalone, Sentinel, Cluster에 맞는 runtime client를 생성합니다. 이 세 가지가 deployment topology입니다. Cluster에서는 database 0만 허용하고, Sentinel에서는 monitored master name을 요구합니다. TLS client certificate가 설정되면 private key reference도 함께 요구합니다.
TLS는 네 번째 deployment topology가 아닙니다. standalone 형태에서 plaintext port를 끄고 TLS transport만 검증하는 별도 qualification lane이며, 테스트에는 deployment mode를 standalone으로 전달합니다. 따라서 “standalone, Sentinel, Cluster, TLS topology를 지원한다”라고 표현하면 transport 조건과 배포 구조가 섞입니다.
minimum version 선언과 실서버 qualification도 구분해야 합니다. support-matrix.md에 기록된 certified 실서버 증거는 Redis 7.4에서 실행한 standalone, Sentinel, Cluster 세 topology의 결과입니다. TLS transport lane도 Redis 7.4에서 실행됐다는 기록은 infra/redis-sdk/README.md에 있지만 support matrix의 certified table에는 TLS row가 없습니다. Redis 7.2와 8.2는 지원 매트릭스와 workflow에 선언된 행일 뿐, 현재 저장소가 certified로 기록한 실서버 실행 버전이 아닙니다. 이번 문서 검토 세션에서는 이 실서버 lane들을 다시 실행하지 않았습니다.
연결은 다음 lane으로 나뉩니다.
- REGULAR: 일반 단일·컬렉션 명령을 처리합니다.
- BLOCKING: server 응답까지 connection을 점유하는 명령을 격리합니다.
- TRANSACTION: WATCH/MULTI/EXEC의 connection state를 다른 요청과 섞지 않습니다.
- SCRIPT: semantic Lua와 등록 script를 격리합니다.
- PUBSUB: subscription의 장기 점유와 buffer 정책을 분리합니다.
- ADMIN: 일반 application 계정과 다른 진단 plane을 사용합니다.
RedisRuntimeOwner.java는 lane별 상한, borrow/return, invalidation, drain, close 순서를 소유합니다. disconnected command를 거부하도록 구성할 수 있고 request queue도 유한하게 둡니다. 종료 시 owner는 drain 뒤 runtime client를 닫습니다. 그러나 runtime client 자체도 AutoCloseable bean이고 inferred destroy를 끄지 않아 Spring이 같은 client의 close()를 다시 호출할 수 있습니다. owner 내부의 반복 close 방지와 production bean graph의 exactly-once 종료는 다른 문제이며, context에서 client close 횟수를 고정하는 테스트는 확인되지 않습니다.
health도 capability의 의미에 따라 다릅니다. cache-only Redis는 선택적 의존성이므로 연결 불가를 DEGRADED로 보고 readiness에서 제외합니다. session, idempotency, rate limit, lease처럼 correctness 역할을 선택하면 redisRequired contributor가 DOWN을 반환하며 readiness group에 동적으로 포함됩니다. 관련 코드는 RedisCorrectnessRoles.java와 RedisReadinessGroupPostProcessor.java에 있습니다.
5. typed API와 semantic API는 용도가 다릅니다
semantic port는 application use case가 사용합니다. typed SDK는 Redis 자료구조를 안전한 primitive로 제공하기 위한 표면입니다. RedisOperations.java는 values, hashes, lists, sets, sortedSets, bitmaps, bitFields, hyperLogLogs, geo, streams, keys, batches 그룹을 노출합니다. ReactiveRedisOperations.java도 같은 방향의 reactive 계약을 제공합니다.
이 facade에는 blocking, transaction, Pub/Sub, admin, raw, extension을 넣지 않았습니다. 서로 다른 connection·ACL·배포 조건이 필요한 표면을 하나의 주입점으로 합치면 호출자가 경계를 인식하기 어려워지기 때문입니다.
현재 production source에는 RedisOperations와 ReactiveRedisOperations interface, 여러 개별 Lettuce operation 구현, CommandPolicyGuard, Sync/Reactive executor, LettuceExceptionTranslator가 있습니다. 그러나 두 aggregate interface를 구현해 모든 operation을 묶는 class뿐 아니라 command catalog·guard·executor·translator를 만드는 Spring bean도 확인되지 않습니다. 따라서 아래와 같은 주입이나 guarded SDK 경로의 자동 조합을 가정하면 안 됩니다.
// 계약은 존재하지만 production auto-configuration에서 이 aggregate bean 조합은 확인되지 않습니다.
private final RedisOperations redis;
즉, 새 use case는 가능하면 semantic port를 먼저 정의해야 합니다. primitive SDK를 직접 노출해야 한다면 composition root에서 catalog, guard, translator, executor와 필요한 operation을 명시적으로 조합하고, 해당 조합이 command guard와 lane을 우회하지 않는지 확인해야 합니다. 현재 semantic adapter는 이 typed SDK 조합을 사용하지 않고 RedisRuntimeOwner에서 REGULAR 또는 SCRIPT lane을 직접 빌립니다.
6. command catalog는 허용 목록이 아니라 실행 정책의 SSOT입니다
redis-command-policy.yml은 314개 command entry를 닫힌 목록으로 관리합니다. 현재 분류는 다음과 같습니다.
| support | 개수 | 의미 |
|---|---|---|
| TYPED | 86 | 기본 typed surface에서 사용합니다. |
| ADVANCED_TYPED | 90 | permit과 budget을 요구하는 고급 typed 명령입니다. |
| VERSION_GATED | 43 | server minimum version과 capability 확인이 필요합니다. |
| ADMIN_ONLY | 37 | 분리된 read-only admin plane에서만 허용합니다. |
| RAW_ONLY | 3 | 배포 allowlist와 token을 거쳐 raw gateway에서만 허용합니다. |
| BLOCKED | 55 | SDK에서 실행 경로를 제공하지 않습니다. |
risk 분류는 R1 133개, R2 109개, R3 39개, R4 33개입니다. 예를 들어 GET과 SET은 typed R1이고, MGET은 multi-key-read policy와 budget이 필요한 R2입니다. SETNX, SETEX, PSETEX처럼 더 명시적인 typed API로 대체할 수 있는 단축 명령과 파괴적 관리 명령은 BLOCKED입니다.
RedisCommandPolicyLoader.java는 일반 YAML parser처럼 느슨하게 읽지 않습니다. anchor, merge, 중복 command, 알 수 없는 field와 잘못된 enum을 거부합니다. RedisCommandCatalog.java는 모르는 명령을 default deny합니다.
CommandPolicyGuard.java를 SyncRedisCommandExecutor 또는 ReactiveRedisCommandExecutor와 함께 조합했을 때의 admission 순서는 다음과 같습니다.
- command가 catalog에 있고 차단되지 않았는지 확인합니다.
- 현재 server version과 배포 mode가 command capability를 만족하는지 확인합니다.
- R2 operation permit의 발급 주체와 policy name을 확인합니다.
- 모든 key가 허용 namespace에 속하는지 확인합니다.
- Cluster multi-key 작업이 같은 slot인지 확인합니다.
- 예상 element 수, request bytes, reply bytes가 operation budget 안인지 확인합니다.
- caller timeout과 command profile 중 더 짧은 effective timeout을 계산합니다.
- blocking 명령이면 block timeout 자체도 설정 상한 안인지 확인합니다.
이렇게 조합된 typed SDK 경로는 declared request와 expected reply를 driver 호출 전에 검사하므로 잘못된 key나 명시된 budget을 Redis server error에 맡기지 않습니다. 관측한 reply byte는 requireReplyWithinBudget을 호출하는 일부 typed decoder에서만 검사합니다. 기본 GET, script, function, raw, admin, extension에는 공통 actual-size 검사가 없고, batch는 exact wire bytes가 아니라 decode된 result shape를 근사해 누적합니다. 따라서 설정된 reply ceiling을 모든 SDK surface의 memory 보호선으로 해석하면 안 됩니다.
위 설명은 구현된 SDK class 경로의 동작이며, 현재 production composition의 공통 실행 경계를 뜻하지 않습니다. RedisSdkAutoConfiguration은 settings, credential, runtime client·owner, health를 만들지만 command catalog, guard, Sync/Reactive executor, LettuceExceptionTranslator bean은 만들지 않습니다. RedisCapabilityConfig가 조합하는 cache, rate-limit, lease, idempotency adapter도 RedisRuntimeOwner lane을 직접 빌리므로 typed command guard를 통과한다고 간주하면 안 됩니다. 이 semantic adapter들은 각자의 key·TTL·Lua·typed outcome 정책을 직접 구현합니다.
7. key는 namespace, logical type, slot 정책을 함께 가집니다
SDK의 canonical namespace는 다음 세 token입니다.
{environment}:{service}:{domain}
RedisNamespace.java는 세 token을 소문자 영숫자와 하이픈 규칙으로 검증합니다. QualifiedRedisKey.java는 SDK가 받는 유일한 logical key 형태입니다. 이미 렌더링한 임의 문자열을 넣는 공개 overload가 없습니다.
RedisKeyRenderer.java가 만드는 물리 형식은 다음과 같습니다.
plain: environment:service:domain:entity:identifier
slot: environment:service:domain:{slotTag}:entity:identifier
Cluster hash tag의 중괄호는 renderer만 추가합니다. key는 UTF-8 기준 최대 512 bytes이고, identifier에는 separator가 들어갈 수 없습니다. RedisKeyRules.java는 e-mail, JWT 형태, 국제 전화번호, bearer token처럼 식별 가능한 민감 정보 패턴을 거부합니다. 다만 짧은 숫자처럼 겉모양만으로 개인정보 여부를 판단할 수 없는 값은 호출자가 먼저 pseudonymize해야 합니다.
semantic adapter는 CapabilityKeyspace.java를 사용해 다음 형식을 만듭니다.
environment:service:domain:capability:v{keyVersion}:...
모든 capability가 raw identifier를 내부에서 자동으로 HMAC 처리하는 것은 아닙니다.
- cache는 configuration의 secret reference와 namespace를 이용해 semantic key를 HMAC-SHA256으로 변환하고 hv1:hex digest를 사용합니다.
- rate limit은 inbound transport가 이미 pseudonymized한 subject digest를 받습니다.
- lease는 caller가 제공한 resourceDigest를 신뢰합니다.
- idempotency V2는 IdempotencyScopeDigest가 이미 64자리 lowercase hex HMAC digest임을 요구합니다.
따라서 lease와 idempotency 호출자가 raw 사용자 ID나 API key를 digest 위치에 그대로 넘기면 안 됩니다. application.yml에 lease와 idempotency의 key-hmac-secret-reference 항목이 남아 있지만 현재 RedisCapabilitySettings에는 두 field가 없고 adapter도 사용하지 않습니다. 설정 파일의 존재만 보고 자동 HMAC을 기대해서는 안 됩니다.
8. codec은 일반 typed value와 semantic cache envelope를 구분합니다
typed SDK의 일반 object value는 RedisCodecRegistry.java에 schema를 명시적으로 등록합니다. 중복 schema를 거부하고, 조회 시 등록한 Java type과 요청 type이 일치하는지 검사합니다. class name을 저장 값에서 읽어 decoder를 동적으로 고르는 경로가 없습니다.
VersionedJsonCodec.java은 다음 네 field의 envelope를 사용합니다.
{
"schema": "order-summary",
"version": 1,
"createdAt": "2026-08-07T00:00:00Z",
"payload": "base64..."
}
framing은 JsonEnvelopeFraming.java에서 고정 순서로 기록하고 정확히 네 field만 읽습니다. schema나 readable version이 맞지 않으면 cache miss처럼 넘기지 않고 serialization failure로 처리합니다. encode 전과 decode 전에 maxValueBytes도 확인합니다.
semantic cache는 이 일반 JSON codec과 다른 CacheEnvelope.java를 사용합니다. 현재 schema version은 1입니다. source revision, region generation, soft/hard absolute expiry, authoritative absence flag, payload bytes를 UTF-8 header와 payload로 encode합니다. future, retired, unknown, corrupt schema를 구분합니다.
이 차이를 문서와 migration에서 유지해야 합니다. 일반 typed value의 JSON envelope와 semantic cache envelope는 서로 교환 가능한 포맷이 아닙니다. 기존 README에 적힌 cache envelope v2와 integrity digest 설명도 현재 CacheEnvelope 구현과 일치하지 않습니다.
9. 일부 typed value 쓰기는 TTL을 호출 계약에 포함합니다
일반 typed SDK에서 ordinary SET 계열과 nontransactional integer·double increment는 Expiration.java의 다음 선택지 중 하나를 받습니다.
- Expiration.After: 양수 Duration의 상대 TTL입니다.
- Expiration.At: 절대 expiry Instant입니다.
- Expiration.Persistent: TTL을 두지 않으며 PersistentKeyPermit이 필요합니다.
이 경계는 해당 경로에서 TTL 인자를 생략하거나 의도 없이 영구 key를 만드는 일을 막습니다. 다만 모든 write에 적용되지는 않습니다. APPEND, SETRANGE, transaction의 INCRBY·collection write와 hash/list/set/zset write는 expiration이나 persistent permit 없이 absent key를 만들 수 있습니다.
semantic capability의 TTL은 각각 다른 의미를 가집니다.
| 기능 | TTL 정책 |
|---|---|
| cache positive | hard TTL을 Redis physical TTL로 사용하며, soft TTL은 fresh와 stale의 경계를 정합니다. |
| cache negative | authoritative absence에 더 짧은 negative TTL을 사용합니다. |
| rate limit fixed window | state에 window의 두 배 TTL을 둡니다. |
| rate limit sliding counter | current/previous window 계산을 위해 window의 세 배 TTL을 둡니다. |
| rate limit token bucket | bucket이 완전히 refill되는 데 필요한 horizon을 기준으로 TTL을 계산합니다. |
| lease | 새 획득(status 1)은 request TTL에서 local elapsed와 drift를 차감합니다. same-attempt replay(status 2)는 반환된 PTTL을 버리는 공백이 있습니다. |
| idempotency | claim에는 replay TTL, complete에는 replay retention, failure에는 failure retention을 사용합니다. |
cache 설정은 soft TTL이 hard TTL보다 길면 startup을 실패시키고, hard TTL이 minimum-hard-ttl보다 짧아도 실패시킵니다. 현재 구현에는 deterministic TTL jitter가 없습니다. 운영 hot spot을 줄이기 위한 jitter가 필요하다면 별도 구현과 검증이 필요합니다.
10. semantic cache: fail-open하되 상태를 지우지 않습니다
RedisCacheRegionAdapter.java는 CacheRegionPort를 구현합니다. 물리 키는 다음과 같습니다.
namespace:cache:v{keyVersion}:{region}:{hmacDigest}
namespace:cache:v{keyVersion}:{region}:generation
lookup 흐름은 다음과 같습니다.
- REGULAR lane connection을 빌립니다.
- 이
CacheKeys가 아직 unresolved일 때만INCRBY generation 0으로 server generation을 최초 한 번 읽고, 이후에는 instance-local generation을 사용합니다. - cache key를 GET하고 envelope를 decode합니다.
- envelope generation이 현재 값과 다르면 invalidated miss로 처리합니다.
- hard expiry가 지났으면 miss로 처리합니다.
- absence envelope이면 negative hit를 반환합니다.
- soft expiry 전이면 fresh, soft expiry 이후 hard expiry 전이면 stale을 반환합니다.
Redis 연결·timeout 오류는 ordinary miss로 합치지 않고 unavailable outcome으로 반환합니다. cache-aside orchestration은 CacheAsideExecutor.java가 담당합니다.
local singleflight와 bulkhead는 in-flight key, waiter, source load를 제한합니다. 정책이 허용하는 transient source failure에서만 hard expiry가 지나지 않은 stale 값을 fallback으로 사용할 수 있습니다.
이 구현의 soft refresh는 요청 경로에서 동기적으로 수행됩니다. background refresh-ahead나 비동기 stale-while-revalidate scheduler는 없습니다.
record는 positive hard TTL을, recordAbsent는 negative TTL을 사용합니다. stale refresh처럼 기존 값을 관찰한 쓰기는 현재 entry bytes에서 계산한 observation token을 다시 비교합니다. 다만 비교용 GET과 최종 SET은 원자적이지 않고 generation도 조건에 포함하지 않습니다. observation token은 현재 envelope의 SHA-256 일부에서 만든 opaque 값입니다.
invalidate는 GETDEL을 사용합니다. invalidateRegion은 keyspace scan과 bulk delete 대신 generation을 INCR하고, 호출에 사용한 CacheKeys의 local generation을 갱신합니다. 이미 이전 generation을 cache한 다른 instance에는 이 무효화가 즉시 전파되지 않습니다.
cache는 성능 보조 기능이므로 mutation 실패도 application correctness 실패로 확대하지 않습니다. adapter는 NOT_APPLIED 또는 unavailable 결과를 돌려 use case가 source of truth를 계속 사용할 수 있게 합니다.
다음 기능은 현재 구현돼 있지 않습니다.
- Redis 기반 CacheRefreshCoordinationPort 구현이 없습니다.
- distributed refresh soft lease가 없습니다.
- local L1 cache가 없습니다.
- invalidation Pub/Sub subscriber가 없습니다.
- TTL jitter가 없습니다.
- refresh-ahead와 probabilistic early refresh가 없습니다.
region generation과 JVM local singleflight는 존재하지만, 이를 multi-process distributed refresh coordination으로 해석하면 안 됩니다.
11. distributed rate limit: quota 오류에서 local fallback을 만들지 않습니다
RedisEdgeRateLimitAdapter.java는 RateLimitScripts.java의 Lua를 SCRIPT lane에서 실행합니다. 지원 algorithm은 fixed-window, sliding-counter, token-bucket입니다.
한 요청의 읽기·계산·갱신을 하나의 Lua 실행에 넣어 concurrent 요청 사이의 원자성을 확보합니다. EVALSHA에서 NOSCRIPT가 오면 script를 load하고 한 번만 다시 실행합니다. key에는 policy ID, policy revision, subject digest가 포함됩니다.
흐름은 다음과 같습니다.
- policy ID가 설정 map에 있는지 확인합니다.
- 요청 cost가 policy maximumCost를 넘지 않는지 확인합니다.
- caller deadline이 이미 끝났으면 command를 보내지 않습니다.
- SCRIPT lane에서 해당 algorithm Lua를 평가합니다.
- reply를 allowed, limit, remaining, retryAfter, resetAt으로 변환합니다.
- unknown policy나 잘못된 cost는 incompatible, Redis failure는 unavailable 계열 outcome으로 보존합니다.
failure policy는 fail-closed만 허용합니다. RedisCapabilityConfig.java는 다른 값을 설정하면 startup을 실패시킵니다. inbound 쪽의 EdgeRateLimitTransportBridge.java는 principal, API key, client IP와 operation을 VersionedEdgeSubjectPseudonymizer.java로 HMAC 처리한 후 provider에 전달합니다. RateLimitInterceptor.java는 결과를 통과, HTTP 429, service unavailable, configuration error로 나눕니다.
레이트리밋을 적용할 때는 다음 세 가지 제한을 반영해야 합니다.
첫째, RateLimitRequest의 evaluationId는 adapter와 Lua가 사용하지 않습니다. response loss 후 같은 평가를 다시 보낼 때 중복 소비를 막는 근거로 사용할 수 없습니다.
둘째, policy에 cleanupGrace와 maximumClockRegression이 있지만 현재 adapter는 이를 Lua argument로 전달하지 않습니다. 설정과 validation이 존재한다고 해서 실행 중 clock regression clamp가 적용된다고 보면 안 됩니다.
셋째, sliding counter는 정확한 sliding log가 아니라 현재 window와 이전 window를 가중해 계산하는 근사치입니다. decision의 certainty도 이를 approximate로 표시합니다.
12. distributed lease: 효율 최적화일 뿐 correctness lock이 아닙니다
RedisDistributedLeaseAdapter.java와 LeaseScripts.java는 acquire, inspect, renew, release를 owner token과 operation ID 비교로 원자화합니다.
새 attempt는 random owner token과 caller operation ID를 가집니다. status 1의 새 획득은 request TTL에서 요청 왕복에 걸린 monotonic elapsed와 drift budget을 차감해 local validity를 만듭니다. timeout이나 연결 손실 뒤에는 획득 실패라고 단정하지 않고 INDETERMINATE를 반환합니다. caller는 같은 attempt로 inspect하거나 tryAcquire를 다시 호출해 ownership을 확인해야 합니다.
status 2의 same-attempt replay는 다릅니다. Lua는 TTL을 연장하지 않고 현재 PTTL을 반환하지만 adapter는 그 값을 버리고 request TTL로 handle을 다시 만듭니다. Redis key가 곧 만료되더라도 replay handle은 더 오래 ACTIVE라고 판단할 수 있고, observedServerExpiry도 실제 PTTL이 아닌 local 계산값입니다. 이는 fencing 부재를 논하기 전부터 server lease와 local validity가 어긋나는 경로입니다.
key 형식은 다음과 같습니다.
namespace:lease:v{keyVersion}:{purpose}:{resourceDigest}
이 lease의 guarantee는 EFFICIENCY_ONLY입니다. fencing token이 없고 protected resource가 stale token을 거부하는 경계도 없습니다. 결제, 재고, unique ID 발급처럼 한 명만 성공해야 하는 domain invariant의 유일한 보호 장치로 사용하면 안 됩니다.
또한 LeaseRequest에 waitTimeout이 있지만 현재 adapter는 한 번의 즉시 tryAcquire만 수행합니다. contentionRetryAfter를 outcome에 제공할 수는 있어도, adapter 내부에서 deadline까지 대기·재시도하는 loop는 없습니다. watchdog, 자동 renew scheduler, 작업 취소 callback도 현재 production source에서 확인되지 않습니다.
13. Redis idempotency V2: owner와 revision을 끝까지 전달합니다
RedisIdempotencyStoreAdapter.java는 IdempotencyScripts.java의 Redis hash state machine을 사용합니다.
claim은 다음 상태를 구분합니다.
- 처음 보는 scope이면 owner, attempt, revision, operation ID, fingerprint, codec, policy revision, lease deadline을 기록하고 CLAIMED를 반환합니다.
- 이미 완료된 동일 fingerprint 요청이면 stored response를 replay합니다.
- processing lease가 끝났거나 retryable failure 상태이면 새 owner가 takeover할 수 있습니다.
- 다른 owner가 처리 중이면 IN_PROGRESS를 반환합니다.
- fingerprint가 다르면 같은 idempotency key의 다른 요청이므로 mismatch를 반환합니다.
markExecutionStarted, renew, complete, markFailed, releaseBeforeExecution은 owner token과 operation ID를 확인하고, 상태에 따라 state revision을 비교합니다. 다만 generic transition script는 target state 확인을 revision 검사보다 먼저 수행합니다. 현재 EXECUTING -> EXECUTING renew는 ALREADY로 끝나 lease를 갱신하지 않습니다.
IdempotencyExecutorV2.java는 confirmed start 뒤 action을 실행하고 mutation이 모호하면 inspect로 reconcile합니다. 그러나 같은 retained attempt가 이미 EXECUTING인 record를 다시 만나거나, 불확실한 응답 뒤 inspect가 EXECUTING_SAME_OPERATION을 반환하면 action을 다시 호출할 수 있습니다. 이 상태 머신만으로 exactly-once를 보장한다고 해석하면 안 됩니다.
key는 다음 정보를 포함합니다.
namespace:idem:v{keyVersion}:d{digestVersion}:{operationCode}:{scopeDigest}
IdempotencyScopeDigest.java는 scopeDigest가 이미 HMAC 처리된 64자리 lowercase hex라고 요구합니다. Redis adapter 자체는 raw principal과 idempotency key를 HMAC하지 않습니다.
exactly-once가 아닌 이유는 두 층에 있습니다. 첫째, 앞서 본 same-attempt 재진입 경로가 한 process 안에서도 action을 다시 호출할 수 있습니다. 둘째, business action의 외부 side effect와 Redis state transition 사이에 하나의 transaction이 생기지 않습니다. action 결과가 발생한 뒤 complete가 확정되지 않으면 recovery가 필요한 상태가 남습니다.
HTTP 요청과의 integration도 아직 부분적입니다. inbound의 IdempotencyKeySupport.java는 기존 V1 IdempotencyScope와 SHA-256 request fingerprint, JSON response codec을 만듭니다. 이 경로에서 V2 IdempotencyScopeDigest와 새 executor로 연결하는 production bridge는 확인되지 않습니다. Redis store와 executor bean이 존재한다는 사실만으로 모든 HTTP idempotency 요청이 V2를 사용한다고 단정하면 안 됩니다.
StoredResponse는 opaque String이고 semantic adapter에서 typed SDK의 maxValueBytes guard를 통과하지 않습니다. 현재 adapter/script에는 response payload의 명시적 byte 상한도 확인되지 않으므로, 실제 사용 전에 transport 또는 codec 경계에서 크기 제한을 추가해야 합니다.
14. Redis HTTP session은 저장소와 최초 인증 경로가 없습니다
redis-session 모드에는 web security 경계 일부가 구현돼 있습니다. RedisSessionWebConfig.java는 @EnableSpringHttpSession을 활성화하고 Secure, HttpOnly, SameSite, path, session-only, Base64, host-only cookie 정책을 설정합니다.
PrimitiveSessionSecurityContextRepository.java는 SecurityContext 전체를 Java serialization으로 넣지 않습니다. principal, e-mail, token, role, authority를 제한된 primitive binary snapshot으로 encode하며 전체 크기를 16 KiB로 제한합니다. decode가 손상된 데이터를 만나면 session attribute를 제거하고 빈 context로 처리합니다.
그러나 이 클래스는 Spring Session의 Redis SessionRepository가 아닙니다. production main source에는 RedisVersionedSessionRepository 구현이나 redisVersionedSessionRepository bean이 확인되지 않습니다. AuthenticationModeCompositionConfig.java는 redis-session을 선택했을 때 redisVersionedSessionRepository와 springSessionRepositoryFilter를 모두 요구합니다. 현재 템플릿만으로 선택하면 저장소가 자동 구성되는 것이 아니라 startup 검증에서 멈추는 경로입니다.
repository만 추가해도 인증 mode가 완성되지는 않습니다. session security branch는 CSRF, IF_REQUIRED, fixation migration, primitive context repository를 설정하지만 snapshot이 없는 요청에서 인증된 Authentication 객체를 최초로 만드는 form login, HTTP Basic, custom authentication filter나 production login endpoint는 확인되지 않습니다. persistence와 최초 인증을 모두 구현하고 end-to-end로 검증해야 합니다.
따라서 현재 구현에는 다음 보장을 부여할 수 없습니다.
- raw session ID의 HMAC physical key 변환
- idle timeout과 absolute lifetime을 함께 적용하는 Redis session 저장소
- create, inspect, save, touch, revoke, rotate Lua state machine
- concurrent stale save 방지와 session ID rotation 원자성
- Redis topology에서의 session qualification
- snapshot이 없는 요청의 최초 authentication
기존 README에는 이 기능들이 구현 candidate로 설명돼 있지만 현행 production source가 뒷받침하지 않습니다. web cookie와 SecurityContext codec이 있다는 사실과 Redis session persistence가 있다는 사실을 분리해야 합니다.
15. transaction, script, function은 별도 programmability 표면입니다
RedisTransactionOperations.java는 WATCH, MULTI, EXEC 기반 optimistic transaction을 제공합니다. 이 transaction은 rollback을 제공하지 않습니다. EXEC 중 한 command가 runtime error를 내더라도 앞뒤 command가 되돌아가지 않습니다. API 결과도 “queue가 실행됨”과 “watched key가 바뀌어 아무것도 실행되지 않음”을 구분할 뿐 rollback 성공을 표현하지 않습니다.
transaction은 전용 connection을 점유합니다. Cluster에서는 watched key와 written key가 한 slot이어야 하며 guard가 전송 전에 검사합니다.
RedisScriptOperations.java는 arbitrary script body를 인자로 받지 않습니다. deployment에서 검토·등록한 RegisteredRedisScript만 실행하며, script가 만지는 모든 key를 QualifiedRedisKey 목록으로 선언해야 합니다.
RedisFunctionOperations.java도 이미 배포된 RegisteredRedisFunction만 호출합니다. request path에서 FUNCTION LOAD로 server-side code를 올리는 API는 없습니다.
programmability interface와 Lettuce 구현은 존재하지만, 이들도 기본 RedisOperations facade에 포함되지 않으며 production auto-configuration bean으로 조합되는 경로는 확인되지 않습니다. 사용하려면 전용 lane, registry, policy guard를 유지하는 composition이 별도로 필요합니다.
16. raw, admin, extensions는 escape hatch가 아니라 별도 배포 결정입니다
Raw gateway
RedisRawGateway.java는 execute(String, byte[]...) 형태를 제공하지 않습니다. ApprovedRawCommand, bounded argument, RawCommandPolicyToken이 있어야 합니다. 설정에서 raw를 켜면 별도 credential과 readable allowlist resource가 필요합니다.
기본 raw policy resource 경로는 classpath:redis-sdk/raw-command-allowlist.yml이지만 이 모듈은 해당 파일을 기본으로 제공하지 않습니다. RedisSdkAutoConfiguration.java는 raw가 켜진 상태에서 resource가 없거나 읽을 수 없으면 startup을 실패시킵니다. 따라서 raw.enabled=true만 설정해 즉시 사용할 수 있는 기능이 아닙니다.
Admin plane
RedisAdminOperations.java은 INFO section, DBSIZE, MEMORY USAGE, bounded SLOWLOG, LATENCY LATEST, bounded client projection, CLUSTER INFO, fixed configuration projection, ACL DRYRUN처럼 read-only 진단만 제공합니다. FLUSHDB, FLUSHALL, SHUTDOWN, CONFIG SET, CLIENT KILL 같은 파괴적 명령은 catalog에서 BLOCKED이고 public method도 없습니다.
Extensions
extensions에는 RedisJSON, Search, TimeSeries, probabilistic 자료구조용 interface와 Lettuce 구현이 있습니다. probabilistic 표면은 Bloom, Cuckoo, Count-Min Sketch, Top-K, t-digest 계열을 포함합니다.
ExtensionCommandRunner.java는 QualifiedRedisKey와 command guard를 사용합니다. permit과 operation budget은 policy name이 있는 command에만 붙고 null-policy path에는 둘 다 없습니다. 어느 분기도 관측 reply byte를 검사하지 않습니다. 대상 Redis에 해당 module이 실제 설치되어 있는지는 배포가 보장해야 하며, 이 extension 집합도 auto-configured application bean으로 확인되지는 않습니다.
17. 오류는 원인보다 실행 확실성을 먼저 보존합니다
Redis write에서 가장 위험한 오류는 “실패했다”가 아니라 “응답은 못 받았지만 server가 실행했을 수도 있다”입니다. 이를 ordinary exception으로만 처리하고 자동 재시도하면 같은 mutation을 두 번 적용할 수 있습니다.
LettuceExceptionTranslator.java는 typed SDK executor와 함께 조합됐을 때 timeout, connection loss, LOADING, BUSY, NOSCRIPT, READONLY, redirection, CROSSSLOT, WRONGTYPE, OOM, MISCONF 등을 안정된 RedisOperationException 하위 타입으로 바꿉니다. RedisFailureMetadata.java는 다음 정보를 low-cardinality metadata로 유지합니다.
- command와 access level
- read인지 write인지
- deployment mode
- retryable인지
- mutation 실행이 ambiguous인지
- failure가 pre-send인지 stored-data corruption인지
translator는 retryable과 ambiguous를 동시에 true로 만들지 않습니다. read timeout은 retryable할 수 있지만, write timeout은 server 적용 여부를 모를 수 있으므로 ambiguous입니다. raw Redis error 전문, key, value는 metadata에 넣지 않습니다.
SyncRedisCommandExecutor.java는 guard와 translator를 주입해 조합한 경로에서 guard를 통과한 뒤 driver invocation 구간의 예외만 실행 ambiguity 판단 대상으로 삼습니다. command가 성공한 뒤 observation sink가 실패했다고 해서 적용된 write를 Redis 실패로 바꾸지 않습니다. reactive class는 ReactiveRedisCommandExecutor.java가 같은 원칙을 구현합니다.
CommandPolicyGuard, Sync/Reactive executor, LettuceExceptionTranslator의 동작과 테스트는 존재하지만 production bean 조합은 확인되지 않습니다. 따라서 위 오류 의미론을 현재 모든 Redis 호출에 공통으로 적용된 보장이라고 읽으면 안 됩니다. 특히 semantic cache, rate-limit, lease, idempotency adapter는 RedisRuntimeOwner lane을 직접 빌리고 자체 outcome·예외 처리를 사용하며, typed executor와 guard를 경유하지 않습니다.
재시도 정책은 “Redis 오류면 다시 보낸다”가 아닙니다.
- pre-send rejection은 mutation이 실행되지 않았으므로 caller가 정책에 따라 다시 시도할 수 있습니다.
- retry-safe read는 유한한 retry 정책을 둘 수 있습니다.
- ambiguous write는 일반 재시도 대상이 아닙니다.
- semantic script는 NOSCRIPT에 한해 script load 후 한 번 재평가합니다.
- idempotency와 lease는 같은 owner·operation identity로 inspect/reconcile합니다.
18. 적용 전에 확인해야 할 조건
이 모듈을 실제 서비스에서 선택하려면 코드 존재 여부 외에 다음을 확인해야 합니다.
- app.redis.enabled와 capability selector가 함께 설정되어야 합니다.
- namespace environment/service/domain이 ACL key pattern과 일치해야 합니다.
- application, advanced, Pub/Sub, raw, admin 계정의 권한을 실제 전송 command와 대조해야 합니다.
- Sentinel은 master name, Cluster는 database 0과 same-slot key 계획이 필요합니다.
- TLS trust material과 hostname verification 정책을 정해야 합니다.
- command timeout, queue, in-flight command/bytes, blocking connection, transaction connection 상한을 workload에 맞게 검증해야 합니다.
- cache key HMAC secret과 rate-limit subject HMAC secret의 rotation 전략을 정해야 합니다.
- lease resourceDigest와 idempotency scopeDigest를 누가 생성하는지 application 경계에서 명시해야 합니다.
- semantic response payload 크기 제한을 별도로 확인해야 합니다.
- 사용하는 Redis server version과 module 설치 여부를 deployment topology lane과 필요한 transport lane에서 검증해야 합니다.
저장소에는 standalone, Sentinel, Cluster deployment topology lane과 별도 TLS transport lane을 선택하는 opt-in redisTopologyTest task, 그리고 lane별 최소 실행 테스트 수 gate가 정의되어 있습니다. 실행 방법은 infra/redis-sdk/README.md와 cache-redis build.gradle에 있습니다.
이 문서를 검토한 root 세션에서는 ./gradlew :adapter:outbound:cache-redis:test --console=plain이 성공했습니다. 이 결과는 기본 Redis 모듈 test task의 증거입니다. 실제 standalone, Sentinel, Cluster, TLS lane은 이 세션에서 실행하지 않았으므로, 실서버 qualification을 이번 실행의 결과로 기록하지 않습니다. Redis 7.4의 standalone·Sentinel·Cluster 세 topology evidence는 support-matrix.md에 기록된 기존 결과입니다. TLS 7.4는 infra/redis-sdk/README.md에 과거 실행 기록이 있지만 support matrix의 certified table에는 row가 없으므로 certified 범위로 강화하지 않습니다.
현재 선택이 유효한 범위와 되돌릴 조건
이 구조는 Redis 사용을 넓게 열기보다 조직의 key, TTL, command, ACL, failure policy를 코드 경계로 강제해야 할 때 유효합니다. semantic port로 application을 Redis에서 분리할 수 있고, primitive SDK는 catalog·guard·executor를 composition root에서 조합한 경우에 typed key와 command guard 아래에 둘 수 있습니다. 현재 production 자동 구성은 후자의 조합을 제공하지 않습니다.
반대로 소수의 단순 캐시만 필요하고 command catalog와 자체 codec을 계속 유지할 팀이 없다면 이 SDK의 유지 비용이 더 클 수 있습니다. 그 경우에도 semantic port는 유지한 채 더 작은 provider 구현으로 교체하는 편이 application use case에 Redis API를 직접 퍼뜨리는 것보다 변경 범위가 작습니다.
현재 코드에서 다음 항목이 필요하다면 “이미 문서에 있으니 제공된다”고 판단하지 말고 구현과 검증을 먼저 추가해야 합니다.
- RedisOperations와 ReactiveRedisOperations aggregate bean 조합
- command catalog, CommandPolicyGuard, Sync/Reactive executor, LettuceExceptionTranslator, typed operation의 production DI
- Redis-backed Spring SessionRepository와 최초 authentication mechanism
- cache L1과 invalidation Pub/Sub
- cache TTL jitter와 distributed refresh coordination
- fencing token이 있는 correctness lease
- same-attempt replay의 PTTL을 반영하는 lease local validity
- rate-limit evaluation deduplication과 clock-regression 설정 적용
- inbound idempotency V2 digest/executor bridge
- semantic idempotency response의 byte 상한
- same-attempt action 중복과 no-op renew를 막는 idempotency lifecycle
- 모든 SDK surface의 관측 reply byte ceiling
- Spring runtime client의 단일 lifecycle authority
- raw/admin/extension/programmability 표면의 production DI
이 목록은 단순한 향후 개선 제안이 아닙니다. 현재 source가 제공하는 보장과 제공하지 않는 보장의 경계입니다. Redis처럼 timeout 뒤의 실행 여부와 key 수명이 correctness에 직접 영향을 주는 저장소에서는 이 경계를 기능 목록보다 먼저 문서화해야 합니다.
시리즈에서 이어 읽기
- SDK 정책부터 읽기: 「YAML 한 줄이 Redis 명령을 거절하기까지」
- capability 코드부터 읽기: 「Redis 캐시 한 요청의 전 생애」
- 운영 관점으로 마무리하기: 「Redis를 켠다는 말의 운영적 의미」
Redis 모듈 해부: Gradle leaf에서 app-bootstrap까지
이 글이 답하는 코드 질문
Redis 구현은 설계 문서에서 여러 SDK 모듈처럼 보이지만, 실제 Gradle 그래프에서는 :adapter:outbound:cache-redis 하나입니다. 그렇다면 API, Lettuce 구현, raw, admin, extension 사이의 경계는 어디에서 강제될까요? 이 글은 다음 질문에 답합니다.
- Redis leaf는 19개 모듈 레지스트리에서 어떤 위치를 차지합니까?
- leaf가 참조할 수 있는 프로젝트와
app-bootstrap이 조립하는 프로젝트는 어떻게 다릅니까? - 한 Gradle 프로젝트 안의 SDK 하위 모듈은 어떤 package 규칙으로 분리됩니까?
- Spring Boot는 leaf에 있는 auto-configuration을 어떻게 찾습니까?
기준은 source HEAD 3b5aee50e33c44c02d08c94bb39ad34814482010입니다.
먼저 보는 파일 지도
| 파일 | 입력 | 출력·역할 | 다음에 볼 곳 |
|---|---|---|---|
modules.json |
module id, Gradle path, 허용 의존, runtime membership | 19개 leaf의 선언 | settings.gradle |
settings.gradle |
modules.json |
레지스트리 검증 후 include된 Gradle project |
각 leaf의 build.gradle |
cache-redis/build.gradle |
허용된 project edge와 외부 라이브러리 | Redis leaf compile/runtime classpath | sdk package와 topology test task |
app-bootstrap/build.gradle |
runtime composition membership | 실제 애플리케이션에 Redis leaf 포함 | Spring component scan과 auto-configuration |
AutoConfiguration.imports |
auto-configuration class 이름 | RedisSdkAutoConfiguration 발견 |
app.redis.enabled 조건 |
RedisSdkModuleBoundaryTest |
sdk 아래 Java source tree |
package 존재 여부와 import 위반 목록 | package별 구현 |
Gradle leaf가 생기는 순서
settings.gradle은 디렉터리를 재귀 탐색해 project를 추측하지 않습니다. 먼저 modules.json을 읽고 root field가 정확히 runtime_compositions, modules인지 검사합니다. runtime composition은 app-bootstrap, sample-portfolio 두 개여야 하고 module 수는 정확히 19개여야 합니다. 이 검증은 settings.gradle의 초기화 코드에 있습니다.
각 module entry도 id, gradle_path, source_path, allowed_dependencies, runtime_memberships 다섯 field만 허용합니다. 중복 id, 중복 Gradle path, 저장소 밖으로 빠져나가는 source path, 존재하지 않는 directory, 알 수 없는 runtime membership은 설정 단계에서 실패합니다. 검증을 통과한 항목만 include와 projectDir 지정으로 Gradle project가 됩니다.
flowchart LR
A[modules.json] --> B[settings.gradle schema 검증]
B -->|정상| C[19개 project include]
B -->|위반| X[Gradle 설정 실패]
C --> D[:adapter:outbound:cache-redis]
D --> E[:app-bootstrap runtime graph]
Redis 항목은 modules.json 115행에서 확인할 수 있습니다.
- id는
adapter-outbound-cache-redis입니다. - Gradle path는
:adapter:outbound:cache-redis입니다. - 허용 project 의존은
domain-core,application-core,shared-contract,adapter-outbound-support입니다. - runtime membership은
app-bootstrap하나입니다.sample-portfolio에는 Redis leaf가 들어가지 않습니다.
여기서 runtime_memberships는 “이 leaf를 어느 실행 조합이 포함해야 하는가”라는 architecture 선언입니다. 실제 classpath edge는 별도로 app-bootstrap/build.gradle이 만듭니다. app-bootstrap 의존 선언은 implementation project(':adapter:outbound:cache-redis')를 포함합니다. 레지스트리 membership과 build dependency가 같은 방향을 가리키는 구조입니다.
leaf의 허용 의존과 실제 의존
Redis leaf의 project dependency는 cache-redis/build.gradle 13행에 세 개가 선언되어 있습니다.
| 선언 | 왜 필요한가 | 현재 읽을 때 주의할 점 |
|---|---|---|
application-core |
cache, lease, idempotency semantic port 구현 | SDK package 자체의 공개 API 의존과 semantic adapter 의존을 구분해야 합니다. |
shared-contract |
rate-limit port와 health contract | leaf 전체의 의존이며 모든 SDK package에서 허용된다는 뜻은 아닙니다. |
adapter:outbound:support |
outbound 공통 지원 | modules.json에서 허용된 edge입니다. |
외부 의존은 Spring Boot auto-configuration/health, Lettuce, Reactor, SLF4J입니다. 공개 reactive API가 Reactor type을 signature에 쓰므로 reactor-core를 직접 선언합니다. 반대로 Spring Data Redis와 Micrometer는 의도적으로 없습니다. 그 이유와 zero-import 기대는 build.gradle 33행에 적혀 있습니다.
이 부재는 두 가지 경계를 만듭니다.
- Redis 명령은 Spring Data의 문자열 중심 표면을 통과하지 않고 자체 typed API와 command policy를 통과합니다.
- SDK가
MeterRegistry를 직접 알지 않습니다. 관찰값을 sink에 넘기는 지점과 실제 metric backend 조립을 분리합니다.
다만 두 번째 경계에는 현재 공백이 있습니다. RedisObservation type과 실행기 sink seam은 구현되어 있지만, app-bootstrap에서 Micrometer/OTel sink를 만드는 production bean은 확인되지 않습니다. package 경계를 “관측이 완성됐다”는 뜻으로 읽으면 안 됩니다.
한 leaf 안의 package 모듈
설계의 SDK 모듈은 별도 Gradle project가 아니라 dev.caskeleton.adapter.outbound.cache.redis.sdk 아래 package로 구현됩니다. 그 결정은 build.gradle 머리말과 RedisSdkModuleBoundaryTest 설명이 함께 고정합니다.
테스트의 DESIGNED_MODULES는 `70행부터 22개 package 경계를 열거합니다.
- 공개 표면:
api,api/key,api/codec,api/command,api/error,api/operations,api/reactive - Lettuce 구현:
lettuce,lettuce/codec,lettuce/command,lettuce/connection,lettuce/observability,lettuce/operations - 정책·topology 지원:
config,cluster - 격리 표면:
programmability,raw,admin - extension:
extensions/json,extensions/search,extensions/timeseries,extensions/probabilistic
NOT_YET_IMPLEMENTED_MODULES는 현재 빈 목록입니다. 따라서 테스트는 22개 package directory가 모두 존재해야 통과합니다. 이것은 directory와 경계가 있다는 계약이지, 모든 interface가 production bean으로 조립됐다는 계약은 아닙니다.
SDK 밖에는 semantic adapter package도 있습니다. cache, ratelimit, lease, idempotency, keyspace가 그 예입니다. 이들은 provider-neutral port를 Redis runtime에 연결하며 app-bootstrap의 RedisCapabilityConfig가 선택적으로 bean을 만듭니다.
import 방향을 강제하는 규칙
가장 엄격한 경계는 sdk.api입니다. FORBIDDEN_IMPORTS는 API package가 다음을 import하지 못하게 합니다.
- Spring, Lettuce, Micrometer
sdk.lettuce,cluster,programmability,raw,admin,config,extensions
그 밖에도 Lettuce package는 raw/admin/extensions를, cluster와 programmability는 raw/admin을, raw와 admin은 서로를 import하지 못합니다. apiPackageDoesNotDependOnDrivers()는 source의 import 문을 읽어 위반을 모읍니다.
Reactive type도 api/reactive와 구현에만 머물러야 합니다. reactorIsConfinedToReactivePackages()는 다른 공개 API에 Reactor import가 들어오면 실패합니다.
두 개의 source scan은 API 모양 자체를 제한합니다.
noArbitraryStringCommandApi()는execute(String ...),call(String ...)같은 임의 명령 표면을 거부합니다.noJavaNativeSerialization()는ObjectOutputStream,ObjectInputStream,java.io.Serializable사용을 거부합니다.
이 테스트들은 Java compiler나 ArchUnit의 complete type graph가 아니라 정규식 기반 source scan입니다. fully qualified type 사용이나 새로운 문법 형태가 규칙 의도를 우회하지 않는지 review가 여전히 필요합니다.
Spring runtime 진입점
Redis leaf가 app-bootstrap classpath에 들어온 뒤에는 두 경로가 작동합니다.
첫째, SDK 기반 bean은 AutoConfiguration.imports가 RedisSdkAutoConfiguration을 Spring Boot에 등록합니다. 이 class는 app.redis.enabled=true일 때만 설정 binding, credential resolution, client, runtime owner, health contributor를 만듭니다.
둘째, semantic capability는 app-bootstrap package의 RedisCapabilityConfig가 맡습니다. 이 configuration도 global switch를 요구하고, cache/rate-limit/lease/idempotency selector마다 port bean을 따로 만듭니다.
따라서 호출 순서는 다음과 같습니다.
sequenceDiagram
participant G as Gradle runtime graph
participant B as Spring Boot
participant A as RedisSdkAutoConfiguration
participant C as RedisCapabilityConfig
G->>B: cache-redis leaf를 classpath에 포함
B->>A: AutoConfiguration.imports 발견
A->>A: app.redis.enabled 조건 평가
A-->>B: settings/client/owner/health bean
B->>C: component scan으로 bootstrap config 발견
C-->>B: 선택된 semantic port bean
정상 분기와 실패 분기
정상적인 Redis-off 배포에서는 leaf가 classpath에 있어도 SDK bean이 생기지 않습니다. module membership은 “코드를 사용할 수 있음”이고 app.redis.enabled는 “이번 deployment에서 runtime을 만든다”입니다.
Redis-on 배포에서는 settings가 검증된 뒤 client와 owner가 생깁니다. role selector가 Redis를 가리킬 때만 해당 semantic port가 추가됩니다.
다음은 request-time 전에 실패합니다.
- registry schema, module 수, path, dependency id가 어긋나면 Gradle 설정이 실패합니다.
- leaf dependency가 registry 허용 범위를 벗어나면 architecture 검증 대상이 됩니다.
- SDK package가 금지 import를 추가하면 module boundary test가 실패합니다.
app.redis.enabled=true인데 settings/credential/topology 전제조건이 맞지 않으면 Spring context가 실패합니다.- global switch가 꺼져 있는데 role selector가 Redis를 고르면
RedisActivationValidator가 모순을 보고합니다.
테스트가 고정하는 계약
RedisSdkModuleBoundaryTest는 package inventory, import 방향, Reactor 격리, 임의 문자열 명령 금지, Java native serialization 금지를 고정합니다. 이 테스트는 실제 package source를 정렬해 읽으므로 scan 자체가 비어 있는 경우도 sourceScanIsDeterministic()에서 잡습니다.
RedisCapabilityCompositionTest는 runtime owner만 있는 경우와 selector별 port가 있는 경우를 구분합니다. 이 테스트는 연결을 열지 않으므로 bean graph 계약입니다.
실제 topology 연결은 redisTopologyTest라는 별도 opt-in task입니다. cache-redis/build.gradle 68행은 standalone, Sentinel, Cluster, TLS lane을 구분하고, 기본 test는 redis-topology tag를 제외합니다. 이번 문서 작업에서는 이 real-server lane을 실행하지 않았습니다.
현재 구현 공백과 잘못 읽기 쉬운 지점
- 22개 designed package가 모두 존재하지만 이것은 production DI 완성을 뜻하지 않습니다. aggregate
RedisOperations/ReactiveRedisOperations, command guard/executor/translator의 production 조립은 확인되지 않습니다. RedisConnectionRegistry는 source와 단위 테스트가 있으나 production 생성 지점은 없습니다. 현행 connection pool과 shutdown은RedisRuntimeOwner가 담당합니다.RedisStartupProbe와RedisCapabilityProbe도 production bean/호출자가 없습니다. 따라서 server version, command presence, write durability가 실제 startup에서 확인된다고 말할 수 없습니다.- auto-configuration import는 SDK 기반 bean만 찾습니다. semantic port는
app-bootstrap의 component scan에 의존합니다. sample-portfolioruntime membership에는 Redis leaf가 없습니다. repository에 Redis 코드가 있다는 사실만으로 두 runtime composition 모두 Redis를 포함한다고 읽으면 안 됩니다.
다음에 열어볼 source와 관련 글
다음 순서로 읽으면 경계에서 조립으로 자연스럽게 이어집니다.
modules.jsonRedis entrycache-redis/build.gradleRedisSdkModuleBoundaryTestAutoConfiguration.importsRedisCapabilityConfig
시리즈에서 이어지는 주제는 Spring 조립, 설정·credential, topology factory, connection lifecycle, health·observability입니다.
시리즈에서 이어 읽기
- 전체 흐름: 「Redis를 범용 클라이언트가 아니라 정책 경계로 다루기」
- 운영 흐름: 「Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지」
app.redis.enabled에서 capability bean까지: Spring 조립 코드 읽기
이 글이 답하는 코드 질문
app.redis.enabled=true는 Redis 기능 전체를 켜는 selector가 아닙니다. 이 값은 공통 SDK runtime을 만들 권한이고, cache·rate-limit·lease·idempotency·session은 각자의 selector를 가집니다. 이 글은 Spring context refresh 동안 어떤 조건과 method가 어떤 bean을 만드는지, 그리고 현재 5개 semantic role 중 왜 4개만 production 조립되는지를 추적합니다.
코드 지도
| 클래스·리소스 | 입력 | 출력 | 다음 호출 |
|---|---|---|---|
AutoConfiguration.imports |
classpath | RedisSdkAutoConfiguration 등록 |
global switch 조건 |
RedisSdkAutoConfiguration |
app.redis.*, secret source, resource loader |
settings, credentials, client, owner, health beans | topology factory |
RedisCapabilityConfig |
owner, settings, capability selector/settings | 4종 semantic port와 V2 executor | request-time adapter |
RedisCapabilitySettings |
ca-skeleton.capabilities.* |
cache/rate-limit/lease/idempotency 세부 설정 | 각 bean factory method |
RedisActivationValidator |
global switch와 5개 role selector | 정상 종료 또는 startup failure | 없음 |
SecretSourceConfig |
secret source strategy, environment | SecretSource, 두 startup validator |
Redis secret bridge |
객체 생성 시점: 두 composition root
SDK 쪽 auto-configuration은 @ConditionalOnProperty로 app.redis.enabled=true를 요구합니다. 값이 false이거나 property가 없으면 이 클래스가 제공하는 bean은 만들어지지 않습니다.
bootstrap 쪽 RedisCapabilityConfig도 같은 global condition을 사용합니다. 두 class의 책임은 다릅니다.
RedisSdkAutoConfiguration: provider 공통 runtime을 만듭니다.RedisCapabilityConfig: deployment가 선택한 provider-neutral semantic port를 그 runtime 위에 만듭니다.
이 분리는 Redis on과 Redis가 어떤 역할을 맡음을 같은 뜻으로 만들지 않습니다. global switch만 켜고 role을 하나도 고르지 않으면 client와 owner는 있지만 semantic port는 없습니다. noRoleComposesNoPort()가 이 상태를 고정합니다.
context refresh 호출 순서
sequenceDiagram
participant E as Environment
participant S as RedisSdkAutoConfiguration
participant V as Settings validation
participant F as TopologyClientFactory
participant O as RedisRuntimeOwner
participant C as RedisCapabilityConfig
participant A as RedisActivationValidator
E->>S: app.redis.enabled 평가
S->>V: bind RedisSdkSettings 후 validate
V->>S: warnings 또는 예외
S->>S: credential reference resolve
S->>F: validated settings + credentials
F-->>S: RedisRuntimeClient
S->>O: lane limit + drain timeout
C->>C: role selector별 semantic bean 생성
A->>E: off + Redis role 모순 검사
A-->>E: 정상 또는 모든 모순을 묶은 startup failure
세부 순서는 bean dependency로 고정됩니다.
redisSdkSettings()가 mutable settings 객체를 만들고@ConfigurationProperties(prefix="app.redis")로 binding합니다.redisSdkSettingsValidation()이validate()와 raw allowlist resource 검사를 실행합니다.redisResolvedCredentials()는 validation bean에 의존하므로 검증 뒤 reference를 해석합니다.redisRuntimeClient()가 topology factory를 호출합니다. client object와 event-loop resource는 이때 생기지만 lane connection은 아직 열리지 않습니다.redisRuntimeOwner()가 여섯 lane의 ceiling과 drain timeout을 받습니다.RedisCapabilityConfig의 조건이 맞는 factory method만 semantic bean을 만듭니다.
실제 Redis TCP connection은 request-time에 owner가 처음 borrow()할 때 RedisRuntimeClient.openLane()을 호출하며 lazy하게 열립니다. 따라서 bean graph가 성공했다는 사실만으로 endpoint 접속과 인증 성공을 증명하지 않습니다.
context close에는 두 client shutdown 경로가 겹칩니다
생성 dependency 때문에 context 종료 시 redisRuntimeOwner()이 client bean보다 먼저 destroy됩니다. owner의 explicit close()는 lane을 drain한 뒤 내부에서 runtime client를 닫습니다. 그러나 redisRuntimeClient()는 destroy inference를 끄지 않은 일반 @Bean입니다. 반환 type인 RedisRuntimeClient는 AutoCloseable을 확장하고 public no-arg close()를 노출합니다. Spring이 다음으로 client bean의 inferred destroy를 실행하면 같은 client의 close()가 다시 호출될 수 있습니다.
따라서 auto-configuration 주석의 “owner before client”는 종료 순서를 설명하지만 client shutdown authority가 owner 하나뿐임을 보장하지는 않습니다. owner state가 CLOSED인지 확인하는 context test와 종료 후 Lettuce thread가 남지 않는 live test는 있지만, runtime client close 횟수를 세는 context-level test는 없습니다.
SecretSource bridge가 필요한 이유
SDK는 RedisSecretSource라는 작은 interface만 압니다. bean이 없으면 process environment를 직접 읽는 fallback을 씁니다.
애플리케이션은 별도의 SecretSource를 composition root에서 선택합니다. redisSdkSecretSource()는 이를 method reference로 SDK에 연결합니다. 이 bridge가 없으면 향후 secret manager backend를 선택해도 Redis만 process environment를 직접 읽게 됩니다.
4/5 semantic composition
현재 RedisCapabilityConfig가 production bean으로 만드는 역할은 네 가지입니다.
Cache
ca-skeleton.capabilities.cache.bindings.default=redis이면 redisDefaultCacheRegion()이 실행됩니다. method는 cache TTL을 검증하고, key HMAC secret을 해석한 뒤 RedisCacheRegionAdapter<String, byte[]>를 CacheRegionPort로 반환합니다.
정상 출력은 cache port 하나입니다. soft TTL이 hard TTL보다 크거나 hard TTL이 floor보다 작거나 command timeout/key version이 유효하지 않으면 bean creation이 실패합니다. HMAC secret reference가 없거나 secret을 찾지 못해도 startup failure입니다.
Rate limit
ca-skeleton.capabilities.rate-limit.provider=redis이면 redisEdgeRateLimitPort()가 RedisEdgeRateLimitAdapter를 만듭니다.
policiesOf()는 policy가 하나도 없으면 실패하고, defaultPolicyId가 map에 없으면 실패합니다. algorithm은 fixed-window, sliding-counter, token-bucket만 받습니다. failure policy는 현재 fail-closed만 지원하며 다른 값은 policyOf()에서 거부합니다.
Lease
ca-skeleton.capabilities.lease.provider=redis이면 redisDistributedLeasePort()가 RedisDistributedLeaseAdapter를 반환합니다. 이 port는 efficiency용 lease이며 fencing을 제공하지 않습니다. 조립 성공을 distributed lock correctness로 확대하면 안 됩니다.
Idempotency V2
ca-skeleton.capabilities.idempotency.provider=redis이면 redisIdempotencyStore()가 owner-safe IdempotencyStorePortV2를 만듭니다. 이어 idempotencyExecutorV2()가 같은 selector 아래 provider-neutral V2 executor를 만듭니다.
Session 공백
다섯 번째 selector ca-skeleton.security.auth-mode=redis-session은 activation validator와 correctness health predicate에는 들어 있습니다. 그러나 RedisCapabilityConfig에는 session repository를 만드는 method가 없습니다. production source에는 snapshot이 없는 요청에서 인증된 Authentication 객체를 최초로 만드는 form login, HTTP Basic, custom authentication filter나 login endpoint도 확인되지 않습니다. 즉 Redis session을 선택하면 global runtime 조건과 readiness 조건에는 반영되지만 SessionRepository와 springSessionRepositoryFilter로 이어지는 persistence 경로와 최초 인증 경로는 완성되지 않습니다. 이것이 4/5 composition입니다.
selector와 global switch의 모순 처리
RedisActivationValidator.REDIS_SELECTING_VALUES는 다음 다섯 selector를 압니다.
| 역할 | Redis를 선택하는 값 |
|---|---|
| default cache binding | redis |
| rate limit provider | redis |
| idempotency provider | redis |
| lease provider | redis |
| auth mode | redis-session |
global switch가 true이면 validator는 즉시 끝납니다. false이면 selector를 모두 검사해 모순을 정렬하고 하나의 requiredAdapterDisabled startup failure로 묶습니다. 첫 번째 missing bean에서 멈추는 대신 잘못된 설정을 한 번에 보여 줍니다. afterSingletonsInstantiated()가 이 동작을 구현합니다.
중요한 순서상의 특성이 있습니다. RedisCapabilityConfig 자체는 switch-off일 때 존재하지 않으므로 semantic bean을 만들지 않습니다. validator는 별도 SecretSourceConfig에서 unconditional bean으로 생성되어 모순을 설명합니다. SecretSourceConfig.redisActivationValidator()를 보면 이 연결이 보입니다.
정상·거절·timeout 분기
정상
- switch off + Redis role 없음: Redis settings도 bean도 만들지 않고 시작합니다.
- switch on + role 없음: validated runtime과 optional health contributor만 만듭니다.
- switch on + 1개 이상 role: 공통 owner 위에 선택된 semantic bean만 만듭니다.
- switch on + 4개 구현 role: cache, rate-limit, lease, idempotency V2가 동시에 한 namespace를 씁니다.
startup 거절
- switch off + Redis role: activation validator가 설정 모순으로 거절합니다.
- switch on + invalid settings/credential/resource/topology: SDK bean dependency chain에서 거절합니다.
- rate-limit 선택 + policy 없음/unknown default/unsupported algorithm: rate-limit bean creation에서 거절합니다.
- cache 선택 + TTL/HMAC 설정 오류: cache bean creation에서 거절합니다.
request-time timeout과 unavailable
조립 class는 command를 전송하지 않습니다. request-time timeout, ambiguous execution, typed unavailable은 semantic adapter와 command executor의 책임입니다. 다만 connection은 lazy하므로 잘못된 endpoint나 password가 context refresh 뒤 첫 borrow/command에서 드러날 수 있습니다. production startup probe가 조립되지 않은 현재 상태에서는 이 차이가 남습니다.
테스트가 고정하는 계약
RedisSdkAutoConfigurationTest는 absent/off switch에서 settings조차 없고 malformed Redis property도 무시되는 것을 고정합니다. on 상태에서는 settings binding, credential role별 resolution, topology mode, owner lifecycle, raw/admin fail-fast를 확인합니다. theRuntimeOwnerFollowsTheContext()는 종료 뒤 owner state만 확인하므로 client의 exactly-once close를 고정하지 않습니다.
RedisCapabilityCompositionTest는 server 없이 bean graph만 검사합니다.
cacheBindingComposesTheCacheRegion(): cache만 선택하면 다른 port가 생기지 않습니다.rateLimitProviderComposesThePort(): web bridge가 요구하는 rate-limit port가 생깁니다.aRateLimiterWithoutPoliciesIsRefused(): 빈 policy 설정은 startup failure입니다.allRolesComposeTogether(): 구현된 네 port가 동시에 생깁니다.theSwitchOffComposesNothing(): role property가 있어도 configuration은 아무 bean도 만들지 않습니다.
RedisActivationValidatorTest는 다섯 role 각각과 다중 모순 보고를 고정합니다.
real-server 행동은 LiveRedisCompositionTest에 있지만 기본 test에서 제외되는 opt-in topology lane입니다. 이번 작성에서는 실행하지 않았습니다.
현재 구현 공백과 잘못 읽기 쉬운 지점
app.redis.enabled=true는 semantic capability가 존재한다는 뜻이 아닙니다. selector와 bean을 따로 확인해야 합니다.- session selector는 validator와 readiness에는 포함되지만 session repository production bean과 인증된
Authentication객체를 최초로 만드는 production mechanism은 없습니다. 두 공백을 모두 해결하고 end-to-end 인증·session persistence를 검증해야 합니다. - aggregate
RedisOperations와ReactiveRedisOperationsfacade, command guard/executor/translator의 production DI도 확인되지 않습니다. semantic adapter는RedisRuntimeOwner와 직접 조립됩니다. RedisStartupProbe/RedisCapabilityProbe는 production bean과 server-fact collector가 없습니다. context refresh 성공은 endpoint reachability나 server capability 확인이 아닙니다.- owner destroy가 runtime client를 닫은 뒤 client bean의 inferred destroy가 같은
close()를 다시 부를 수 있습니다. lifecycle authority와 exactly-once 보장이 production bean graph와 context test에서 명확하지 않습니다. - capability settings의 validation은 한 곳에서 일괄 실행되지 않습니다. 예를 들어 cache validation은 cache bean factory가 호출될 때 실행되고, rate-limit은
policiesOf()에서 검증됩니다. - idempotency는 V2 store와 executor가 조립되지만 기존 V1 inbound bridge가 자동으로 V2를 쓰는지는 별도 문제입니다.
다음에 열어볼 source와 관련 글
RedisSdkAutoConfigurationRedisCapabilityConfigRedisActivationValidatorRedisCapabilityCompositionTest
이어지는 시리즈 주제는 설정·Secret·Credential, topology factory, connection lane lifecycle, health/readiness, semantic capability별 request 흐름입니다.
시리즈에서 이어 읽기
- 전체 흐름: 「Redis를 범용 클라이언트가 아니라 정책 경계로 다루기」
- 운영 흐름: 「Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지」
Redis 설정은 어떻게 실패하는가: 바인딩·검증·Secret·Credential 추적
이 글이 답하는 코드 질문
Redis 설정에는 endpoint, topology, timeout, pool ceiling, TLS, ACL account가 함께 들어갑니다. 이 값들은 언제 binding되고, 어느 단계에서 거절되며, secret://... reference는 어떻게 실제 username/password가 될까요? 이 글은 Spring property에서 RedisURI에 전달될 credential까지의 경로와 현재 environment/secret registry drift를 구분합니다.
코드 지도
| 코드 | 입력 | 출력 | 실패 위치 |
|---|---|---|---|
RedisSdkSettings |
app.redis.* |
typed 설정과 warning 목록 | validate() |
RedisSdkAutoConfiguration.redisSdkSettings() |
Spring binder | bound settings bean | binding failure |
RedisCredentialResolver |
purpose + secret://<source>/<name> |
optional RedisCredentials |
malformed/unresolved reference |
RedisResolvedCredentials |
role별 credential | immutable role map + Sentinel credential | client factory 이전 |
SecretSourceConfig |
strategy + environment | application SecretSource |
backend 생성 |
SecretSourceValidator |
profile, role selectors, secret source | prod secret contract | singleton 초기화 종료 시점 |
env-keys.yaml |
환경 키 계약 | 분류·기본값·required_when | registry test/build gate |
secrets-classification.yaml |
secret 이름 | source·rotation·masking 계약 | registry contract test |
Redis-off에서는 binding도 하지 않습니다
RedisSdkSettings에는 일부 유효한 local default가 있지만 class 자체에는 @ConfigurationProperties가 없습니다. 이유는 `class 설명에 적혀 있습니다. application-wide scan이 이 type을 발견하면 Redis를 쓰지 않는 deployment도 값을 binding하고 검증하게 됩니다.
실제 등록은 app.redis.enabled=true 조건 아래의 redisSdkSettings()만 합니다. switch가 없거나 false이면 다음 모두 생략됩니다.
app.redis.*binding- cross-field validation
- credential reference resolution
- raw allowlist와 TLS material 읽기
- client/event loop/runtime owner 생성
disabledIgnoresMalformedRedisConfiguration()은 switch-off 상태에서 Cluster non-zero database, 빈 nodes, zero timeout 같은 값도 context에 영향을 주지 않는다고 고정합니다.
bind → validate → resolve 순서
Spring은 factory method가 settings 객체를 반환한 다음 configuration property를 채웁니다. 그래서 factory method 안에서 validate()를 부르면 아직 default만 검사하게 됩니다. 별도 validation bean이 settings에 의존하는 이유입니다.
sequenceDiagram
participant B as Spring Binder
participant S as RedisSdkSettings
participant V as SettingsValidation bean
participant R as RedisCredentialResolver
participant SS as RedisSecretSource
participant F as TopologyClientFactory
B->>S: app.redis.* binding
V->>S: validate()
S-->>V: warnings 또는 IllegalStateException
V->>V: raw policy resource probe
R->>SS: reference의 name resolve
SS-->>R: secret 또는 empty
R-->>F: role별 username/password
redisSdkSettingsValidation()은 warning을 log하고 raw gateway가 켜졌다면 allowlist resource가 실제로 읽히는지 확인합니다. redisResolvedCredentials()는 이 validation bean을 parameter로 받아 순서를 강제합니다.
RedisSdkSettings.validate()가 거절하는 것
핵심 cross-field rule은 validate()에 모여 있습니다.
topology와 namespace
- Cluster에서 database가 0이 아니면 거절합니다.
- 음수 database와 빈 node 목록을 거절합니다.
- Sentinel이면
app.redis.sentinel.master-name이 필요합니다. - namespace의
environment,service,domain은RedisKeyRules.requireToken()을 통과해야 합니다.
Standalone node가 정확히 하나인지, host:port 문법인지 여부는 settings가 아니라 topology factory가 검사합니다. settings validation이 성공해도 client factory 단계에서 실패할 수 있습니다.
timeout과 limit
fast, collection, script, batch, admin timeout은 모두 양수이며 30초 이하여야 합니다. fast timeout이 5초를 넘으면 failure가 아니라 warning입니다. 기본값은 Timeouts field에서 각각 500ms, 2s, 1s, 2s, 3s입니다.
blocking maxBlock은 양수여야 하고 blocking/transaction connection ceiling도 1 이상이어야 합니다. key/value/stream/hash/batch/scan/offline queue/bitmap limit은 모두 양수이며 key byte limit은 RedisKeyRules.MAX_KEY_BYTES를 넘을 수 없습니다. capacity의 in-flight command/byte/reply ceiling도 양수여야 합니다.
여기서 양수 검증과 runtime 적용을 구분해야 합니다. limits.offlineQueueCommands는 기본값이 1,000이고 1 미만이면 거절되지만, production main source에서 getOfflineQueueCommands()를 호출하는 코드는 없습니다. Lettuce의 실제 requestQueueSize는 이 값이 아니라 capacity.maximumInFlightCommands를 사용합니다. 두 기본값도 각각 1,000과 64로 다릅니다.
TLS와 lifecycle
mTLS client certificate를 지정했는데 client key reference가 없으면 실패합니다. TLS가 켜졌지만 hostname verification을 끄면 warning입니다. lifecycle은 nonblank client name, positive connect/TLS-handshake/acquire/shutdown/drain timeout, nonnegative quiet period, quietPeriod <= shutdownTimeout을 요구합니다. 이 규칙은 Lifecycle.validate()에 있습니다.
현재 tlsHandshakeTimeout과 acquireTimeout도 binding과 validation은 되지만 production 사용처가 getter 외에는 확인되지 않습니다. owner는 pool 포화 시 즉시 거절하며 acquire timeout 동안 대기하지 않습니다. 이들 setting과 offlineQueueCommands를 runtime에 적용된 값으로 설명하면 안 됩니다.
raw, admin, advanced
raw gateway가 켜지면 nonblank policy resource와 raw 전용 credential reference가 필요합니다. admin plane이 켜지면 admin credential reference가 필요합니다. advanced operation이 꺼진 상태에서 advanced policies를 설정하면 실패합니다.
raw resource의 nonblank 검사는 settings가 하고, 존재/가독성 검사는 requireRawPolicyResource()가 합니다. 기본 raw path는 classpath:redis-sdk/raw-command-allowlist.yml이지만 해당 이름의 resource를 leaf가 제공하지 않습니다. raw를 실제로 켤 때는 존재하는 resource로 명시해야 합니다.
authentication
application credential reference가 없으면 기본적으로 startup failure입니다. local anonymous Redis를 쓰려면 app.redis.authentication.anonymous-access-accepted=true를 명시해야 하고, 이 경우 warning을 남깁니다. advanced credential이 없으면 script가 application account로 fallback한다는 warning을 남깁니다. Authentication.validate()가 이 두 trade-off를 구분합니다.
credential reference 해석
허용 문법은 secret://<source>/<name>입니다. named ACL user를 지정하려면 source segment를 <user>@<source>로 씁니다.
예를 들어 secret://ca-skeleton-application@environment/APP_REDIS_PASSWORD는 다음으로 분해됩니다.
- scheme:
secret:// - ACL username:
ca-skeleton-application - source label:
environment - secret name:
APP_REDIS_PASSWORD
RedisCredentialResolver.resolve()는 reference가 blank이면 Optional.empty()를 반환합니다. scheme이 다르거나 source/name separator가 없으면 configuration error입니다. secret source가 null/blank 값을 반환하면 connection 생성 전 startup failure입니다.
source segment에 @가 없으면 username은 default입니다. 이 동작은 usernameOf()에 있습니다. RedisCredentials.toString()은 password를 ***로 바꿔 출력합니다.
source 문자열은 현재 backend routing에 쓰이지 않습니다. resolver는 마지막 path name만 secretSource.apply(name)에 넘깁니다. 즉 secret://vault/NAME이라고 써도 vault backend를 자동 선택하지 않습니다. 실제 backend는 application의 SecretSourceConfig가 선택합니다.
역할별 credential과 fallback
redisResolvedCredentials()은 다음 순서로 account를 해석합니다.
APPLICATIONADVANCEDPUBSUB- admin enabled일 때
ADMIN - raw enabled일 때
RAW - Sentinel mode일 때 별도 Sentinel control credential
설정되지 않은 advanced/pubsub role은 map에 들어가지 않습니다. topology factory의 role router가 해당 lane을 application client로 보냅니다. admin과 raw는 enabled 상태에서 reference가 필수이므로 암묵적으로 application account에 내려가지 않습니다.
Sentinel credential은 data primary account와 다릅니다. Sentinel control plane이 primary 위치를 조회할 때 쓸 credential이고 application credential은 발견된 primary에 명령을 보낼 때 씁니다.
application SecretSource와 prod validator
기본 application backend는 EnvironmentSecretSource입니다. Spring Environment에서 key를 읽고 null/blank를 empty로 바꿉니다. SecretSourceFactory의 enum switch에는 현재 ENVIRONMENT만 있습니다.
RedisCapabilityConfig.redisSdkSecretSource()가 application SecretSource를 SDK interface에 연결합니다. 따라서 정상적인 app-bootstrap 실행에서는 SDK의 System.getenv() fallback 대신 configured backend를 사용합니다.
SecretSourceValidator.afterSingletonsInstantiated()은 prod profile에서 두 검사를 합니다.
- property source에
__LOCAL_DEV_prefix 값이 있으면 거절합니다. REQUIRED_PROD_SECRETS중 현재 Redis role에 필요한 secret이 없으면 거절합니다.
Redis secret은 global switch와 role selector가 모두 맞을 때만 요구됩니다. cache, rate-limit, session, idempotency, lease prefix를 따로 판정하며 알 수 없는 Redis role은 Redis-on일 때 fail-closed로 요구합니다.
environment/secret registry drift
현재 production code와 registry 사이에는 중요한 불일치가 있습니다.
첫째, SDK와 topology tests는 application credential 예시로 APP_REDIS_PASSWORD를 사용합니다. 그러나 env-keys.yaml과 secrets-classification.yaml에는 APP_REDIS_PASSWORD entry가 확인되지 않습니다. 대신 classification registry는 APP_CACHE_REDIS_PASSWORD, APP_RATE_LIMIT_REDIS_PASSWORD, APP_SESSION_REDIS_PASSWORD 같은 이전 role별 이름을 유지합니다.
둘째, SecretSourceValidator.REQUIRED_PROD_SECRETS도 이 role별 legacy secret 이름을 요구합니다. 반면 SDK는 app.redis.authentication.credential-reference에 적힌 임의의 <name>을 해석합니다. validator는 실제 reference target을 읽지 않습니다.
그 결과 prod deployment가 APP_REDIS_PASSWORD를 올바르게 주입하고 reference를 그 이름으로 설정해도, 선택한 role에 따라 APP_CACHE_REDIS_PASSWORD나 APP_RATE_LIMIT_REDIS_PASSWORD가 없다는 별도 startup failure를 만날 수 있습니다. 반대로 registry가 요구한 role별 password가 있어도 SDK reference가 다른 이름을 가리키면 SDK resolver에서 실패합니다.
셋째, env-keys.yaml은 app.redis.* typed settings가 application.yml에 없고 generated configuration metadata와 대조된다고 설명합니다. 이 구조는 intentional입니다. 따라서 application.yml에 APP_REDIS_NODES placeholder가 없다는 사실 자체는 drift가 아닙니다. 문제는 credential material의 실제 reference target과 prod required-secret 목록이 서로 다른 SSOT를 가진다는 점입니다.
넷째, APP_REDIS_LIFECYCLE_ACQUIRE_TIMEOUT과 APP_REDIS_LIFECYCLE_TLS_HANDSHAKE_TIMEOUT은 env-keys.yaml runtime 설정 구간에 등록되어 있지만 현행 runtime 적용 코드를 찾지 못했습니다. APP_REDIS_LIMITS_OFFLINE_QUEUE_COMMANDS도 public configuration으로 등록되어 binding·validation되지만, 값을 바꿔도 현행 Lettuce requestQueueSize는 바뀌지 않습니다. 실제 queue ceiling의 입력은 capacity.maximumInFlightCommands입니다.
정상·실패 분기 요약
| 단계 | 정상 | 실패 |
|---|---|---|
| switch 조건 | off이면 완전 생략 | off + Redis role은 activation validator failure |
| binding | typed value로 변환 | duration/enum/type binding 오류 |
| settings validation | warning 또는 validated settings | cross-field IllegalStateException |
| resource validation | raw/TLS resource 읽기 가능 | startup failure |
| credential parse | optional role 또는 parsed reference | literal/malformed reference 거절 |
| secret resolve | nonblank secret | connection 전에 resolved to nothing |
| prod secret contract | selected role secret 존재 | legacy required list와 실제 reference drift 가능 |
이 구간의 failure는 command가 전송되기 전이므로 execution certainty는 NOT_SENT 성격입니다. 실제 authentication 실패는 connection이 lazy하게 열릴 때 발생할 수 있습니다. reference resolution 성공은 server가 password를 받아들였다는 증명이 아닙니다.
테스트가 고정하는 계약
RedisSdkSettingsTest는 topology/database, timeout, lane ceiling, TLS, raw/admin, authentication warning과 failure를 직접 고정합니다.
RedisSdkAutoConfigurationTest는 configured role별 secret source 호출 횟수와 unresolved/malformed reference의 startup failure를 확인합니다. configuredAccountsAreResolvedPerRole()는 application/advanced/pubsub account map을 고정합니다.
RequiredWhenIsEnforcedTest는 env registry의 required_when 조건을 context failure와 대조합니다. SecretsClassificationRegistryTest는 validator의 required secret list와 classification registry를 1:1로 맞춥니다. 이 테스트들은 두 registry가 서로 일치함을 보이지만 SDK reference target과의 일치까지 보이지는 않습니다.
SecretSourceValidatorTest는 prod/local sentinel과 role별 조건을 고정합니다.
현재 한계와 다음 source 순서
- credential rotation은 restart-only입니다. runtime refresh/dual credential handover가 조립되지 않았습니다.
secret://의 source segment는 현재 backend selector가 아니라 문법·username carrier입니다.RedisStartupProbeproduction 조립이 없어 reference resolution 뒤 실제 authentication과 server fact 확인은 lazy connection/request에 남습니다.- prod required secret validator와 실제 SDK credential reference target은 정렬되지 않았습니다.
- lifecycle acquire/TLS handshake timeout과
limits.offlineQueueCommands는 registry와 settings에는 있으나 runtime 적용이 확인되지 않습니다. 현행 LettucerequestQueueSize는 별도 capacity setting을 사용합니다.
다음에는 RedisSdkSettings.validate(), RedisCredentialResolver.resolve(), redisResolvedCredentials(), SecretSourceValidator 순서로 읽으면 됩니다.
관련 시리즈 주제는 topology별 URI/client 생성과 role별 lane routing입니다.
시리즈에서 이어 읽기
- 전체 흐름: 「Redis를 범용 클라이언트가 아니라 정책 경계로 다루기」
- 운영 흐름: 「Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지」
하나의 설정에서 세 topology로: RedisTopologyClientFactory 코드 읽기
이 글이 답하는 코드 질문
동일한 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입니다.
시리즈에서 이어 읽기
- 전체 흐름: 「Redis를 범용 클라이언트가 아니라 정책 경계로 다루기」
- 운영 흐름: 「Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지」
Redis 연결을 여섯 lane으로 나눈 이유: Pool과 RuntimeOwner 생명주기
이 글이 답하는 코드 질문
Redis connection은 thread-safe하다는 설명만 보면 하나를 공유해도 될 것처럼 보입니다. 하지만 blocking command, transaction, script, Pub/Sub, admin은 connection 상태와 권한이 다릅니다. 이 글은 여섯 RedisConnectionKind가 어떻게 account와 pool ceiling을 고르고, RedisRuntimeOwner가 borrow·return·invalidate·drain·close를 어떤 순서로 처리하는지 설명합니다.
먼저 보는 클래스 지도
| 클래스 | 입력 | 출력 | 다음 호출 |
|---|---|---|---|
RedisConnectionKind |
command descriptor 또는 explicit lane | lane과 credential role | runtime client role router |
RedisRuntimeOwner |
runtime client, lane limits, drain timeout | typed RedisLease |
gateway 또는 return |
RedisLease |
borrowed lane connection | gateway, invalidate, close | owner.release |
RedisRuntimeClient |
kind + optional routing key | 새 driver lane connection | owner idle pool |
RedisConnectionRegistry |
generic factory + limit | untyped legacy lease | 현재 production에서 호출되지 않음 |
RedisSdkAutoConfiguration.redisRuntimeOwner() |
settings + runtime client | Spring destroy method를 가진 owner bean | request-time borrow |
여섯 lane과 격리하는 실패 모드
RedisConnectionKind는 정확히 여섯 값을 가집니다.
| 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()은 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)는 admission과 connection acquisition을 나눕니다.
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는 kind, connection, reusable, closed를 가집니다.
gateway()는 close 전까지만 접근할 수 있습니다.invalidate()는reusable=false로 바꿉니다.close()는 synchronized이며 한 번만release()를 호출합니다.
release()는 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()에서 양수 여부를 검사합니다. 그러나 production main source에는 getOfflineQueueCommands()의 호출자가 없습니다. 따라서 이 값을 바꿔도 현행 driver request queue의 runtime ceiling은 바뀌지 않습니다.
Lettuce client 내부의 실제 requestQueueSize는 capacity.maximumInFlightCommands로 설정됩니다. connection lease ceiling과 driver command queue는 다른 층입니다. owner limit을 통과했다고 해서 driver queue가 반드시 여유 있다는 뜻은 아닙니다.
lifecycle 상태 전이
owner state는 OPEN, DRAINING, CLOSED 세 개입니다.
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()의 순서는 다음과 같습니다.
- atomic CAS로
OPEN -> DRAINING을 수행합니다. 실패하면 이미 닫는 중이거나 닫혔으므로 return합니다. - 새 borrow는 즉시 거절됩니다.
- outstanding 합계가 0이 될 때까지
drainTimeout안에서 monitor wait합니다. - deadline이 지나면 outstanding 수를 warning으로 남기고 계속 종료합니다.
- 모든 idle deque를 비우고 pooled connection을 닫습니다.
- 마지막에 runtime client를 닫습니다.
- 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()가 정확히 한 번만 호출된다는 뜻은 아닙니다.
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()은 client bean에 의존하고 explicit destroyMethod="close"를 가집니다. 따라서 context는 owner를 먼저 destroy하고, owner는 내부에서 client.close()를 호출합니다. 한편 redisRuntimeClient()는 destroy method inference를 끄지 않은 일반 @Bean입니다. 반환 type인 RedisRuntimeClient는 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()와 ClusterRuntimeClient.close()에는 별도 closed guard가 없습니다. role router도 close()가 호출될 때마다 하위 client를 닫습니다. 현재 Lettuce가 반복 shutdown을 받아들일 수 있더라도, 이 구조만으로 lifecycle ownership이 exactly-once라고 말할 수는 없습니다.
RedisConnectionRegistry와 현행 owner를 구분합니다
RedisConnectionRegistry는 문서 주석에서 “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는 server 없이 lifecycle을 직접 검사합니다.
aLeaseIsReturnedAndPooled(): close once, double-close no-op, pool reuseanInvalidatedConnectionIsNotReused(): invalidated transaction connection closeanExhaustedLaneRefuses(): queue 대신 즉시 rejectionclosingStopsAdmissionFirst(): DRAINING에서 새 lease 거절theClientShutsDownLast(): connection close 뒤 client shutdownaDeadPooledConnectionIsReplaced(): idle-dead replacement
RedisConnectionRegistryTest는 lane routing과 counter 격리를 고정하지만 legacy/non-production class의 단위 계약입니다.
LiveRedisCompositionTest.aLeaseReachesTheServer()는 PING 뒤 outstanding이 0인지 확인합니다. closingTheContextTearsEverythingDown()는 context 종료 뒤 Lettuce thread 수가 원래 수준으로 돌아오는지 확인합니다. 이들은 opt-in real-server lane이며 이번 문서 작업에서는 실행하지 않았습니다.
직접 owner test의 theClientShutsDownLast()는 fake client가 connection 뒤에 닫히는 순서를, closingTwiceIsIdempotent()는 owner.close()를 두 번 불러도 fake client shutdown이 한 번임을 고정합니다. 둘 다 Spring이 client bean을 별도로 destroy하는 경로는 포함하지 않습니다. auto-configuration test의 theRuntimeOwnerFollowsTheContext()는 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 구성에는 쓰이지 않습니다. 실제 LettucerequestQueueSize는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, RedisRuntimeOwner.borrow(), release(), close() 순서로 읽으면 됩니다.
관련 시리즈 주제는 executor timeout과 execution certainty입니다.
시리즈에서 이어 읽기
- 전체 흐름: 「Redis를 범용 클라이언트가 아니라 정책 경계로 다루기」
- 운영 흐름: 「Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지」
YAML 한 줄이 Redis 명령을 거절하기까지: Policy Loader·Catalog·Guard
이 글이 답하는 코드 질문
Redis 명령 하나가 애플리케이션 코드에서 Lettuce 호출로 넘어가기 전에 무엇을 검사합니까?
이 질문은 다음 세 경계를 나눠 읽어야 답할 수 있습니다.
- YAML은 조직이 명령을 어떻게 분류했는지 기록합니다.
- catalog는 분류되지 않은 명령을 기본 거절합니다.
- guard는 서버 능력, permit, namespace, slot, budget, timeout을 순서대로 검사합니다.
기준은 source HEAD 3b5aee50e33c44c02d08c94bb39ad34814482010, 2026-08-13입니다.
정적 조사 결과 정책 파일에는 명령과 subcommand를 합쳐 314개 항목이 있습니다. WAIT 항목은 없습니다. 따라서 현재 WAIT는 허용 명령이 아니라 catalog 조회에서 거절되는 default-deny 대상입니다.
먼저 보는 클래스·리소스 지도
| 진입점 | 입력 | 출력 | 다음 호출 |
|---|---|---|---|
| redis-command-policy.yml | 명령별 scalar 필드 | 조직 정책 314개 | RedisCommandPolicyLoader |
| RedisCommandPolicyLoader.loadDefault | classpath YAML | Map<CommandId, RedisCommandPolicy> |
RedisCommandCatalog |
| RedisCommandCatalog.require | CommandId |
분류된 policy | CommandPolicyGuard |
| CommandRequest | key, 크기, permit, budget, block, 지연된 invocation | 실행 전 요청 | executor |
| CommandPolicyGuard.validate | CommandRequest |
CommandAdmission |
sync/reactive/queueing executor |
| ConfiguredRedisPermitVerifier | permit와 요구 policy | 통과 또는 거절 | guard/context |
| RedisCommandDescriptor | policy에서 파생된 값 | 실행 불변식 | lane·translator |
CommandRequest.invocation은 이미 시작한 future가 아니라 Supplier<CompletionStage<R>>입니다. invocation field 선언 덕분에 guard가 끝나기 전에는 driver call이 시작되지 않습니다.
객체 생성 시점과 request-time을 구분합니다
객체 생성 시점
의도된 조립 순서는 다음과 같습니다.
RedisCommandPolicyLoader가/redis-sdk/redis-command-policy.yml을 읽습니다.- loader가 각 block을
RedisCommandPolicy로 바꿉니다. RedisCommandCatalog가 immutable map을 소유합니다.- deployment 설정으로
ConfiguredRedisPolicyAuthority와 verifier를 만듭니다. - probed
RedisCapabilities,RedisNamespace, renderer, slot calculator로 guard를 만듭니다. - guard와 translator를 sync/reactive/queueing executor에 주입합니다.
그러나 이 순서가 production Spring bean으로 완성됐다고 볼 근거는 없습니다. RedisSdkAutoConfiguration은 runtime client와 owner를 만들지만 catalog, authority, verifier, guard, executor bean은 만들지 않습니다.
request-time
sequenceDiagram
participant O as Typed/Advanced operation
participant R as CommandRequest
participant G as CommandPolicyGuard
participant C as RedisCommandCatalog
participant E as Executor
participant L as Lettuce gateway
O->>R: key·size·optional permit/budget·invocation 구성
E->>G: validate(request)
G->>C: require(commandId)
C-->>G: policy 또는 default-deny
G->>G: reachability→capability→permit→namespace→slot→budget(if present)→timeout
G-->>E: CommandAdmission
E->>L: invocation.get()
여기서 관측한 reply 크기와 예외 번역은 validate 안에 있지 않습니다. guard의 주석은 전체 pipeline을 요약하지만, 실제 validate는 admission까지 담당합니다. requireRequestBudget이 비교하는 값은 request byte와 request builder가 미리 선언한 expectedReplyBytes입니다. typed decoder path에서 서버가 돌려준 byte·element 수를 OperationBudget과 비교하려면 해당 decoder가 RedisOperationContext.requireReplyWithinBudget을 명시적으로 호출해야 합니다.
그 호출은 MGET, bounded range, collection page 같은 일부 typed decoder에는 있지만 모든 경로에 있지는 않습니다. 기본 GET request은 expectedReplyBytes가 0이고 decode에도 관측 reply 검사가 없습니다. script, function, raw, admin은 budget을 request에 붙이지만 결과 decoder 앞에서 관측 크기를 검사하지 않습니다. extension은 더 나뉩니다. policy name이 있으면 collection budget을 붙이고 null이면 budget을 비우며, 어느 분기도 관측 reply 크기를 검사하지 않습니다.
batch는 이 typed helper를 쓰지 않는 별도 경로입니다. preflight에서는 item의 declared expectedReplyBytes 합계를 검사하고, 응답 뒤에는 decoded result shape의 근사치를 누적합니다. 이 값은 exact wire bytes가 아닙니다. 따라서 admission을 통과했다는 사실만으로 실제 reply byte ceiling까지 집행됐다고 말할 수 없습니다.
YAML parser가 fail-closed인 방식
loader는 범용 YAML parser를 사용하지 않습니다. 허용하는 문법은 commands: root 하나, 명령 block, scalar field뿐입니다.
readBlocks는 다음 입력을 거절합니다.
- tab이 들어간 문서
- 두 번째 root 또는
commands:가 아닌 root - root보다 먼저 나온 command block
- 0·2·4칸 외 indentation
- 중복 command
- 알 수 없는 field
- 값이 비어 있는 field
- 중복 field
허용 field 집합은 FIELDS에 고정되어 있습니다. risk와 support는 필수입니다. boolean은 정확히 true 또는 false여야 합니다.
기본값도 정책입니다
toPolicy는 생략한 값을 다음처럼 채웁니다.
minimum-version:7.2read-only:falseblocking:falseretry-safe:read-only값may-be-ambiguous:!read-onlykey-spec:1 1 1access: support class에서 파생timeout-profile: risk와 blocking에서 파생
key-spec은 none, movable, 또는 <first> <last> <step>만 읽습니다. keySpec parser가 다른 표기를 거절합니다.
R1~R4와 support class는 다른 축입니다
RedisRiskLevel은 비용과 위험을 분류합니다.
| risk | 코드상 의미 |
|---|---|
R1 |
bounded single-key ordinary command |
R2 |
O(N), 큰 reply, blocking, multi-key, 큰 payload 등; permit와 budget 필요 |
R3 |
server·client·ACL·topology 작업; application path 거절 |
R4 |
destructive; SDK 전체 차단 |
CommandSupport은 어떤 surface로 노출하는지 정합니다.
TYPEDADVANCED_TYPEDRAW_ONLYADMIN_ONLYVERSION_GATEDBLOCKED
두 축의 조합은 자유롭지 않습니다. RedisCommandDescriptor constructor는 R4가 BLOCKED가 아니거나 R3가 ADMIN_ONLY/BLOCKED가 아니면 실패합니다. ambiguous write를 retry-safe로 표시하는 조합도 거절합니다.
Guard의 실제 검사 순서
validate의 순서는 다음과 같습니다.
- catalog에서 policy를 찾습니다.
BLOCKED,NONE, application에서 도달할 수 없는 risk를 거절합니다.- probed server version이
minimumVersion을 만족하는지 봅니다. - R2이면 permit와 budget을 요구합니다.
- 모든 key가 process namespace에 속하는지 확인하고 render합니다.
- key의 slot을 계산하고 Cluster에서 여러 slot이면 거절합니다.
- request byte와 예상 reply byte가 budget 이내인지 확인합니다.
- effective timeout을 계산합니다.
- descriptor로 connection lane을 정해
CommandAdmission을 반환합니다.
이 순서에서 실패하면 invocation supplier는 평가되지 않습니다. 즉 namespace 위반이나 budget 초과는 Redis 서버 오류가 아니라 전송 전 SDK 거절입니다.
Permit은 marker interface가 아닙니다
R2 요청에 AdvancedOperationPermit 구현체를 넣었다고 통과하지 않습니다. ConfiguredRedisPermitVerifier.check는 네 가지를 확인합니다.
- concrete granted type인가
- 현재 authority의 issuer id인가
- HMAC signature가 맞는가
- command가 요구한 policy name과 같은가
여러 key를 건드리는 R2 요청은 advanced permit과 별개로 multi-key permit이 필요합니다. 별도 검사는 한 permit이 비싼 연산 승인과 fan-out 승인을 동시에 뜻하지 않게 합니다.
정상·거절·timeout 분기
정상 분기
GET처럼 catalog의 R1/TYPED 명령은 server version과 namespace를 통과하면 기본 FAST timeout 500ms와 REGULAR lane을 받습니다.
R2 명령은 올바른 policy로 발급된 permit, 필요한 multi-key permit, 양수 budget을 갖춰야 admission을 받습니다. non-blocking 명령과 server block을 선언하지 않은 optional-blocking 명령에서는 budget timeout이 policy 기본 timeout을 덮습니다.
거절 분기
- catalog에 없는 명령:
RedisCommandRejectedException, not sent BLOCKED/R4: SDK 전체 거절- server version 미달:
RedisCapabilityUnavailableException - permit 없음·위조·다른 policy:
RedisCommandRejectedException - namespace 이탈:
RedisCommandRejectedException - Cluster cross-slot:
RedisCrossSlotException - request/예상 reply budget 초과:
RedisCommandRejectedException - bounded block 누락·0·음수·상한 초과:
RedisCommandRejectedException
blocking 명령이 bounded server block을 선언한 경우에는 budget timeout을 쓰지 않습니다. effectiveTimeout은 block 상한을 검사한 뒤 serverBlock + BLOCKING_MARGIN(2초)를 반환합니다. BLPOP처럼 block이 필수인 명령은 선언이 없으면 거절하고, XREAD처럼 optional인 명령은 block을 생략했을 때만 budget 또는 profile timeout으로 돌아갑니다.
WAIT는 현재 사용할 수 없습니다
정책 YAML의 314개 block을 정적으로 세었지만 WAIT block은 찾지 못했습니다. catalog는 unknown command에 permissive fallback을 두지 않습니다. default-deny require 때문에 WAIT를 typed, raw, semantic surface에서 실행할 수 있다고 읽으면 안 됩니다.
기존 operations.md의 WAIT 설명은 실행 가능한 현행 surface의 근거가 아닙니다.
테스트가 고정하는 계약
policy loader 테스트는 계약마다 시작점을 나눠 읽을 수 있습니다.
GET의 R1과KEYS의BLOCKED/NONE- access·timeout·retry·ambiguity 파생
- version-gated minimum version 보존
- 모든 R4 command 차단
- advanced command의 required policy name
- unknown field·enum·duplicate·tab·잘못된 root 거절
- unclassified command default-deny
- deprecated command name 차단
- arbitrary
EVAL차단과 registeredEVALSHA분리
guard 테스트도 한 링크에 여러 사례를 묶지 않습니다.
- ordinary command의 lane과 timeout
- R2 permit·budget 필수
- caller 구현 permit 거절
- advanced permit만 있는 multi-key 요청 거절
- 두 permit을 가진 multi-key advanced 요청 허용
- 다른 policy용 permit 거절
- blocked command 거절
- foreign namespace 전송 전 거절
- Cluster cross-slot 전송 전 거절
- standalone의 slot 불일치 허용
- request budget 초과 거절
- blocking server block 상한과 2초 margin
- server version 미달 거절
permit provenance는 authority가 발급한 permit 허용, caller 구현체 거절, 다른 policy permit 거절, 다른 authority permit 거절로 각각 고정됩니다.
이 테스트들은 이번 문서 작업에서 실행하지 않았습니다. source를 정적으로 조사했습니다. 공유 검증 기록에 따르면 기본 module test는 이전 root 세션에서 성공했지만, 이것을 이번 실행 결과로 표현하지 않습니다.
현재 구현 공백과 잘못 읽기 쉬운 지점
- 314개 policy와 guard 구현은 존재하지만 production bean 조립은 확인되지 않습니다.
- aggregate facade와 executor까지 조립되지 않았으므로 “애플리케이션의 모든 Redis 명령이 현재 이 guard를 지난다”고 단정할 수 없습니다.
- admission guard는 request 크기와 예상 reply 크기만 budget과 비교합니다. typed decoder의 actual-size 집행은 일부 경로에만 있습니다. 기본 GET, script, function, raw, admin에는 그 호출이 없고, extension은 policy name이 있을 때만 budget을 갖지만 어느 분기도 관측 reply를 검사하지 않습니다. batch는 decoded shape를 별도로 근사 측정하므로 exact wire-byte 집행이 아닙니다.
- permit은 Redis ACL을 넓히지 않습니다. process 내부 provenance 증명이며 실제 보안 경계는 계정 ACL입니다.
WAIT는 policy에 없으므로 현재 default-deny입니다.- server metadata drift gate용 코드와 테스트가 있어도 이 조사에서는 real-server metadata 비교를 실행하지 않았습니다.
다음에 source를 열 때는 policy YAML, loader, catalog, guard, CommandRequest, 각 executor 순으로 보면 됩니다.
시리즈의 관련 문서
관련 범위는 keyspace·expiration, typed operations, advanced surfaces, execution failure certainty입니다.
시리즈에서 이어 읽기
- 전체 흐름: 「Redis를 범용 클라이언트가 아니라 정책 경계로 다루기」
- 운영 흐름: 「Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지」
Raw key와 영구 쓰기를 막는 코드: Namespace·Hash Slot·TTL
이 글이 답하는 코드 질문
호출자가 Redis key 문자열을 직접 만들지 못하게 하는 경계는 어디이며, expiry 없는 쓰기는 어떤 코드에서 거절됩니까?
현행 구현의 답은 둘로 나뉩니다.
- typed API는
QualifiedRedisKey만 받아 namespace, key grammar, UTF-8 byte 상한, Cluster slot을 검사합니다. - ordinary value
SET계열·nontransactional increment와PERSIST는 expiry 또는PersistentKeyPermit을 검증하지만, 모든 value·transaction·collection write가 이 경계를 지나지는 않습니다.
따라서 “raw key를 typed API에서 막는다”는 주장은 source로 확인되지만, “모든 영구 쓰기를 막는다”는 주장은 현재 구현 전체에는 맞지 않습니다.
먼저 보는 클래스·리소스 지도
| 클래스 | 입력 | 출력 | 다음 호출 |
|---|---|---|---|
| RedisNamespace | environment, service, domain | namespace prefix | QualifiedRedisKey |
| QualifiedRedisKey | namespace, name, optional slot tag | 논리 key | renderer·guard |
| RedisKeyRenderer | qualified key | wire key 또는 slot source | gateway·slot calculator |
| RedisKeyRules | key part, rendered key | 검증된 문자열 | key value object |
| Expiration | permit, duration, instant | persistent/relative/absolute expiry | value request builder |
| RedisOperationContext | namespace, renderer, verifier, authority, limits | render·encode·permit helper | operation request builder |
| KeyOperationRequests | key와 expiry 변경 요청 | guarded CommandRequest |
executor |
Key는 문자열이 아니라 구조입니다
RedisNamespace는 세 token을 가집니다.
environment : service : domain
prefix는 prod:order:shared 같은 prefix를 만듭니다. 각 token은 lower-case alphanumeric과 -만 허용하며 길이는 1..64자입니다.
QualifiedRedisKey는 다음을 묶습니다.
RedisNamespace- entity와 identifier를 가진
RedisKeyName - 선택적인
RedisSlotTag
typed operation signature에는 이미 render된 String key가 없습니다. QualifiedRedisKey의 경계는 namespace와 slot 검사를 건너뛸 public typed path를 만들지 않습니다.
Renderer가 고정하는 wire 형식
RedisKeyRenderer.render는 두 형식만 만듭니다.
plain: environment:service:domain:entity:identifier
tagged: environment:service:domain:{slotTag}:entity:identifier
brace는 caller가 넣지 않고 renderer만 넣습니다. RedisSlotTag 자체는 RedisKeyRules.requireIdentifier을 통과해야 하므로 nested brace나 separator를 넣을 수 없습니다.
slotSource는 tagged key에서 tag value만 반환하고, plain key에서는 전체 rendered key를 반환합니다. 이 값이 Redis Cluster CRC16 계산 입력입니다.
Key rule이 잡는 것과 잡지 못하는 것
RedisKeyRules은 rendered key의 hard maximum을 512 UTF-8 bytes로 둡니다. 실제 renderer는 deployment가 설정한 maxKeyBytes가 1..512 범위인지 먼저 검사합니다.
identifier는 다음 조건을 만족해야 합니다.
- 1..128자
- 첫 글자는 alphanumeric
- 나머지는
[A-Za-z0-9._~-] :separator 금지- 인식 가능한 mail address, JWT, international phone,
bearer/eyjprefix 금지
이 검사는 구조적으로 알아볼 수 있는 민감 정보만 거절합니다. 42 같은 bare digit나 이미 fingerprint된 surrogate id는 개인 정보인지 기계적으로 판별할 수 없으므로 허용합니다. caller가 원본 식별자를 fingerprint해야 하는 책임은 남습니다.
Request-time key 검증 순서
sequenceDiagram
participant A as Application
participant T as Typed operation
participant C as RedisOperationContext
participant G as CommandPolicyGuard
participant S as Slot calculator
participant L as Lettuce gateway
A->>T: ValueKey/HashKey/... 전달
T->>C: renderKey(QualifiedRedisKey)
C-->>T: UTF-8 wire bytes
T->>G: CommandRequest(keys, deferred invocation)
G->>G: bound namespace 비교
G->>S: slotSource 계산
S-->>G: slot
G-->>T: admission
T->>L: deferred command 실행
operation request builder가 먼저 render하더라도 guard는 requireNamespace에서 각 key의 namespace를 process-bound namespace와 다시 비교하고 render합니다.
여러 key가 하나의 slot에 있어야 하는지는 topology에 따라 다릅니다. requireSameSlot은 Cluster에서만 여러 slot을 RedisCrossSlotException으로 거절합니다. standalone과 Sentinel은 여러 slot 개념으로 요청을 막지 않습니다.
Expiration은 세 상태를 표현합니다
Expiration은 sealed interface입니다.
| variant | 뜻 | constructor 검사 |
|---|---|---|
Expiration.Persistent |
expiry 없음 | non-null permit 필수 |
Expiration.After |
상대 TTL | positive Duration 필수 |
Expiration.At |
절대 expiry | non-null Instant 필수 |
중요한 점은 Persistent에 아무 marker permit이나 넣는다고 끝나지 않는다는 것입니다. requireExpirationPermit이 Persistent를 발견하면 persistent-key policy에 대해 verifier를 호출합니다.
이 검사는 guard가 아니라 operation context에 있습니다. SET과 PERSIST는 catalog에서 R1이므로 guard의 R2 permit 검사에 걸리지 않습니다. 그 이유를 적은 코드가 별도 경계를 둔 이유를 설명합니다.
Value write의 호출 순서
LettuceRedisValueOperations.set은 ValueOperationRequests.set으로 위임합니다.
- key, value, expiration이 null인지 검사합니다.
Expiration.Persistent이면 permit provenance를 검증합니다.- key를 render합니다.
- codec으로 value를 encode하고 byte ceiling을 검사합니다.
SETCommandRequest를 만듭니다.- invocation에는
gateway.set(..., expiration)을 지연 저장합니다. - executor가 guard admission 후 invocation을 실행합니다.
setIfAbsent, setIfPresent, getAndSet, getAndExpire도 같은 expiration 경계를 사용합니다. nontransactional integer/double increment는 persistent면 INCRBY/INCRBYFLOAT, expiring이면 TTL을 함께 다루는 등록 script로 분기합니다. increment 분기를 보면 expiry를 increment 뒤 별도 명령으로 붙이는 race를 피합니다.
이 설명은 value API의 모든 write로 넓힐 수 없습니다. APPEND request와 SETRANGE request는 expiration이나 persistent permit을 받지 않습니다. 두 Redis 명령은 absent key를 새 string으로 만들 수 있으므로 TTL 없는 key가 생길 수 있습니다.
transaction queue도 별도 경계입니다. transaction의 set은 expiration을 받지만 queued increment은 plain INCRBY만 enqueue합니다. 이어지는 hash/list/set/zset write도 expiry나 permit 없이 absent key를 만들 수 있습니다. nontransactional increment가 expiry-aware script로 분기한다는 계약을 transaction increment에 적용하면 안 됩니다.
Expiry 변경 API의 정상·실패 분기
ExpirationCondition은 ALWAYS, IF_NO_EXPIRY, IF_HAS_EXPIRY, IF_GREATER, IF_LESS를 노출합니다.
ExpirationResult은 결과를 APPLIED, CONDITION_NOT_MET, ABSENT, DELETED로 구분합니다.
상대 TTL
KeyOperationRequests.expire은 0 또는 음수 TTL을 전송하지 않습니다. Redis가 즉시 삭제하도록 맡기는 대신 “삭제는 명시적으로 호출하라”고 SDK에서 거절합니다.
절대 expiry
expireAt은 현재 시각보다 과거인지 request builder에서 계산하고, server가 적용했다고 답하면 DELETED로 매핑합니다. 이 비교는 Instant.now 사용 지점에 있으며 injected Clock을 쓰지 않습니다.
영구 전환
persist는 permit 검증 후 PERSIST을 만듭니다. 위조 permit이면 server에 가지 않습니다.
Raw gateway에서도 key 검사가 사라지지 않습니다
raw surface는 아무 byte sequence나 통과시키는 우회로가 아닙니다. LettuceRedisRawGateway는 policy KeySpec으로 key argument 위치를 찾고 RedisOperationContext.parseKey로 다시 qualified key를 만듭니다.
parseKey는 bound namespace prefix가 아니거나 key grammar가 틀리면 거절합니다. movable key 위치를 결정할 수 없는 shape도 best guess하지 않습니다.
테스트가 고정하는 계약
renderer 테스트는 slot tag의 brace 위치, plain key 형식, tagged key의 공통 slot source, configured byte ceiling 초과 거절, 1..512 밖의 maximum 거절을 각각 고정합니다.
key rule 테스트는 mail, JWT/auth material, international phone, separator injection, malformed namespace를 거절하고 ordinary surrogate identifier는 허용한다고 고정합니다.
guard 쪽에서는 foreign namespace가 전송되지 않는 사례, Cluster cross-slot의 client-side 거절, standalone의 slot 불일치 허용을 서로 다른 테스트가 고정합니다.
RedisRawGatewayContractTest의 namespace 사례는 raw key도 parse-back과 namespace 검사를 통과해야 한다고 고정합니다.
이 테스트는 이번 문서 작업에서 실행하지 않았고 정적으로 읽었습니다.
현재 구현 공백과 잘못 읽기 쉬운 지점
ExpirationJavadoc은 “every write”를 말하지만 TTL 의무는 전체 typed write에 완결되지 않았습니다. APPEND, SETRANGE, transaction INCRBY, transaction의 collection write뿐 아니라 RedisHashOperations.put과 RedisListOperations.pushLeft도 expiration이나 persistent permit을 받지 않습니다.Expiration.Atconstructor는 과거 시각을 거절하지 않습니다.expireAt결과가DELETED일 수 있습니다.- raw gateway는 approved command만 받지만, production raw approvals와 gateway bean 조립은 확인되지 않습니다.
- aggregate
RedisOperationsproduction bean도 확인되지 않으므로 typed key 경계가 실제 application entry point로 조립됐다고 단정할 수 없습니다. - key rule은 인식 가능한 민감 정보만 잡습니다. caller-side pseudonymization 책임이 남습니다.
다음에 source를 열 때는 RedisNamespace, QualifiedRedisKey, renderer, rules, RedisOperationContext, value/key request builder 순으로 보면 됩니다.
시리즈의 관련 문서
관련 범위는 command admission, codec schema, typed operations, raw surface입니다.
시리즈에서 이어 읽기
- 전체 흐름: 「Redis를 범용 클라이언트가 아니라 정책 경계로 다루기」
- 운영 흐름: 「Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지」
Redis 값의 스키마를 코드로 고정하기: Registry·Envelope·Version
이 글이 답하는 코드 질문
Redis에 저장한 object byte가 어느 schema와 version인지 어떻게 판별하며, 배포가 읽지 못하는 값은 cache miss가 아니라 어떤 실패가 됩니까?
코드는 payload와 framing의 책임을 나눕니다.
RedisPayloadCodec<T>는 schema id, write version, readable versions, payload encode/decode를 소유합니다.VersionedJsonCodec<T>는 timestamp가 포함된 envelope와 byte ceiling을 소유합니다.RedisCodecRegistry는 deployment가 승인한 schema와 Java type의 닫힌 집합을 소유합니다.
다른 schema, 읽을 수 없는 version, 깨진 framing은 RedisSerializationException입니다. ordinary miss로 바꾸지 않습니다.
먼저 보는 클래스·리소스 지도
| 클래스·리소스 | 입력 | 출력 | 다음 호출 |
|---|---|---|---|
| RedisPayloadCodec | domain object 또는 payload bytes/version | payload bytes 또는 object | VersionedJsonCodec |
| RedisEnvelope | schema, version, createdAt, payload | immutable envelope | framing |
| JsonEnvelopeFraming | envelope 또는 stored bytes | canonical JSON bytes 또는 envelope | VersionedJsonCodec |
| VersionedJsonCodec | typed value/stored bytes | versioned bytes/typed value | typed operation |
| RedisCodecRegistry | payload codec와 value type | schema별 RedisCodec<V> |
typed key factory |
| golden order-summary-v1 | 고정 timestamp와 payload | byte compatibility 기준 | codec contract test |
객체 생성 시점: registry를 닫습니다
RedisCodecRegistry.builder는 세 값을 받습니다.
maxValueBytes- envelope에 기록할
Clock - decode failure metadata에 기록할
RedisDeploymentMode
builder의 register는 RedisPayloadCodec<V>와 Class<V>를 함께 받습니다. 내부에서 VersionedJsonCodec을 만들고 schema id를 key로 저장합니다.
동일 schema를 두 번 등록하면 실패합니다. class name을 보고 codec을 반사적으로 만들거나 stored bytes의 schema를 보고 미등록 decoder를 동적으로 로드하는 path는 없습니다.
registry에는 object envelope 외에도 네 built-in codec이 있습니다.
- UTF-8 string
- native long counter
- native double counter
- opaque byte array
이 built-in codec은 forSchema map과 별도로 singleton을 반환합니다.
생성자 단계의 불변식
VersionedJsonCodec 생성자는 다음을 확인합니다.
- payload codec, clock, deployment mode가 null이 아닙니다.
- maximum encoded bytes가 양수입니다.
- payload codec이 자신이 쓰는
writeVersion()을 읽을 수 있습니다.
세 번째 규칙은 배포가 쓴 직후 자기 값을 못 읽는 설정을 시작 전에 막습니다. constructor 검사에 있습니다.
RedisEnvelope도 schema가 1..128자의 제한된 alphabet인지, version이 양수인지 검사합니다. schema pattern은 letter, digit, ., _, -만 허용합니다. quote나 control character가 framing 구조를 바꾸지 못하게 합니다.
payload byte array는 constructor와 accessor에서 defensive copy됩니다. array를 record component로 두지 않고 value equality를 직접 구현했습니다.
Encode 호출 순서
sequenceDiagram
participant O as Typed operation
participant V as VersionedJsonCodec
participant P as RedisPayloadCodec
participant F as JsonEnvelopeFraming
participant R as Redis
O->>V: encode(value)
V->>P: encodePayload(value)
P-->>V: payload bytes
V->>V: schema·writeVersion·clock.instant로 envelope 생성
V->>F: write(envelope)
F-->>V: canonical UTF-8 JSON
V->>V: encoded byte ceiling 검사
V-->>O: bytes
O->>R: admission 후 write
encode는 Redis 호출 전 byte 길이를 검사합니다. 초과하면 RedisSerializationException이며 bytes는 server로 가지 않습니다.
codec id는 json:<schema>:v<writeVersion>입니다. 예를 들면 json:order-summary:v1입니다.
Canonical envelope bytes
JsonEnvelopeFraming.write는 field를 다음 순서로 씁니다.
{"schema":"order-summary","version":1,"createdAt":"2026-08-07T00:00:00Z","payload":"<base64>"}
payload는 Base64입니다. JSON serializer 설정이나 reflection에 byte 결과가 좌우되지 않습니다. 같은 envelope를 주면 writer는 같은 UTF-8 byte를 만듭니다. timestamp가 envelope의 일부이므로 실제 encode 호출의 clock instant가 다르면 전체 byte도 달라집니다.
golden-byte test는 fixed clock을 사용해 이 변수를 고정합니다.
Decode 호출 순서
flowchart TD
A[stored bytes] --> B{byte ceiling 이내인가}
B -- 아니요 --> X[RedisSerializationException]
B -- 예 --> C[framing read]
C --> D{exact four field set이고 값 변환이 가능한가}
D -- 아니요 --> X
D -- 예 --> E{schema가 codec schema와 같은가}
E -- 아니요 --> X
E -- 예 --> F{payloadCodec.canRead version인가}
F -- 아니요 --> X
F -- 예 --> G[decodePayload payload, version]
decode는 먼저 stored byte 길이를 검사합니다. 그다음 framing을 읽고 schema와 readable version을 확인합니다. 마지막에만 payload decoder를 호출합니다.
이 순서는 잘못된 schema의 payload를 우연히 같은 Java shape로 decode하는 것을 막습니다. future version도 caller가 canRead에서 명시하지 않으면 hard failure입니다.
Framing parser가 실제로 검사하는 범위
JsonEnvelopeFraming.read는 범용 JSON parser가 아니라 hand-written framing parser입니다. 다음 입력은 거절합니다.
- null 또는 empty bytes
- JSON object brace가 없는 문자열
- 네 field 중 일부가 없거나 extra field가 있는 object
- 중복 field
- integer가 아닌 version
Instant로 읽히지 않는 timestamp- Base64가 아닌 payload
- envelope constructor 규칙을 어긴 schema/version
- field name의 quote, colon, escape 구조가 parser 문법과 맞지 않는 framing
그러나 이 목록을 strict 또는 canonical JSON validation으로 읽으면 안 됩니다. fields parser는 quoted value면 quote를 벗기고, 아니면 다음 comma까지의 text를 그대로 가져옵니다. 이후 version은 Integer.parseInt, createdAt은 Instant.parse, payload는 Base64 decode가 성공하는지만 봅니다. 그래서 "version":"1"처럼 JSON type이 writer와 달라도 통과하며 schema·timestamp·payload의 unquoted text도 변환 가능하면 통과할 수 있습니다. 마지막 field 뒤 trailing comma도 현재 loop가 허용합니다.
source comment의 “reordered fields를 거절한다”는 설명도 실제 코드와 일치하지 않습니다. parser는 LinkedHashMap에 읽지만 key set equality만 비교합니다. 같은 네 field를 재배열한 object는 통과합니다. writer가 canonical bytes를 만든다는 사실, reader가 exact field set과 변환 가능성을 확인한다는 사실, reader가 canonical JSON까지 강제한다는 주장은 서로 다릅니다.
Schema evolution을 적용하는 순서
RedisPayloadCodec은 stored version을 decodePayload에 넘깁니다. 따라서 호환 변경은 다음 배포 순서를 취할 수 있습니다.
- reader가 old version과 next version을 모두
canRead하도록 배포합니다. - 실제 decode가 version별 payload를 처리하도록 합니다.
writeVersion을 next version으로 올린 writer를 배포합니다.- old data의 TTL·migration 조건을 확인한 뒤 old reader 제거를 검토합니다.
이 순서는 API가 허용하는 패턴이지 자동 migration 구현이 있다는 뜻은 아닙니다. registry나 codec에는 stored data backfill, read-repair, dual-write, version usage metric이 없습니다.
정상·실패 분기와 failure metadata
정상
- registered schema와 요청한 Java type이 일치합니다.
- envelope schema가 payload codec schema와 같습니다.
- stored version을
canRead가 허용합니다. - framing과 payload decode가 성공합니다.
lookup 실패
forSchema는 미등록 schema와 잘못된 requested Class<V>를 IllegalArgumentException으로 거절합니다. generic cast가 나중의 ClassCastException으로 밀리지 않습니다.
등록 단계도 같은 schema id의 두 구현을 허용하지 않습니다. putIfAbsent 검사는 두 번째 등록이 같은 payload codec인지 비교해 합치지 않고 즉시 실패합니다. 따라서 schema id 하나가 배포 안에서 어느 decoder를 뜻하는지 모호해지지 않습니다. 다만 서로 다른 배포가 같은 schema id를 다른 의미로 등록하는 문제까지 중앙에서 탐지하는 registry는 아닙니다. 그 호환성은 golden byte와 교차 version test로 관리해야 합니다.
serialization 실패
다른 schema, unreadable version, oversized bytes, framing 오류는 모두 RedisSerializationException입니다. 다만 metadata의 deployment mode 경로는 같지 않습니다. VersionedJsonCodec이 직접 만드는 size/schema/version failure는 failure factory를 거쳐 bound deployment mode를 넣습니다.
반면 decode의 framing 호출은 JsonEnvelopeFraming.read가 던진 failure를 다시 감싸지 않습니다. framing 쪽 serializationFailure는 deployment mode를 STANDALONE으로 고정합니다. Cluster에 bound된 codec이라도 malformed framing이면 metadata가 현재 STANDALONE을 보고합니다.
stored data corruption은 retryable도 ambiguous도 아닙니다. 같은 byte를 다시 decode해도 성공할 근거가 없으므로 read라는 이유만으로 retryable로 표시하지 않습니다.
테스트가 고정하는 계약
registry 테스트는 declared type lookup, wrong type의 lookup-time 거절, unregistered schema 거절을 각각 고정합니다.
versioned codec 테스트도 계약별 시작 행이 다릅니다.
- v1 golden payload read
- fixed clock writer와 golden byte 일치
- 다른 schema 거절
- future version의 silent decode 방지
- empty·non-JSON·missing field·bad version·extra field 거절
- encode size 선검사
- stable codec id
- foreign-schema failure의 non-retryable·non-ambiguous·bound Cluster metadata
- schema id의 control character와 quote 거절
- framing failure의 non-retryable 속성
reordered field, 잘못된 JSON value type, trailing comma 거절 test는 없습니다. framing failure 테스트는 retryable만 검사하고 deployment mode는 검사하지 않습니다. 이번 문서 작업에서 테스트를 실행하지 않았고 production source와 test를 정적으로 대조했습니다.
Golden byte가 의미하는 범위
golden file은 envelope framing, field spelling/order, timestamp rendering, Base64 payload를 한 사례로 고정합니다. payload codec의 모든 version 호환성을 자동으로 증명하지는 않습니다.
payload representation을 바꾸려면 새 golden fixture와 old-version read test가 필요합니다. 기존 golden file을 새 writer output으로 덮어쓰는 것만으로는 backward compatibility를 증명할 수 없습니다.
현재 구현 공백과 잘못 읽기 쉬운 지점
RedisCodecRegistry, payload codec 등록, typed key 조립의 production bean은 확인되지 않습니다.- aggregate
RedisOperationsproduction facade도 확인되지 않으므로 registry가 application path에 실제 연결됐다고 단정할 수 없습니다. - framing writer는 field order와 JSON value type을 고정하지만 reader는 reordered field, 변환 가능한 잘못된 JSON value type, trailing comma를 허용합니다. strict/canonical JSON parser가 아니며 class comment와 구현도 drift했습니다.
- framing parser failure metadata는 bound deployment mode 대신
STANDALONE을 hard-code합니다. 현재 테스트는 corrupt framing의 topology를 고정하지 않습니다. - 자동 migration, read-repair, dual-write, stored version inventory는 없습니다.
createdAt은 compatibility framing의 일부지만 expiry나 freshness를 자동 판단하지 않습니다.- built-in string/number/bytes codec은 versioned object envelope와 다른 wire format입니다.
다음에 source를 열 때는 RedisPayloadCodec, RedisEnvelope, framing, VersionedJsonCodec, registry, golden test 순으로 보면 됩니다.
시리즈의 관련 문서
관련 범위는 keyspace, typed operations, execution failure certainty입니다.
시리즈에서 이어 읽기
- 전체 흐름: 「Redis를 범용 클라이언트가 아니라 정책 경계로 다루기」
- 운영 흐름: 「Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지」
문자열 명령 대신 타입을 노출하는 RedisOperations 코드 지도
이 글이 답하는 코드 질문
GET, HSET, ZRANGE 같은 command string 대신 애플리케이션이 무엇을 호출하며, sync와 reactive API가 같은 정책을 적용한다는 근거는 어디에 있습니까?
public aggregate interface는 RedisOperations와 ReactiveRedisOperations입니다. 둘 다 12개 accessor를 노출합니다. 각 operation은 typed key와 value codec을 받고, 공통 request builder가 CommandRequest를 만든 뒤 sync 또는 reactive executor로 보냅니다.
다만 이 aggregate interface를 구현한 production class와 Spring bean은 확인되지 않습니다. 세부 operation 구현과 contract 테스트가 존재한다는 사실과 application이 aggregate facade를 주입받을 수 있다는 사실을 구분해야 합니다.
먼저 보는 클래스·리소스 지도
| 클래스 | 입력 | 출력 | 다음 호출 |
|---|---|---|---|
| RedisOperations | 없음, accessor 호출 | sync operation group | 각 LettuceRedis*Operations |
| ReactiveRedisOperations | 없음, accessor 호출 | reactive operation group | 각 LettuceReactiveRedis*Operations |
| typed key interfaces | QualifiedRedisKey와 codec |
ValueKey, HashKey 등 |
request builder |
| operation interface | typed key, value, option, permit, budget | domain-shaped result | Lettuce implementation |
| package-private request builder | operation arguments | CommandRequest<R> |
executor |
| sync executor | deferred request | value/collection | gateway |
| reactive executor | deferred request | Mono/Flux |
gateway |
대표 호출을 볼 때는 LettuceRedisValueOperations과 ValueOperationRequests을 함께 읽으면 구조가 드러납니다.
Aggregate가 노출하는 12개 그룹
sync와 reactive aggregate accessor는 다음과 같습니다.
| accessor | sync surface | 대표 자료형·명령군 |
|---|---|---|
values() |
RedisValueOperations |
value/string, GET·SET·counter |
hashes() |
RedisHashOperations |
hash field/value |
lists() |
RedisListOperations |
ordered list |
sets() |
RedisSetOperations |
unordered set·algebra |
sortedSets() |
RedisSortedSetOperations |
score/rank/range |
bitmaps() |
RedisBitmapOperations |
bit offset·BITOP |
bitFields() |
RedisBitFieldOperations |
typed bitfield subcommand |
hyperLogLogs() |
RedisHyperLogLogOperations |
PFADD·PFCOUNT·PFMERGE |
geo() |
RedisGeoOperations |
point·distance·bounded search |
streams() |
RedisStreamOperations |
append·range·group·pending |
keys() |
RedisKeyOperations |
exists·delete·expiry·scan·rename |
batches() |
RedisBatchOperations |
bounded pipelined batch |
aggregate accessor 선언은 blocking list/stream, transaction, Pub/Sub, admin, raw, script/function을 포함하지 않습니다. 이 surface들은 connection ownership이나 ACL이 달라 별도 API로 남습니다.
Key type이 data structure와 codec을 고정합니다
operation은 String key와 byte[] value를 받지 않습니다. 예를 들어 ValueKey<V>는 qualified key와 RedisCodec<V>를 묶고, HashKey<F,V>는 field codec과 value codec을 함께 가집니다.
이 형태가 고정하는 계약은 다음과 같습니다.
- namespace 없는 raw key가 typed operation signature에 들어오지 않습니다.
- 같은 key를 hash API와 list API에 우연히 넘길 수 없습니다.
- encode/decode codec이 call마다 따로 선택되지 않습니다.
Optional<V>,ExpirationResult,ScanPage<T>같은 결과가 Redis reply sentinel을 감춥니다.
server에 이미 다른 data type으로 저장된 key라면 compile-time type만으로 막을 수 없습니다. 이 경우 driver의 WRONGTYPE을 exception translator가 RedisDataTypeMismatchException으로 바꿉니다.
Sync value read의 호출 순서
sequenceDiagram
participant A as Application
participant V as LettuceRedisValueOperations
participant B as ValueOperationRequests
participant C as RedisOperationContext
participant E as SyncRedisCommandExecutor
participant G as RedisCommandGateway
A->>V: get(ValueKey<V>)
V->>B: get(key)
B->>C: renderKey(key.key)
B->>B: GET CommandRequest 구성
V->>E: execute(request)
E->>E: guard.validate
E->>G: deferred get(bytes)
G-->>B: stored bytes/null
B->>C: value codec으로 decode
C-->>A: Optional<V>
ValueOperationRequests.get은 key를 render하고 GET command id, request byte 수, deferred gateway call을 한 객체에 넣습니다. reply가 오면 key에 묶인 codec으로 decode합니다.
이 기본 GET에는 OperationBudget이 없고 expectedReplyBytes도 0입니다. 공통 decode 함수는 codec만 호출하므로 관측한 reply byte ceiling을 집행하지 않습니다. MGET의 decodeAll이 budget을 검사하는 것과 다른 경로입니다.
LettuceRedisValueOperations.get은 request builder와 executor를 연결할 뿐 command 정책을 다시 구현하지 않습니다.
Reactive path가 공유하는 부분과 다른 부분
reactive value implementation도 같은 ValueOperationRequests를 사용합니다. 따라서 command 선택, key rendering, permit, budget, encoding 분기가 sync와 reactive에서 따로 복제되지 않습니다.
다른 것은 executor와 반환 shape입니다.
- sync는
CompletionStage를 deadline까지 기다리고 값을 반환합니다. - reactive는
Mono.defer안에서 admission을 실행하고Mono.fromCompletionStage로CompletionStage를Mono로 변환합니다. Optional<T>sync 결과는 reactive에서 emptyMono<T>가 됩니다.List<T>/Set<T>sync 결과는Flux<T>가 됩니다.- primitive는 boxed
Mono가 됩니다.
ApiParityInspector의 규칙은 method name과 generic parameter를 비교하고 예상 reactive return shape를 계산합니다.
Pub/Sub은 mechanical parity 대상에서 의도적으로 빠집니다. sync는 handler와 closeable subscription을 반환하고 reactive는 publisher cancellation을 lifecycle로 사용하기 때문입니다.
Group별로 봐야 하는 정책 지점
Value
RedisValueOperations은 ordinary SET 계열을 expiration이 필수인 public method로 표현합니다. setIfAbsent와 setIfPresent는 각각 SET NX와 SET XX, getAndSet은 SET GET, getAndExpire는 GETEX 옵션으로 내려갑니다. deprecated command 이름인 SETNX와 GETSET 자체는 policy에서 BLOCKED이며 이 API가 전송하지 않습니다.
multi-get과 range/append는 permit·budget을 요구합니다. 다만 APPEND와 SETRANGE의 method에는 expiration이나 PersistentKeyPermit이 없습니다. append request와 setRange request는 absent key를 만들 수 있는데도 TTL 경계를 호출하지 않습니다.
Hash
RedisHashOperations은 field와 value codec을 분리합니다. full collection read나 scan은 bound를 가진 API로 표현됩니다. hash write에는 expiration 인자가 없다는 현재 공백이 있습니다.
List
RedisListOperations은 side를 enum으로 표현하고 count/range를 bound합니다. blocking pop/move는 aggregate 밖의 blocking surface입니다.
Set과 sorted set
set algebra의 multi-key 비용은 permit과 budget으로 드러납니다. sorted set은 ScoreRange, RankRange, LexRange, page/bound 자료형으로 overload ambiguity를 줄입니다.
Bitmap과 bitfield
bitmap은 bit offset과 multi-key bit operation을 구분합니다. bitfield는 raw subcommand string 대신 BitFieldSubcommand, overflow enum, typed result를 사용합니다.
HLL과 Geo
HyperLogLog merge는 multi-key permit 대상입니다. Geo search는 center/radius/unit/page를 자료형으로 묶고 reply 수를 제한합니다.
Stream
stream은 StreamId, StreamRange, StreamReadOffset, StreamGroup, StreamConsumer, pending/claim result를 사용합니다. blocking read와 version-gated deletion은 기본 aggregate와 분리됩니다.
Key
key group은 expiry, TTL, scan, delete/unlink, rename을 담당합니다. scan은 전 keyspace materialization 대신 cursor page를 반환합니다.
Batch
batch는 aggregate에 있지만 atomic transaction이 아닙니다. per-command outcome과 partial failure를 반환하는 latency optimization입니다.
Request builder가 공유하는 guardrail
각 family의 package-private *OperationRequests는 다음 일을 맡습니다.
- null과 local option을 검사합니다.
- key를 render합니다.
- value/member/field를 codec으로 encode합니다.
- request byte와 expected reply byte를 계산합니다.
- 필요한 permit과
OperationBudget을 붙입니다. - gateway call을 supplier로 지연합니다.
- reply를 typed result로 decode합니다. 관측 reply budget 검사는 builder가
requireReplyWithinBudget을 호출한 MGET, bounded range, collection page 등 일부 경로에만 있습니다.
예를 들어 multiGet은 empty key list를 거절하고, 모든 rendered key byte를 합산하고, collection budget과 multi-key permit을 MGET request에 넣습니다.
정상·실패 분기
정상
- absent GET/hash field/list pop은
Optional.empty등 typed absence로 돌아옵니다. - conditional write는 boolean 또는 typed outcome으로 조건 불충족을 표현합니다.
- cursor operation은 elements와 next cursor/complete state를 반환합니다.
- sync와 reactive는 같은 request builder를 거쳐 같은 command·permit·budget을 적용합니다.
전송 전 거절
- malformed key와 foreign namespace
- forged/missing permit
- empty 또는 configured maximum을 넘긴 collection
- request/reply estimate가 budget을 넘긴 경우
- Cluster cross-slot
- server version에 없는 version-gated command
- codec encode size 초과
server reply 실패
WRONGTYPE:RedisDataTypeMismatchException- ACL 오류:
RedisAccessDeniedException - redirection/partition:
RedisRedirectionException - busy/loading: typed busy failure
- timeout/connection loss: read/write와 ambiguity에 따라 분기
decode 실패
schema, version, framing이 맞지 않으면 cache miss로 바뀌지 않고 RedisSerializationException입니다.
테스트가 고정하는 계약
PAIRS 선언은 15개 sync/reactive surface pair를 열거합니다. 기본 12개 외에 blocking list, blocking stream, hash field expiration도 pair 대상이며, 전체 pair parity 테스트가 각 pair의 method shape를 비교합니다.
aggregate accessor 테스트는 accessor가 정확히 batches, bitFields, bitmaps, geo, hashes, hyperLogLogs, keys, lists, sets, sortedSets, streams, values인지 고정합니다. publisher 반환 테스트는 모든 reactive method의 return type을 별도로 검사합니다.
family별 contract 테스트도 있습니다.
- RedisValueOperationsContractTest
- RedisHashOperationsContractTest
- RedisListOperationsContractTest
- RedisSetOperationsContractTest
- RedisSortedSetOperationsContractTest
- RedisBitmapGeoOperationsContractTest
- RedisStreamOperationsContractTest
- RedisKeyOperationsContractTest
이 테스트는 in-memory gateway와 contract fixture를 많이 사용합니다. 일부 live test가 별도 존재하지만 이번 문서 작업에서는 어떤 테스트도 실행하지 않았습니다.
WAIT와 typed surface
RedisOperations와 세부 typed interface에는 WAIT method가 없습니다. command policy에도 WAIT가 없습니다. durability 문맥에서 WAIT를 언급한 기존 문서를 typed API 지원 증거로 읽으면 안 됩니다. 현재는 catalog default-deny입니다.
현재 구현 공백과 잘못 읽기 쉬운 지점
RedisOperations와ReactiveRedisOperations구현 class를 production source에서 찾지 못했습니다.- 두 aggregate type의 Spring bean도 확인되지 않습니다.
- guard, executor, translator의 production DI가 확인되지 않으므로 세부 Lettuce operation을 application에 연결하는 bridge가 미조립입니다.
- tests의
RedisOperationsFixture는 production composition 증거가 아닙니다. - sync/reactive parity는 signature와 return shape를 고정하지만 실서버에서 두 path의 모든 동작이 같다는 증명은 아닙니다.
- expiration 의무는 ordinary value
SET계열과 nontransactional increment에는 적용되지만 모든 write에 완결되지 않았습니다. APPEND, SETRANGE, transaction의 INCRBY, transaction collection write, hash/list/set/zset write는 absent key를 만들 수 있어도 expiration이나 persistent permit을 받지 않습니다. - budget 객체와 관측 reply ceiling은 같은 뜻이 아닙니다. 기본 GET과 advanced script/function/raw/admin/extension path에는 관측 reply 크기를 검사하는 호출이 없습니다.
- version-gated extension은 aggregate accessor에 자동으로 들어오지 않습니다.
다음에 source를 열 때는 aggregate interface, 한 family interface, sync/reactive implementation, 공통 request builder, context, executor, family contract test 순으로 보면 됩니다.
시리즈의 관련 문서
관련 범위는 command admission, keyspace·expiration, codec, advanced surfaces, execution failure certainty입니다.
시리즈에서 이어 읽기
- 전체 흐름: 「Redis를 범용 클라이언트가 아니라 정책 경계로 다루기」
- 운영 흐름: 「Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지」
Batch·Transaction·Script·Function·Pub/Sub·Admin·Raw를 분리한 이유
이 글이 답하는 코드 질문
왜 advanced 기능을 RedisOperations 하나에 모두 넣지 않았으며, 각 surface는 어떤 connection·ACL·배포 계약을 가집니까?
이 분리는 기능 이름보다 failure mode와 ownership 차이에서 나옵니다.
- batch는 pipeline 최적화이며 atomic하지 않습니다.
- transaction은 한 connection의
WATCH/MULTI/EXEC상태를 독점합니다. - script는 process에 등록한 source를 first use에
SCRIPT LOAD하고NOSCRIPT에서 한 번 복구합니다. - function은 application이 load하지 않고 이미 배포된 library를
FCALL합니다. - Pub/Sub subscription은 long-lived connection lifecycle입니다.
- admin은 read-only diagnostic account와 projection을 사용합니다.
- raw는 catalog와 deployment approval이 모두 허용한 command만 실행합니다.
세부 class와 테스트는 있지만 이 surface들의 production bean 조립은 확인되지 않습니다.
먼저 보는 클래스·리소스 지도
| surface | 진입점 | connection·권한 | 핵심 결과 |
|---|---|---|---|
| Batch | RedisBatchOperations | ordinary guarded calls, batch bounds | ordered per-item result |
| Transaction | RedisTransactionOperations | exclusive TRANSACTION lane |
executed/conflict, attempts |
| Script | LettuceRedisScriptOperations | scripting grant, guarded EVALSHA |
decoded script reply |
| Function | LettuceRedisFunctionOperations | capability-gated FCALL/FCALL_RO |
decoded function reply |
| Pub/Sub | LettuceRedisPubSubOperations | dedicated PUBSUB gateway |
publish count/subscription |
| Admin | LettuceRedisAdminOperations | own connection, admin-readonly account | bounded/redacted diagnostic |
| Raw | LettuceRedisRawGateway | raw account 의도, catalog+approval | caller decoder result |
| Extensions | extension package의 LettuceRedis*Operations |
probed module capability | JSON/TS/probabilistic/search |
Connection lane은 API 모양과 함께 읽습니다
RedisConnectionKind은 REGULAR, BLOCKING, TRANSACTION, SCRIPT, PUBSUB, ADMIN 여섯 lane을 정의합니다.
다음 failure mode는 한 pool에 섞기 어렵습니다.
- blocking command는 server block이 끝날 때까지 connection을 점유합니다.
- transaction은
MULTI이후 connection-local state를 가집니다. - subscribed connection은 ordinary command에 사용할 수 없습니다.
- script와 admin은 application traffic과 다른 privilege가 필요합니다.
- long-lived subscription close는 one-shot command reply와 lifecycle이 다릅니다.
다만 RedisConnectionKind.forCommand은 descriptor만으로 blocking/admin/regular을 정합니다. transaction, script, Pub/Sub의 실제 전용 connection 선택은 각 surface 조립이 맡아야 합니다. 이 조립은 production에서 확인되지 않습니다.
Batch: pipeline이지 transaction이 아닙니다
RedisBatchOperations은 세 가지를 명시합니다.
- command는 독립적으로 성공하거나 실패할 수 있습니다.
- 다른 client의 command가 사이에 실행될 수 있습니다.
- write batch를 자동 retry하지 않습니다.
LettuceRedisBatchOperations는 BatchExecution에 실행을 위임합니다. 외부에서 구현한 RedisBatch는 받지 않고 SDK builder가 만든 batch인지 확인합니다.
호출 흐름은 다음과 같습니다.
- builder가 item별
CommandRequest를 보존합니다. - batch 자체 command count와 request bytes를 선검사합니다.
- 각 item을 guard에 미리 admission하면서 declared
expectedReplyBytes를 합산하고 batch reply ceiling과 비교합니다. - 한 item이 거절되거나 declared 합계가 ceiling을 넘으면 어느 item도 보내지 않습니다.
- dispatch는 in-flight bound와 batch/item timeout 중 짧은 값을 적용합니다.
- 전송 뒤에는 item별 success/failure를 input order로 수집합니다.
- decoded reply shape의 근사 누적값이 ceiling을 넘으면 그 지점의 item을 failure로 기록할 수 있습니다.
BatchExecution.measure는 driver가 이미 decode한 결과를 셉니다. byte[]는 길이, CharSequence는 length(), collection과 map은 요소의 재귀 합계, unknown scalar는 1입니다. wire protocol의 byte 수를 계측하는 코드가 아니므로 이름이 observedReplyBytes여도 exact reply bytes로 읽으면 안 됩니다.
정상 결과에 partial failure flag가 있다는 사실은 atomicity가 없다는 API 신호입니다.
Transaction: rollback이 아니라 optimistic concurrency입니다
RedisTransactionOperations은 Redis transaction이 rollback하지 않는다고 명시합니다. EXEC 안의 한 command가 runtime error여도 다른 queued command는 실행될 수 있습니다.
LettuceRedisTransactionOperations.watchAndExecute의 흐름은 다음과 같습니다.
sequenceDiagram
participant A as Caller
participant T as TransactionOperations
participant Q as QueueingExecutor
participant R as Redis gateway
A->>T: watched keys, callback, options
T->>T: Cluster same-slot 선검사
T->>Q: WATCH request admission/issue
T->>R: MULTI
T->>A: queue callback 실행
A->>Q: typed queued commands
Q->>R: +QUEUED, reply는 아직 미확정
T->>R: EXEC
alt executed
R-->>T: replies
T->>T: QueuedReply available 표시
else watched key changed
R-->>T: null/conflict
T->>T: attempt 상한까지 재시도
end
runOnce는 callback이나 guard가 실패해도 open window를 DISCARD하고, commit 뒤 watch가 남으면 UNWATCH합니다.
Cluster에서는 watched key와 queued write key를 attempt 단위로 누적해 same-slot인지 확인합니다. command 하나씩 보면 합법이어도 transaction 전체가 cross-slot일 수 있기 때문입니다.
QueueingRedisCommandExecutor는 +QUEUED에서 성공 observation을 기록하지 않습니다. reply stage가 EXEC에서 resolve될 때 성공/실패를 기록합니다.
transaction queue의 TTL 계약도 ordinary value API와 같지 않습니다. transaction set은 expiration을 받지만 Queue.increment은 plain INCRBY를 enqueue합니다. absent key면 persistent counter가 만들어질 수 있습니다. 같은 queue의 hash/list/set/zset write도 expiration이나 persistent permit을 받지 않습니다.
Script: 등록과 server load는 같은 시점이 아닙니다
RedisScriptRegistry.register는 process 안에서 reviewed script identity와 source를 등록합니다. 같은 id에 다른 body를 재등록하면 실패합니다.
하지만 register는 Redis에 SCRIPT LOAD를 보내지 않습니다. server load는 digest이 처음 호출되어 cache miss가 났을 때 수행합니다.
flowchart TD
A[process setup: register script object] --> B[first execute]
B --> C{digest cache hit인가}
C -- 아니요 --> D[SCRIPT LOAD]
D --> E[digest cache 저장]
C -- 예 --> F[EVALSHA]
E --> F
F --> G{NOSCRIPT인가}
G -- 아니요 --> H[result decode]
G -- 예 --> I[digest forget]
I --> J[SCRIPT LOAD 후 EVALSHA 한 번 재실행]
LettuceRedisScriptOperations.execute는 key가 비어 있거나 maxKeys를 넘으면 거절합니다. key는 namespace와 same-slot 검사를 받으며 request/reply/timeout budget도 붙습니다. 다만 EVALSHA request의 expectedReplyBytes는 0이고, decoder 호출 전 관측 reply 크기를 검사하지 않습니다. maxReplyBytes가 budget에 저장된다는 사실만 확인되며 실제 reply ceiling 집행은 빠져 있습니다.
NOSCRIPT만 자동 복구합니다. server가 EVALSHA 실행 전에 script 부재를 답했으므로 reload와 1회 재호출이 ambiguous write retry는 아닙니다. 다른 failure는 자동 재호출하지 않습니다.
RedisScriptRegistry class comment의 “registration is a deployment step”은 process registration을 뜻한다고 좁혀 읽어야 합니다. 실제 Redis SCRIPT LOAD는 first use입니다.
Function: deployment-time library와 request-time call을 나눕니다
RegisteredRedisFunction은 library, semantic version, function name, max keys, timeout, reply ceiling, read-only flag, decoder를 가집니다.
application surface에는 FUNCTION LOAD가 없습니다. policy에서 FUNCTION LOAD는 admin-only이며, LettuceRedisFunctionOperations은 probed FUNCTIONS capability가 있을 때만 instance를 만듭니다.
request-time에는 다음만 수행합니다.
- key가 1개 이상이고 declared
maxKeys이내인지 확인합니다. - key와 arguments를 encode하고 request size를 계산합니다.
- reply ceiling과 timeout으로
OperationBudget을 만듭니다. - read-only면
FCALL_RO, 아니면FCALL을 선택합니다. - guard admission 후 function name으로 call합니다.
function request도 function.maxReplyBytes()로 budget을 만들지만 expectedReplyBytes는 0입니다. decoder 호출 앞에 관측 reply budget 검사가 없습니다.
script와 달리 function not found에서 library를 load하는 recovery가 없습니다. function library는 배포 pipeline이 먼저 설치해야 합니다.
현재 call path는 RegisteredRedisFunction.library()와 version()을 server request에 넣거나 server-side library metadata와 대조하지 않습니다. 실제 gateway 호출은 function.name()만 전달합니다. record가 version을 보유한다는 것과 runtime deployment check가 구현됐다는 것은 다릅니다.
Pub/Sub: publish와 subscription lifecycle이 다릅니다
LettuceRedisPubSubOperations은 publish는 guarded command로 보내지만 subscribe는 dedicated gateway로 시작해 caller가 닫아야 하는 Subscription을 반환합니다.
channel subscription은 channel별 codec map을 만듭니다. 여러 channel을 구독하면서 첫 channel codec으로 모든 payload를 decode하지 않습니다. 요청하지 않은 channel message가 오면 codec을 추측하지 않고 실패합니다.
pattern subscription은 concrete channel만 전달받으므로 어느 pattern codec인지 역산할 수 없습니다. 따라서 singleCodec이 모든 pattern의 codec id가 같은지 검사합니다.
sharded Pub/Sub은 capability-gated 별도 surface입니다. ordinary Pub/Sub과 topology routing 의미가 같다고 합치지 않습니다.
Admin: command allowlist가 아니라 projection까지 좁힙니다
LettuceRedisAdminOperations.run은 catalog policy가 ADMIN_ONLY이면서 read-only인지 다시 확인합니다.
노출 기능은 INFO, DBSIZE, MEMORY USAGE, bounded SLOWLOG, LATENCY LATEST, bounded CLIENT projection, CLUSTER INFO, fixed CONFIG GET projection, ACL DRYRUN입니다.
CONFIG GET은 glob을 받지 않고 DIAGNOSTIC_PARAMETERS에 고정된 이름만 요청합니다. 응답에서도 allowlist를 다시 적용하고 secret-shaped parameter name의 value를 redact합니다.
slow log에는 command family만 남기고 arguments를 버립니다. client projection에는 peer address와 connection name을 넣지 않습니다.
admin run은 OperationBudget을 request에 붙이지만 expectedReplyBytes를 0으로 두고 raw list를 그대로 반환합니다. projection별 count 상한은 있어도 실제 reply byte ceiling을 공통으로 집행하는 호출은 없습니다.
Raw: 두 개의 독립된 승인이 필요합니다
RawCommandApprovals은 두 조건을 모두 요구합니다.
- organization catalog가 command를
RAW_ONLY로 분류했습니다. - deployment가 concrete
ApprovedRawCommand를 등록했습니다.
approval은 policy id, command id, max arguments, request/reply ceiling, timeout, decoder를 고정합니다. token은 같은 registry가 같은 policy id에 대해 발급한 concrete instance여야 합니다.
여기서 reply ceiling을 고정한다는 말은 approval과 OperationBudget이 그 숫자를 보유한다는 뜻입니다. raw request의 expectedReplyBytes는 0이고 caller decoder 앞에도 관측 reply 크기 검사가 없어, 실제 ceiling 집행까지 완성되지는 않았습니다.
raw gateway는 argument에서 key를 추출해 bound namespace로 parse합니다. movable key command는 local parser가 정확히 위치를 결정할 수 있는 family만 허용합니다. 모르는 shape를 best guess하지 않습니다.
WAIT는 catalog에 없으므로 raw approval 대상으로도 등록할 수 없습니다. WAIT를 raw escape hatch로 쓸 수 있다는 근거는 없습니다.
Extension module: server capability가 bean 존재를 결정해야 합니다
JSON, Time Series, probabilistic structure, Search extension implementation은 각각 probed capability를 받는 ifSupported factory를 가집니다.
- JSON path와 value ceiling을 검사합니다.
- Time Series는 retention과 bounded range를 요구합니다.
- probabilistic reserve는 error/capacity/compression 등의 bound를 요구합니다.
- Search index name을 namespace에 묶고 page와 timeout을 요구합니다.
extension 공통 runner는 policy name이 있는 command에만 permit과 collection budget을 붙입니다. null policy path는 permit과 budget이 모두 비어 있습니다. 예를 들어 JSON.SET은 null을 넘기고, bounded JSON.GET은 policy name을 넘깁니다. 두 분기 모두 expectedReplyBytes가 0이며 runner가 반환된 List<Object>를 그대로 넘기므로 관측 reply 검사가 없습니다. bounded read에 budget 객체가 있다는 사실도 reply byte ceiling 집행을 뜻하지 않고, null policy command에는 그 객체조차 없습니다.
RedisExtensionModulesContractTest은 capability가 없으면 fixture에서 instance가 없음을 고정합니다. 이것은 production conditional bean이 실제로 조립됐다는 증거는 아닙니다.
테스트가 고정하는 계약
Batch 계약은 batch ceiling 선검사, refused item의 전체 batch 취소, item permit·budget 보존, foreign batch 거절, decoded shape 근사 누적값의 ceiling 교차 처리을 각각 고정합니다. 마지막 테스트는 ASCII string 사례에서 누적 failure가 나는 계약이며 exact wire-byte 계측을 증명하지 않습니다.
Transaction 계약도 사례별로 나뉩니다.
- commit 전 queued command 미적용
- queued reply 조기 접근 금지
- watch conflict에서 실행하지 않음
- attempt ceiling 안의 conflict 재시도
- callback failure의 connection state cleanup
- queued command의 동일 admission 적용
- watch key와 queued write의 cross-slot 거절
- 여러 queued write 사이의 cross-slot 거절
- co-located key commit
Script 계약은 first use 실행과 load, digest cache, NOSCRIPT 1회 reload, unregistered script 거절, id/body identity 안정성을 별도 테스트로 고정합니다.
Function 계약은 capability absence, deployed function call, key declaration·상한, semantic version identity을 각각 고정합니다.
Pub/Sub은 subscription close lifecycle, foreign namespace 거절, empty subscription 거절, channel별 codec, pattern mixed codec 거절, sharded capability gate, reactive cancellation cleanup을 서로 다른 테스트가 고정합니다.
Admin은 diagnostic parsing, fixed·redacted config projection, slow log argument 제거, client identity 제거, projection bound, destructive command 차단, ADMIN_ONLY read-only command 한정을 개별 사례로 고정합니다.
Raw는 approved command 실행, RAW_ONLY만 approval 가능, movable key parser 필수, token provenance, registered approval과 token 일치, namespace parse-back, argument ceiling을 각각 고정합니다.
이번 문서 작업에서는 이 테스트를 실행하지 않았습니다. production source와 테스트를 정적으로 대조했습니다.
현재 구현 공백과 잘못 읽기 쉬운 지점
- advanced surface implementation은 있지만 production Spring bean 조립은 확인되지 않습니다.
- script의 process registration과 Redis server load 시점은 다릅니다.
SCRIPT LOAD는 first use입니다. - function은 배포 시 load해야 하며 request-time load/recovery가 없습니다.
RegisteredRedisFunction의 library/version은 call path에서 server deployment와 대조되지 않습니다. Javadoc이 말하는 deployment check 구현도 찾지 못했습니다.- admin class는
FUNCTION LOAD를 public method로 노출하지 않습니다. function deployment는 이 application admin surface 밖의 작업입니다. - raw role credential은 settings에서 해석되지만
RedisConnectionKind에는RAWlane이 없고 descriptor는RAW_GATEWAY를REGULAR로 매핑합니다. 실제 별도 raw account connection 조립은 확인되지 않습니다. - batch는 atomic하지 않고 transaction은 rollback하지 않습니다.
- script, function, raw, admin에는 reply budget 값이 있지만 관측한 reply byte를 decoder 전에 검사하지 않습니다. extension은 policy name이 있을 때만 budget이 있고 null policy path에는 budget 자체가 없으며, 어느 쪽도 관측 reply를 검사하지 않습니다.
- batch의 post-decode ceiling은 result shape의 근사 누적값에 적용됩니다.
CharSequence.length()와 unknown scalar 1을 사용하므로 exact wire bytes가 아닙니다. - transaction
INCRBY와 transaction collection write는 expiration이나 persistent permit 없이 absent key를 만들 수 있습니다. - extension fixture의
ifSupported조립은 production conditional bean 증거가 아닙니다.
다음에 source를 열 때는 RedisConnectionKind, 각 public interface, implementation, contract test, 마지막으로 production auto-configuration 순으로 보면 됩니다.
시리즈의 관련 문서
관련 범위는 connection lifecycle, command admission, typed operations, execution failure certainty입니다.
시리즈에서 이어 읽기
- 전체 흐름: 「Redis를 범용 클라이언트가 아니라 정책 경계로 다루기」
- 운영 흐름: 「Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지」
Timeout 뒤 쓰였는지 모를 때: Executor와 실행 확실성 모델
이 글이 답하는 코드 질문
Redis write가 timeout 또는 connection loss로 실패했을 때 “실행되지 않았다”고 말할 수 있습니까? sync, reactive, transaction queue는 같은 admission과 failure metadata를 어떻게 사용합니까?
현행 translator의 핵심 규칙은 다음과 같습니다.
- server가 거절했다는 reply가 있으면 confirmed failure로 다룹니다.
- read timeout/connection failure는 policy가 retry-safe인 경우 retryable metadata를 가질 수 있습니다.
- 실행됐을 수 있는 write timeout/connection loss는
RedisAmbiguousExecutionException입니다. - ambiguous failure는
retryable=false입니다.
executor 자체에는 자동 retry loop가 없습니다. metadata와 ExecutionCertainty는 caller가 retry·reconciliation·compensation을 결정할 근거이지, 현재 production pipeline이 자동 재전송한다는 증거가 아닙니다.
먼저 보는 클래스 지도
| 클래스 | 입력 | 출력 | 다음 호출 |
|---|---|---|---|
| CommandRequest | command/key/size/permit/budget/deferred invocation | 실행 전 요청 | guard |
| CommandAdmission | descriptor/lane/slot/timeout | 실행 결정 | executor |
| SyncRedisCommandExecutor | request | blocking result 또는 typed failure | translator·observation |
| ReactiveRedisCommandExecutor | request | Mono<R> |
translator·observation |
| QueueingRedisCommandExecutor | transaction command/stage | unresolved stage, explicit await | EXEC |
| LettuceExceptionTranslator | Throwable와 execution context | stable SDK exception | caller |
| RedisFailureMetadata | payload-free failure facts | retry/ambiguity 판단 값 | caller·telemetry |
| ExecutionCertainty | descriptor와 certainty state | 자동 retry 허용 여부 | failover model |
| SentinelFailoverObserver | promotion/reconnect/in-flight 분류 | counters와 certainty | operator/caller |
Admission과 wire send의 경계
CommandRequest는 invocation을 Supplier<CompletionStage<R>>로 보관합니다. guard가 실패하면 supplier를 평가하지 않으므로 명령은 전송되지 않습니다.
flowchart TD
A[CommandRequest] --> B[guard.validate]
B -->|거절| C[not-sent typed exception]
B -->|admit| D[invocation.get]
D --> E{reply/driver outcome}
E -->|success| F[result + success observation]
E -->|server error| G[confirmed typed failure]
E -->|timeout/connection loss| H{read인가, ambiguous write인가}
H -->|retry-safe read| I[retryable non-ambiguous failure]
H -->|write may have applied| J[ambiguous non-retryable failure]
admission failure와 invocation 이후 failure는 evidence가 다릅니다. namespace·permit·budget·capability 거절은 not sent입니다. invocation을 시작한 뒤 reply를 못 받은 write는 server에 도달하지 않았다고 증명할 수 없습니다.
Effective timeout은 어디서 옵니까
기본 timeout은 TimeoutProfile에 있습니다.
| profile | default |
|---|---|
FAST |
500ms |
COLLECTION |
2s |
SCRIPT |
1s |
BATCH |
2s |
ADMIN |
3s |
BLOCKING |
2s default, 실제 block에는 margin 적용 |
R2 request가 OperationBudget을 가지면 non-blocking path에서는 budget의 timeout이 effective timeout입니다. server block을 선언하지 않은 optional-blocking path도 같은 분기입니다. OperationBudget은 element, request bytes, reply bytes, timeout을 모두 양수로 요구합니다.
blocking command가 bounded server block을 선언하면 budget timeout은 사용하지 않습니다. 0·음수·configured maximum 초과를 거절한 뒤 serverBlock + BLOCKING_MARGIN(2s)를 client-side timeout으로 씁니다. optional-block command에 block이 없을 때만 budget 또는 profile default를 사용합니다. 이 분기는 CommandPolicyGuard.effectiveTimeout에 그대로 드러납니다.
Sync executor의 호출 순서
SyncRedisCommandExecutor.execute는 다음 순서로 동작합니다.
- guard가
CommandAdmission을 만듭니다. - descriptor, lane, topology, slot으로 observation을 시작합니다.
invocation.get()으로 driver call을 시작합니다.- returned stage를 effective timeout까지 기다립니다.
- success면 observation을 기록하고 결과를 반환합니다.
- runtime failure면 elapsed를 넣은 context로 translate합니다.
- translated metadata의 ambiguity를 failure observation에 기록한 뒤 throw합니다.
CompletableFuture.get timeout은 Lettuce RedisCommandTimeoutException으로 감싸 translator에 보냅니다. Java InterruptedException은 interrupt flag를 복원한 뒤 CompletionException으로 감쌉니다. 두 failure는 translator에서 같은 branch를 타지 않습니다.
observation sink는 NoThrowObservationSink으로 감쌉니다. success 기록의 경계는 meter failure를 Redis write failure로 오인하지 않게 driver try/catch 밖에서 success observation을 기록합니다.
Reactive executor의 호출 순서
ReactiveRedisCommandExecutor.execute는 Mono.defer 안에서 admission을 실행합니다.
이 위치 때문에 다음이 성립합니다.
- publisher assembly 때는 Redis 호출과 guard validation이 시작되지 않습니다.
- subscribe 때 namespace/permit/budget failure가 error signal로 발생합니다.
- caller는
onErrorResume같은 reactive recovery를 사용할 수 있습니다. - 같은 publisher를 여러 번 subscribe하면 deferred request가 다시 실행될 수 있습니다.
admission 후 Mono.fromCompletionStage와 .timeout(admission.timeout())을 적용합니다. error는 translator를 거쳐 stable SDK exception이 되고 observation에 ambiguity가 기록됩니다.
sync와 reactive는 같은 guard와 descriptor semantics를 사용하지만 timeout 구현 자체는 Future.get과 Reactor operator로 다릅니다.
Queueing executor는 왜 기다리지 않습니까
transaction의 queued command는 MULTI 안에서 +QUEUED만 받습니다. 실제 reply는 EXEC가 실행될 때까지 존재하지 않습니다. 여기서 일반 sync executor처럼 wait하면 transaction이 자기 reply를 만들 EXEC에 도달하지 못해 deadlock합니다.
QueueingRedisCommandExecutor.queue는 admission과 invocation 시작까지만 하고 stage를 반환합니다.
success observation도 queue 시점이 아니라 stage completion에 붙입니다. watch conflict로 EXEC가 실행하지 않은 command를 성공으로 세지 않기 위해서입니다.
transaction 자체가 소유한 WATCH, MULTI, EXEC, cleanup stage는 await로 기다립니다. 특히 EXEC reply timeout은 transaction 전체가 실행됐을 수도 있으므로 write context로 번역되어 ambiguous입니다. Java interrupt도 flag를 복원한 뒤 EXEC write context로 translator에 보내므로 unclassified ambiguous failure가 됩니다.
Translator의 분류 순서
translate는 CompletionException과 ExecutionException을 먼저 벗깁니다. 이미 RedisOperationException이면 그대로 반환합니다.
그다음 구체적인 driver type을 분류합니다.
| 입력 | SDK failure | retry/ambiguity |
|---|---|---|
RedisCommandTimeoutException 또는 Java TimeoutException |
read: RedisTimeoutException; ambiguous write: RedisAmbiguousExecutionException |
read policy에 따라 retryable; write ambiguous |
Lettuce RedisCommandInterruptedException |
timeout과 같은 hierarchy | read policy에 따라 retryable; write ambiguous |
executor의 Java InterruptedException |
unclassified read: generic RedisOperationException; ambiguous write: RedisAmbiguousExecutionException |
retry-safe read만 retryable; write ambiguous |
| connection failure | read: RedisConnectionException; ambiguous write: RedisAmbiguousExecutionException |
같은 규칙 |
| loading/busy | RedisBusyException |
read 여부 또는 false |
| Lettuce NOSCRIPT | RedisNoScriptException |
false/false |
| read-only replica/partition | RedisRedirectionException |
false/false |
| server execution error | leading error code로 세분화 | server reply가 있으므로 non-ambiguous |
| unclassified failure | retry-safe read: generic retryable failure; ambiguous write: ambiguous failure | descriptor에서 결정 |
unclassified write가 plain non-applied failure로 떨어지지 않는 것이 중요합니다. unclassified fallback은 server reply가 없고 write가 ambiguous할 수 있으면 안전한 기본값으로 ambiguity를 선택합니다.
이 구분은 class 이름이 비슷해서 놓치기 쉽습니다. translator가 timeout으로 직접 분류하는 interrupted type은 Lettuce의 RedisCommandInterruptedException뿐입니다. Future.get이 던지는 java.lang.InterruptedException은 그 type이 아니므로 unwrap 뒤 unclassified fallback으로 갑니다.
Server error code와 정보 노출 제한
RedisCommandExecutionException은 message의 첫 uppercase error code만 읽습니다.
WRONGTYPE→RedisDataTypeMismatchExceptionCROSSSLOT→RedisCrossSlotExceptionNOPERM,NOAUTH,WRONGPASS,NOUSER,UNAUTHORIZED→RedisAccessDeniedExceptionMOVED,ASK,TRYAGAIN,CLUSTERDOWN,MASTERDOWN,REDIRECT→RedisRedirectionExceptionBUSY,LOADING,BUSYGROUP,BUSYKEY→RedisBusyExceptionNOSCRIPT→RedisNoScriptExceptionOOM,MISCONF,NOREPLICAS,EXECABORT,READONLY→RedisCommandRejectedException
serverError는 raw server message를 SDK message에 복사하지 않습니다. Redis error에 들어갈 수 있는 key와 argument fragment가 exception/telemetry로 노출되지 않게 합니다.
RedisFailureMetadata가 보존하는 것
RedisFailureMetadata는 다음 field만 가집니다.
- low-cardinality
commandCategory CommandAccess- read 여부
- retryable 여부
- ambiguous execution 여부
- optional server version
- deployment mode
- optional Cluster slot
- elapsed duration
key, value, credential, raw server message는 없습니다. constructor는 retryable과 ambiguous가 동시에 true인 상태를 금지하며 slot을 0..16383으로 제한합니다.
notSent factory는 read rejection만 retryable로 표시하고 ambiguity는 false로 둡니다. stored data corruption은 read여도 retryable이 아니므로 별도 storedDataCorruption factory를 사용합니다.
실행 확실성 네 상태
ExecutionCertainty는 상태를 네 개로 이름 붙입니다.
stateDiagram-v2
[*] --> CONFIRMED_SUCCESS: server success reply
[*] --> CONFIRMED_FAILURE: server refusal reply
[*] --> SAFE_TO_RETRY_FAILURE: server 미도달 증명
[*] --> AMBIGUOUS_FAILURE: 도달/적용 여부 불명
allowsAutomaticRetry는 confirmed outcome에는 false, safe-to-retry failure에는 true를 반환합니다. ambiguous failure는 descriptor가 retry-safe일 때만 true입니다.
그러나 exception metadata의 invariant는 ambiguous와 retryable을 동시에 허용하지 않습니다. 따라서 ExecutionCertainty.AMBIGUOUS_FAILURE가 retry-safe read에 대해 자동 retry를 허용하는 모델과 translator가 생성하는 metadata는 서로 다른 표현 계층입니다. 현재 executor가 ExecutionCertainty를 사용해 retry하는 코드는 없습니다.
Sentinel reconnect queue와 in-flight 분류
SentinelFailoverObserver는 promotion 시 다음을 기록하도록 설계됐습니다.
- promotion count
- ambiguous non-idempotent write count
- reconnect queue가 차서 거절한 count
- longest reconnect duration
offerWhileReconnecting은 atomic counter가 configured maximum을 넘으면 즉시 false를 반환하고 refusal을 셉니다. unbounded backlog를 만들지 않습니다.
classify(descriptor, reachedServer)는 server에 도달하지 않았으면 SAFE_TO_RETRY_FAILURE, 도달했으면 AMBIGUOUS_FAILURE를 반환합니다. 후자의 descriptor가 retry-safe가 아니면 ambiguous write counter를 올립니다.
이 observer의 class comment에 있는 2,086과 1 수치는 historical Sentinel 실험 설명입니다. client가 성공 reply를 받은 뒤 old primary의 write가 유실되는 경우는 observer가 볼 수 없으며, server-side min-replicas-to-write와 bounded min-replicas-max-lag가 필요하다고 설명합니다. 이 수치를 현행 runtime test 결과로 표현하면 안 됩니다.
WAIT로 이 공백을 해결한다고 읽어도 안 됩니다. 현행 command policy에 WAIT가 없어 default-deny이며 typed/semantic surface도 없습니다.
테스트가 고정하는 계약
translator 테스트는 failure별 시작 행을 따로 가집니다.
- write timeout의 ambiguous·non-retryable metadata
- read timeout의 retryable·non-ambiguous metadata
- write 주변 connection loss의 ambiguity
- async completion wrapper 제거
- server code의 stable exception hierarchy 변환
- server message detail 비노출
- 이미 번역한 failure의 동일 instance 통과
- unrecognized write failure의 ambiguity
- unrecognized read failure의 retryable metadata
Sentinel observer는 server 미도달, non-idempotent in-flight write, idempotent read, confirmed outcome, bounded queue, longest reconnect를 각각 단위 테스트합니다.
Transaction 쪽은 queued command의 commit 전 미적용, QueuedReply 조기 접근 금지, watch conflict에서 미실행을 별도 테스트가 고정합니다.
이 테스트는 이번 문서 작업에서 실행하지 않았습니다. real-server standalone/Sentinel/Cluster/TLS lane도 실행하지 않았습니다.
현재 구현 공백과 잘못 읽기 쉬운 지점
- sync/reactive/queueing executor, translator, guard의 production bean 조립은 확인되지 않습니다.
- aggregate facade와 application bridge가 미조립이므로 이 failure model이 모든 production Redis call에 적용된다고 단정할 수 없습니다.
- executor에는 automatic retry loop가 없습니다.
retryable은 재전송이 일어났다는 뜻이 아닙니다. ExecutionCertainty와SentinelFailoverObserver는 production source에서 서로 외의 사용처나 runtime wiring을 찾지 못했습니다.CommandExecutionContext.of는 server version을Optional.empty()로 만들며 executors는withServerVersion을 호출하지 않습니다. translator가 만든 failure metadata의 server version은 현재 비어 있습니다. guard rejection metadata에는 probed version이 들어가는 것과 다릅니다.QueueingRedisCommandExecutor.queue의 asynchronously failed stage는 translator로 observation을 만들지만 returned stage 자체를 translated failure로 교체하지 않습니다. transaction caller가 받는 exception shape는 별도 검증이 필요합니다.- Java
InterruptedException은 LettuceRedisCommandInterruptedException과 달리 timeout hierarchy로 번역되지 않습니다. sync read는 generic unclassified failure가 될 수 있고, write와 transactionEXEC는 ambiguous가 됩니다. 이 차이를 직접 고정하는 executor contract test는 확인되지 않았습니다. - Sentinel observer의 reconnect queue counter는 실제 driver queue를 소유하는 자료구조가 아니라 admission 판단과 metric 모델입니다. production 연결도 확인되지 않았습니다.
- success reply 뒤 promotion으로 유실된 write는 client ambiguity model이 탐지할 수 없습니다.
WAIT는 현재 default-deny입니다.
다음에 source를 열 때는 guard와 admission, 세 executor, execution context, translator, metadata, certainty enum, Sentinel observer, tests 순으로 보면 됩니다.
시리즈의 관련 문서
관련 범위는 command admission, connection lifecycle, typed operations, advanced surfaces입니다.
시리즈에서 이어 읽기
- 전체 흐름: 「Redis를 범용 클라이언트가 아니라 정책 경계로 다루기」
- 운영 흐름: 「Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지」
Redis 캐시 한 요청의 전 생애: Generation·Envelope·Soft/Hard TTL
이 글이 답하는 코드 질문
ca-skeleton.capabilities.cache.bindings.default=redis인 애플리케이션에서 캐시 조회 한 번은 어디에서 시작하고, 어떤 Redis 명령을 거쳐, 언제 원본 저장소로 내려갑니까? 이 글은 Spring이 만드는 CacheRegionPort<String, byte[]>와 애플리케이션의 CacheAsideExecutor를 함께 읽습니다.
먼저 결론을 구분해야 합니다.
- Redis cache region adapter는 production bean으로 조립됩니다.
CacheAsideExecutor의 local single-flight, source bulkhead, stale fallback도 구현되어 있습니다.- 그러나 두 객체를 묶는 production use-case bean은 확인되지 않습니다.
- 분산 refresh용
CacheRefreshCoordinationPort는 계약과 테스트 대역만 있고 Redis production 구현·bean은 확인되지 않습니다. - adapter 안에서도 region generation은 instance-local로 한 번만 읽고, conditional write는 generation과
CacheWriteCondition을 보존하지 않습니다. future schema의QUARANTINE_AND_RELOAD도 executor에서는 실제 reload가 아니라FAIL_FAST로 끝납니다.
따라서 아래 흐름 중 Redis 조회·기록은 현재 조립된 capability이고, distributed refresh 흐름은 구현된 오케스트레이션 계약이지만 production 조립은 미완성입니다.
먼저 보는 클래스·리소스 지도
| 코드 | 입력 | 출력 | 다음 호출 |
|---|---|---|---|
RedisCapabilityConfig.redisDefaultCacheRegion |
RedisRuntimeOwner, namespace, cache 설정, Secret, Clock |
CacheRegionPort<String, byte[]> bean |
RedisCacheRegionAdapter 생성자 |
CacheRegionPort |
semantic key/value | typed lookup·record·invalidate 결과 | provider adapter |
CacheAsideExecutor.getOrLoad |
key, region, source loader | CacheResult<V> |
lookup, single-flight, source load, record |
RedisCacheRegionAdapter.lookup |
semantic key | Hit, NegativeHit, Miss, IncompatibleSchema, Unavailable |
generation 확인, GET, envelope 해석 |
RedisCacheRegionAdapter.write |
value/absence, source revision, write intent | CacheRecordOutcome |
조건 확인 후 SET + TTL |
CacheEnvelope |
schema, revision, generation, 두 expiry, absence, payload | pipe header + payload bytes | interpret |
CacheRefreshCoordinationPort |
key, attempt, lease TTL | claimed/contended/unavailable/indeterminate | source refresh admission |
객체가 만들어지는 시점
전역 app.redis.enabled=true이고 default cache binding이 redis일 때만 redisDefaultCacheRegion bean이 생깁니다. 이 메서드는 cache 설정을 검증하고, 공통 app.redis.namespace 아래의 CacheKeys를 만들며, semantic key를 HMAC-SHA-256으로 바꾸는 함수를 주입합니다. HMAC material에는 environment/service/domain이 함께 들어가므로 같은 identifier라도 namespace가 다르면 digest도 달라집니다. 출력은 hv1:<hex>입니다. 근거는 KeyDigest.of와 of에서 확인할 수 있습니다.
기본 설정은 soft TTL 30초, hard TTL 5분, negative TTL 10초, command timeout 200ms입니다. positiveSoftTtl <= positiveHardTtl, hard TTL의 configured floor, 양수 command timeout, 양수 key version을 startup에 검사합니다. RedisCapabilitySettings.Cache.validate
CacheAsideExecutor는 생성 시 region별 정책으로 local CacheSingleFlight와 CacheSourceBulkhead를 만듭니다. 2인자 생성자는 refresh coordinator를 주입하지 않습니다. 4인자 생성자만 coordinator와 CacheRefreshCoordinationPolicy를 받습니다. CacheAsideExecutor 생성자
요청 시 호출 순서
sequenceDiagram
participant U as Use case
participant E as CacheAsideExecutor
participant C as RedisCacheRegionAdapter
participant R as Redis
participant S as Source loader
U->>E: getOrLoad(key, region, loader)
E->>C: lookup(key)
opt 이 CacheKeys의 generation이 unresolved
C->>R: INCRBY generation 0
end
C->>R: GET entryKey(HMAC(key))
alt fresh 또는 negative hit
C-->>E: Hit / NegativeHit
E-->>U: 즉시 결과
else future schema
C-->>E: QUARANTINE_AND_RELOAD + unusable token
E-->>U: FAIL_FAST (source 미호출)
else stale/miss/unavailable
C-->>E: typed lookup
E->>E: local single-flight + source bulkhead
E->>S: load(key, cancellation)
S-->>E: loaded / absent / failure
E->>C: record 또는 recordAbsent
C->>R: SET envelope [NX/none] PX hardTTL
E-->>U: LoadedFromSource 등 typed result
end
1. generation을 먼저 확정합니다
lookup은 REGULAR lane을 빌린 뒤 resolveGeneration을 호출합니다. 다만 서버 값을 읽는 시점은 각 CacheKeys의 최초 접근 한 번뿐입니다. resolved가 true가 되면 이후 lookup과 write는 Redis counter를 다시 읽지 않고 process-local generation을 사용합니다. 최초 호출의 INCRBY generationKey 0은 키가 없을 때 0을 만들고 그 시점의 출발값을 맞추지만, instance 사이의 이후 변경을 전파하지는 않습니다. resolveGeneration, CacheKeys.resolved
예를 들어 instance A와 B가 모두 generation 0을 resolve한 뒤 A가 region을 1로 올리면, A의 CacheKeys만 1로 갱신됩니다. B는 계속 0을 사용하므로 generation-0 entry를 hit하거나 generation 0으로 다시 기록할 수 있습니다. 현행 region invalidation을 multi-instance 전체에 즉시 적용되는 semantic invalidation으로 읽을 수 없는 이유입니다.
entry key는 공통 namespace, capability cache, key layout version, region, HMAC digest로 렌더링됩니다. 원래 semantic key는 Redis key에 들어가지 않습니다. CacheKeys.entryKey
2. GET 결과를 다섯 종류로 나눕니다
저장값이 없으면 Miss(ABSENT)입니다. 값이 있으면 CacheEnvelope.decode가 여섯 개의 | 경계를 찾고 schema version, source revision, generation, soft/hard absolute epoch millis, absence marker와 payload를 복원합니다. 현행 schema는 v1입니다. CacheEnvelope.encode
해석 순서는 다음과 같습니다.
- future schema는 adapter에서
QUARANTINE_AND_RELOAD로 분류합니다. 그러나 이 2인자IncompatibleSchema에는 usable observation token과 write condition이 없습니다. - retired, unknown, corrupt envelope는
FAIL_FAST입니다. - envelope generation이 현재 generation과 다르면
Miss(INVALIDATED)입니다. - hard expiry가 지났으면
Miss(EXPIRED)입니다. - absence marker가 있으면
NegativeHit입니다. - 그 밖에는 soft expiry 전이면
FRESH, soft와 hard 사이면STALE입니다.
이 순서는 interpret에 그대로 드러납니다. future schema를 보통 miss로 바꾸지 않는 이유는 구버전 instance가 신버전 값을 덮어쓰는 일을 막기 위해서입니다.
여기서 typed label과 end-to-end 동작을 구분해야 합니다. CacheAsideExecutor는 policy가 QUARANTINE_AND_RELOAD여도 observation token이 usable하지 않으면 policy를 FAIL_FAST로 바꾼 IncompatibleSchema를 즉시 반환합니다. source loader는 호출하지 않습니다. Redis adapter가 future schema에 쓰는 2인자 생성자는 observation token과 write condition을 모두 unavailable()로 채우므로, 현행 조합의 실제 흐름은 FUTURE_VERSION → QUARANTINE_AND_RELOAD label → executor FAIL_FAST입니다. CacheLookup.IncompatibleSchema, getOrLoad의 schema 분기
3. fresh와 negative는 source를 호출하지 않습니다
CacheAsideExecutor.getOrLoad는 FRESH를 FreshHit로, NegativeHit를 그대로 반환합니다. stale 값은 hard expiry와 observation token을 가진 후보로 보존합니다. miss와 unavailable은 source refill 대상으로 넘어갑니다. getOrLoad 분기
같은 process의 같은 key는 local single-flight로 합쳐집니다. maximum in-flight key, key당 waiter, wait duration을 넘으면 각각 MAXIMUM_IN_FLIGHT_KEYS, MAXIMUM_WAITERS, WAIT_TIMEOUT으로 거절됩니다. source bulkhead가 차면 SOURCE_OVERLOADED, deadline을 넘으면 LOAD_TIMEOUT입니다.
4. source 결과에 따라 positive 또는 negative를 기록합니다
Loaded는 region.record, AuthoritativeAbsent는 recordAbsent를 호출합니다. transient/permanent failure는 캐시에 쓰지 않습니다. source가 RetryableNoEffect 같은 idempotency 의미를 주는 구조가 아니라, cache 전용 SourceLoadOutcome으로 분리되어 있습니다. invokeSourceDirect
새 entry는 CacheEnvelope.CURRENT_SCHEMA_VERSION, source revision, 현재 generation, now + effectiveSoft, now + ttl, absence, payload를 가집니다. physical Redis TTL은 hard TTL과 같습니다. positive entry는 hard TTL, negative entry는 별도 negative TTL을 사용합니다. write
ONLY_IF_ABSENT는 SET ... NX에 대응합니다. ONLY_IF_OBSERVED에서는 lookup 시점의 entry bytes로 CacheObservationToken과 CacheWriteCondition을 모두 만듭니다. executor도 두 값을 CacheRecordMetadata에 실어 보냅니다. 그러나 Redis adapter의 write는 metadata.writeCondition()을 읽지 않고, 현재 entry bytes의 SHA-256 앞 16바이트와 metadata.observedToken()만 비교합니다. CacheRecordMetadata, executor의 metadata 전달, write의 조건 비교
따라서 감지 범위는 entry bytes 교체에 한정됩니다. region generation bump는 기존 entry bytes를 바꾸지 않으므로 source load 중 invalidate가 일어나도 비교가 통과합니다. 같은 adapter라면 새 local generation으로 load 결과를 써서 invalidation 직후 값을 다시 채울 수 있고, 다른 instance라면 앞서 캐시한 이전 generation으로 쓸 수 있습니다. generation과 byte observation을 하나의 atomic CAS에 넣지 않았고, bytes 비교용 GET과 최종 SET도 Lua나 transaction으로 묶지 않았습니다.
invalidation은 삭제와 세대 교체로 나뉩니다
단일 key invalidation은 GETDEL을 호출해 INVALIDATED와 ALREADY_ABSENT를 구분합니다. region invalidation은 KEYS나 SCAN으로 entry를 지우지 않고 generation key에 INCRBY 1을 적용한 뒤, 이 호출에 사용된 CacheKeys만 반환값으로 갱신합니다. 기존 entry는 Redis에 남아 hard TTL로 사라집니다. invalidate를 수행한 instance에서는 다음 lookup이 generation mismatch가 되지만, 이미 이전 generation을 resolve한 다른 instance에는 이 결론이 적용되지 않습니다. invalidateRegion, observeGeneration
stale refresh와 실패 분기
CacheAsideExecutor는 stale source load가 transient failure이고 policy가 허용하며 hard expiry 전이면 StaleFallbackAfterTransientFailure를 반환합니다. permanent failure에는 stale을 쓰지 않습니다. toResult
optional refresh coordinator가 주입된 경우에는 stale 또는 configured hard miss에서 claim을 시도합니다. Indeterminate claim은 같은 attempt로 한 번만 다시 호출합니다. contender나 unavailable/indeterminate가 stale을 갖고 있으면 source를 호출하지 않고 StaleRefreshDeferred를 반환합니다. owner는 claim 후 cache를 다시 읽어 다른 instance가 이미 채웠는지 확인하고, 자기 source load를 마친 뒤 finally에서 release합니다. invokeSource
이 executor는 비동기 background refresh scheduler가 아닙니다. owner가 동기 refresh를 수행하고 contender만 stale을 즉시 받습니다. hard miss의 bounded wait는 Thread.sleep 뒤 한 번 다시 읽는 구현입니다. boundedWait
Redis 장애는 cache에 한해 degraded로 처리됩니다. lookup은 Unavailable(UNAVAILABLE, NOT_APPLIED), record와 invalidation은 DEGRADED_UNAVAILABLE을 반환합니다. cache miss처럼 source로 내려갈 수 있다는 정책입니다. 다만 CacheRecordOutcome과 CacheInvalidationOutcome에는 INDETERMINATE가 정의되어 있어도 이 adapter의 catch-all은 이를 반환하지 않습니다. unavailable
테스트가 고정하는 계약
RedisCacheRegionAdapterTest는 absent→record→fresh hit, soft/hard expiry, negative expiry, schema label, 같은 adapter의 generation invalidation, entry-byte 조건부 기록과 Redis 장애 degradation을 in-memory gateway에서 고정합니다.- 같은 테스트의
regionInvalidationBumpsTheGeneration는 하나의 adapter와 하나의CacheKeys로 record→invalidate→lookup을 검사합니다. 두 adapter가 generation을 각각 resolve한 뒤 한쪽만 invalidate하는 regression test는 없습니다. onlyIfObservedRefusesAStaleWrite는 entry bytes 자체가 바뀐 경우를 검사합니다. generation bump와 in-flightONLY_IF_OBSERVED를 결합하지 않습니다.aFutureSchemaIsQuarantined는 adapter의 category와 policy label만 검사합니다. 실제 adapter와 executor를 결합해 source reload를 확인하지 않습니다.CacheAsideExecutorTest는 fresh/negative의 source bypass와 typed source 결과를 검사합니다.- 같은 테스트의
invalidationDuringLoadRejectsTheOldCapturedWriteCondition는 condition을 직접 교체하고metadata.writeCondition()을 검사하는 fake region의 application-core 계약입니다. Redis adapter가 이 condition을 소비한다는 증거는 아닙니다. - 같은 테스트의
distributedSoftLeaseLetsOnePodRefreshWhileAContenderReturnsStale는 두 executor와 test coordinator로 owner 하나만 source를 호출하는 계약을 고정합니다. Redis 구현을 검증하는 테스트는 아닙니다. LiveRedisSemanticPortsTest는 standalone/cluster real-server lane에서 application ACL account로 record/read가 동작함을 확인하도록 태그되어 있습니다.RedisCapabilityCompositionTest.cacheBindingComposesTheCacheRegion는 연결하지 않고 cache bean 한 개만 생기는지를 검사합니다.
현재 구현 공백과 잘못 읽기 쉬운 지점
- semantic Redis composition은 cache, rate-limit, lease, idempotency V2 네 개가 있고 Session이 빠진 4/5입니다.
CacheRegionPortbean은 있지만CacheAsideExecutor를 이 bean과 묶어 실제 use case에 주입하는 production 조립은 검색되지 않습니다.CacheRefreshCoordinationPortproduction 구현은 없습니다.DisabledCacheRefreshCoordinationPort와 테스트 내부 fake coordinator만 확인됩니다. 따라서 “분산 refresh가 Redis lease로 동작한다”고 말할 근거는 없습니다.- 각 instance는 region generation을 최초 한 번만 읽습니다. 다른 instance의 bump를 관찰하지 못하므로 multi-instance semantic invalidation은 완성되지 않았고, 이를 재현하는 test도 없습니다.
- Redis adapter의
ONLY_IF_OBSERVED는CacheWriteCondition과 generation을 조건에 포함하지 않습니다. entry-byte 비교만 하며GET과SET도 원자적이지 않습니다. application-core의 invalidation-during-load fake test를 Redis 구현 증거로 확대할 수 없습니다. - future schema의
QUARANTINE_AND_RELOAD는 adapter label입니다. unusable observation 때문에 executor는FAIL_FAST를 반환하고 source를 호출하지 않습니다. CacheEnvelope주석에는 background refresh 표현이 있으나 executor 구현은 동기 owner refresh입니다. 현행 method body가 우선 근거입니다.- 이번 문서 작업에서는 real-server lane을 실행하지 않았습니다. 위 live test 설명은 코드와 historical evidence의 범위이며 현재 HEAD 재실행 결과가 아닙니다.
다음에 열어볼 source 순서
다음 읽기 순서는 RedisCapabilityConfig → CacheAsideExecutor → RedisCacheRegionAdapter → CacheEnvelope → 두 test class가 적절합니다. SDK의 command admission과 connection lane은 별도 문서가 소유할 범위입니다.
시리즈에서 이어 읽기
- 전체 흐름: 「Redis를 범용 클라이언트가 아니라 정책 경계로 다루기」
- 운영 흐름: 「Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지」
세 가지 Redis Rate Limit Lua를 코드로 추적하기
이 글이 답하는 코드 질문
HTTP 요청 하나가 어떤 식별자를 남기고 Redis의 fixed-window, sliding-counter, token-bucket 중 하나를 실행합니까? evaluationId와 maximumClockRegression은 실제 Lua에 전달됩니까? timeout 뒤 결과는 어떻게 표현합니까?
현행 production 경로는 HTTP transport bridge부터 Redis Lua까지 조립됩니다. 그러나 계약에 있는 evaluation deduplication과 clock-regression 설정은 이 adapter가 소비하지 않습니다. 이 차이를 먼저 고정해야 코드를 과대평가하지 않습니다.
먼저 보는 클래스·리소스 지도
| 코드 | 입력 | 출력 | 다음 호출 |
|---|---|---|---|
RateLimitInterceptor.preHandle |
HTTP request | 통과 또는 typed outcome의 HTTP 응답 | EdgeRateLimitTransportBridge.evaluate |
EdgeRateLimitTransportBridge.evaluate |
raw HTTP subject | pseudonymous RateLimitRequest |
EdgeRateLimitPort.evaluate |
RedisEdgeRateLimitAdapter.evaluate |
policy, subject digest, cost, evaluation ID, deadline | Evaluated, Unavailable, Incompatible |
SCRIPT lane과 RateLimitScripts |
RateLimitKeys.counterKey |
policy ID/revision, subject digest | physical key | Lua KEYS[1] |
RateLimitScripts.evaluate |
policy parameters, cost, caller time | {allowed, remaining, resetAfterMillis} |
SCRIPT LOAD, EVALSHA |
RateLimitOutcome |
evaluation/failure | provider-neutral discriminated result | web response mapping |
객체 조립과 transport pseudonym
ca-skeleton.capabilities.rate-limit.provider=redis이고 Redis 전역 switch가 켜져 있으면 RedisCapabilityConfig.redisEdgeRateLimitPort가 bean을 만듭니다. 설정의 policy map을 RateLimitPolicy로 바꾸고, RateLimitKeys, 세 Lua를 가진 RateLimitScripts, Clock, command timeout, failure retry-after를 주입합니다. redisEdgeRateLimitPort
policy map이 비어 있거나 default policy ID가 map에 없으면 startup이 실패합니다. failure policy는 fail-closed만 허용됩니다. algorithm 문자열은 fixed-window, sliding-counter, token-bucket만 받습니다. policiesOf
HTTP 경계는 principal/API key/client IP와 route operation을 EdgeRateLimitSubject로 만든 뒤 VersionedEdgeSubjectPseudonymizer로 보냅니다. pseudonymizer는 subject kind, canonical identity, operation ID를 UTF-8 byte length로 framing해 HMAC delegate에 전달하고 v<version>:<digest>를 만듭니다. VersionedEdgeSubjectPseudonymizer.pseudonymize
bridge는 server-owned evaluation ID를 새로 만들고 caller deadline을 clock.instant() + budget으로 계산합니다. client가 보낸 Idempotency-Key나 rate-limit evaluation header는 사용하지 않습니다. cost는 HTTP bridge에서 1로 고정됩니다. EdgeRateLimitTransportBridge.evaluate
요청 시 호출 순서
sequenceDiagram
participant H as HTTP interceptor
participant B as Transport bridge
participant A as RedisEdgeRateLimitAdapter
participant L as RateLimitScripts
participant R as Redis
H->>B: evaluate(request)
B->>B: subject resolve + pseudonym + evaluationId
B->>A: RateLimitRequest(cost=1, deadline)
A->>A: policy/cost/deadline 검사
A->>L: evaluate(key, policy, cost, now)
L->>R: SCRIPT LOAD (digest miss)
L->>R: EVALSHA key args
alt NOSCRIPT
L->>R: SCRIPT LOAD
L->>R: EVALSHA 한 번 재시도
end
R-->>L: allowed, remaining, resetAfter
L-->>A: Evaluation
A-->>B: Evaluated / Unavailable / Incompatible
RedisEdgeRateLimitAdapter는 먼저 policy 존재 여부와 cost <= maximumCost를 검사합니다. 실패하면 Redis를 호출하지 않고 Incompatible(STATE_INCOMPATIBLE)을 반환합니다. caller deadline이 이미 지났으면 Unavailable(ADMISSION_REJECTED)입니다. 이후 SCRIPT lane을 빌리고 policy revision과 subject digest가 포함된 단일 counter key를 Lua에 넘깁니다. policy revision이 바뀌면 이전 counter와 새 counter가 섞이지 않습니다. RateLimitKeys
세 Lua가 읽고 쓰는 상태
fixed-window
FIXED_WINDOW는 windowStart를 계산하고 hash field 이름으로 씁니다.
HGET key <windowStart>로 현재 소비량을 읽습니다.current + cost > limit이면 mutation 없이 deny합니다.- 허용이면
HSET으로 소비량을 쓰고PEXPIRE key windowMillis*2를 설정합니다. - 반환값은 allow flag, 남은 budget, 현재 window 끝까지의 milliseconds입니다.
고정 window 경계가 바뀌면 새 field를 사용하므로 budget이 복구됩니다. key TTL은 매 hit마다 다시 설정되지만 두 window 길이로 제한됩니다.
sliding-counter
SLIDING_COUNTER는 current window와 previous window를 HGET으로 읽습니다. 이전 window 사용량에 남은 비율을 곱하고 math.floor한 뒤 current를 더합니다.
- estimated consumption에 cost를 더해 limit을 넘으면 deny합니다.
- 허용이면 current field만
HSET합니다. - 두 window 전 field를
HDEL하고 key에windowMillis*3TTL을 둡니다. - 이 방식은 exact sliding log가 아니므로 decision certainty가
APPROXIMATE_ALGORITHM입니다.
token-bucket
TOKEN_BUCKET는 hash의 tokens, updatedAt을 HMGET합니다.
- 상태가 없으면 full capacity와 현재 시각으로 시작합니다.
- 지난 whole refill period 수만큼 token을 보충합니다.
- 부족해도 상태와 TTL을
HSET/PEXPIRE한 뒤 deny합니다. - 충분하면 cost를 빼고 같은 방식으로 저장합니다.
- stored timestamp는 whole period만 전진하므로 partial period를 버리지 않습니다.
세 script 모두 caller Clock의 epoch milliseconds를 ARGV로 받으며 Redis TIME은 호출하지 않습니다. 다만 이 사실만으로 clock regression bound가 적용되는 것은 아닙니다.
script 등록과 NOSCRIPT 복구
각 algorithm은 process-local AtomicReference<String>에 SHA digest를 cache합니다. digest가 없으면 SCRIPT LOAD에 해당하는 gateway.loadScript를 먼저 호출하고, 이후 evaluateRegisteredForList로 EVALSHA를 보냅니다. run
failure message가 NOSCRIPT로 시작할 때만 digest cache를 비우고 load 후 EVALSHA를 한 번 더 보냅니다. NOSCRIPT는 script가 실행되지 않았다는 서버 응답이므로 이 재시도는 ambiguous mutation 재시도와 다릅니다. 그 외 exception은 그대로 올립니다.
정상·거절·ambiguous 분기
정상 reply는 세 값 이상이어야 합니다. 부족하거나 예상하지 못한 type이면 decoder가 IllegalStateException을 던지고 adapter catch-all에서 Unavailable(NO_MUTATION_CONFIRMED)가 됩니다. evaluationOf
정상 evaluation은 RateLimitDecision으로 변환됩니다. allowed이면 retry-after는 0, denied이면 최소 1ms입니다. sliding counter만 approximate이고 나머지는 certain입니다. decisionOf
실패는 모두 fail-closed typed outcome입니다.
- unknown policy/oversized cost:
Incompatible(STATE_INCOMPATIBLE) - expired caller deadline:
Unavailable(ADMISSION_REJECTED) - non-ambiguous
RedisOperationException:Unavailable(UNAVAILABLE_BEFORE_SEND) - ambiguous metadata, interruption, timeout, 알 수 없는 exception:
Unavailable(NO_MUTATION_CONFIRMED)
이 adapter는 RateLimitOutcome.Indeterminate를 반환하지 않습니다. mutation 여부가 불확실해도 UnavailableCategory.NO_MUTATION_CONFIRMED라는 이름을 사용합니다. 따라서 이 category 이름을 “mutation이 없다고 확인됨”으로 해석하면 안 됩니다. 구현 주석은 ambiguous call이 budget을 소비했을 수 있다고 설명합니다. RedisOperationException catch
설정·계약이 있지만 소비되지 않는 두 항목
RateLimitPolicy는 기본적으로 RateLimitEvaluationDedupPolicy.enabledDefaults()를 넣습니다. 기본은 TTL 5초, 최대 256 entries, 논리 stored bytes 65,536입니다. RateLimitEvaluationDedupPolicy.enabledDefaults
그러나 RedisEdgeRateLimitAdapter와 RateLimitScripts는 request.evaluationId()나 policy.evaluationDedupPolicy()를 읽지 않습니다. Lua key와 ARGV에도 evaluation ID가 없습니다. response-loss retry dedupe는 현재 구현되지 않았습니다. live test의 “port de-duplicates repeats” 주석도 현행 production body와 맞지 않는 historical/drift 문구입니다. LiveRedisSemanticPortsTest.request
RateLimitPolicy.maximumClockRegression도 validation되며 bootstrap 설정에서 채워집니다. 하지만 scripts에 전달되지 않습니다. token bucket은 updatedAt > now이면 stored timestamp를 지금으로 낮출 뿐 bound를 비교하거나 CLOCK_UNSAFE를 반환하지 않습니다. RateLimitOutcome.UnavailableCategory.CLOCK_UNSAFE는 type에 있으나 adapter에서 생성되지 않습니다.
테스트가 고정하는 계약
RedisEdgeRateLimitAdapterTest는 fixed limit, 새 window, sliding approximate 표시, token refill, unreachable fail-closed, unknown policy, oversized cost, deadline과 subject isolation을 in-memory gateway에서 검사합니다.EdgeRateLimitProviderNeutralContractTest는 세 portable algorithm과 bounded pseudonymous request를 고정합니다. dedupe 실행을 검증하지는 않습니다.EdgeRateLimitTransportBridgeTest는 raw subject가 port를 넘지 않고 server-generated evaluation ID와 750ms deadline이 전달됨을 확인합니다.LiveRedisSemanticPortsTest.theRateLimiterEnforcesUnderTheAdvancedAccount는 standalone/cluster lane에서 advanced account로 세 번 허용 후 deny되는 fixed window를 검증하도록 태그되어 있습니다.RedisTopologyContractTest.scriptPathIsAdvancedOnly는 advanced account만EVALSHA를 실행하고EVAL은 누구에게도 열지 않는 ACL 계약을 real server에 묻습니다.
현재 한계와 다음 source 순서
- evaluation ID 생성과 bounded dedupe policy type은 있지만 Redis state/Lua가 이를 소비하지 않습니다.
maximumClockRegression과CLOCK_UNSAFE도 설정·type만 있고 실행 경로가 소비하지 않습니다.- Lua의 TTL 식은 policy의
cleanupGrace를 사용하지 않습니다. validation에는 포함되지만 script ARGV에는 전달되지 않습니다. - 실패는 fail-closed이지만 ambiguous mutation을
Indeterminate로 분리하지 않습니다. - 이번 작성에서는 real-server topology lane을 재실행하지 않았습니다.
source는 transport bridge → adapter → scripts → adapter test → live semantic test 순으로 읽는 편이 호출 경계를 가장 빨리 드러냅니다.
시리즈에서 이어 읽기
- 전체 흐름: 「Redis를 범용 클라이언트가 아니라 정책 경계로 다루기」
- 운영 흐름: 「Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지」
Redis Lease는 왜 Lock이 아닌가: Acquire·Renew·Release 코드 읽기
이 글이 답하는 코드 질문
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 |
operation ID, lease request, inspection request | attempt, acquire/inspect outcome | Redis adapter |
LeaseRequest |
purpose, resource digest, wait timeout, TTL, attempt | bounded request | tryAcquire |
RedisDistributedLeaseAdapter.tryAcquire |
request | acquired/replayed/contended/conflict/indeterminate | acquire Lua |
LeaseScripts |
key, ownerToken:operationId, TTL |
status, PTTL, holder | SCRIPT LOAD, EVALSHA |
RedisLeaseHandle |
confirmed ownership | local validity와 ACTIVE/LOST/RELEASED/UNKNOWN | renew/release Lua |
LeaseWatchdog |
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
기본값은 command timeout 200ms, contention retry-after 50ms, drift budget 10ms입니다. RedisCapabilitySettings.Lease
resource의 raw ID는 port contract가 허용하지 않습니다. resourceDigest는 versioned lowercase SHA-256 형태로 validation되고 Redis key는 namespace/capability lease/key version/purpose/digest 아래에 생깁니다. LeaseKeys.leaseKey
attempt를 send 전에 만드는 이유
caller는 첫 provider call 전에 newAttempt(operationId)를 호출합니다. adapter는 SecureRandom 24바이트를 Base64URL without padding으로 바꿔 owner token을 만들고 caller operation ID와 묶습니다. newAttempt
Redis value는 ownerToken:operationId입니다. 같은 attempt를 유지하면 reply-loss 뒤 재호출을 새 acquisition과 구분할 수 있습니다. 같은 owner라도 operation ID가 다르면 이전 작업의 lease를 새 작업이 상속하지 못합니다.
acquire 호출 순서와 상태
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
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, handle
가령 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
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
local validity는 server PTTL이 아닙니다
status 1로 새 lease를 만든 acquisition handle의 grantedValidity는 leaseTtl - driftBudget입니다. 이 계산은 status 2 replay에도 그대로 재사용되지만, replay에는 새 TTL이 부여되지 않았으므로 안전한 근거가 아닙니다. localValidityOf
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
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
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, renew
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
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를 반환합니다. 이 계약은 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
NOSCRIPT 이외의 exception은 adapter로 올라가 typed Indeterminate가 됩니다. acquire/renew/release는 mutation 가능성이 있으므로 clean failure로 바꾸지 않는 선택입니다. inspect는 read-only이지만 exception 역시 Indeterminate입니다.
테스트가 고정하는 계약
DistributedLeaseV2ContractTest는 bounded/redacted attempt, digest-only request, response-loss outcome,EFFICIENCY_ONLY, usable budget을 provider-neutral type 수준에서 검사합니다.RedisDistributedLeaseAdapterTest는 uncontended acquire, contention, same-operation replay, operation conflict, renew, local expiry, release, inspection과 unreachable indeterminate를 in-memory gateway로 고정합니다.theSameClaimReplays는 outcome type만 검사합니다. replay handle의 remaining validity가 reply PTTL 이하인지 확인하지 않습니다.- 같은 테스트의
anExpiredBudgetIsLost는 server call 없이 monotonic budget만으로 LOST가 반환됨을 검사합니다. 그 뒤 renew하거나 UNKNOWN 뒤 renew하는 경로는 없습니다. LeaseWatchdogTest는 registration bound와 indeterminate renew 시 cancel/lost callback을 고정합니다.LiveRedisSemanticPortsTest.theLeaseIsExclusiveUnderTheAdvancedAccount는 standalone/cluster real-server lane에서 한 holder만 acquire하고 두 번째는 contended이며 release가 성공하는 흐름을 검사하도록 태그되어 있습니다.RedisCapabilityCompositionTest.leaseProviderComposesThePort는 selector가 port bean을 만드는지만 확인하며 서버에는 연결하지 않습니다.
현재 한계와 다음 source 순서
- fencing token이 없으므로 domain correctness lock이 아닙니다.
waitTimeout은 request validation에는 있지만 Redis adapter가 소비하지 않습니다. wait loop가 없습니다.- same-attempt replay는 reply PTTL을 버리고 요청 TTL로 local budget을 다시 만듭니다. replay handle이 실제 Redis lease보다 오래 ACTIVE라고 판단할 수 있으며 이를 막는 regression test가 없습니다.
state()의 local-expiry LOST는 field에 저장되지 않고,renew는 state precondition 없이 실행됩니다. 성공해도 field를 ACTIVE로 복구하지 않아Renewedoutcome과 UNKNOWN/LOST/RELEASED state가 함께 남을 수 있습니다.- adapter는 before-send unavailable과 after-send ambiguous를 구분하지 않고 대부분
Indeterminate로 보냅니다. port에 있는Unavailable·Overloadedvariant는 이 adapter에서 생성되지 않습니다. observedServerExpiry는 acquire reply의 PTTL을 반영하지 않습니다. replay에서는 진단값도 server expiry보다 길 수 있습니다.- watchdog은 구현·unit test되어 있지만 production bean 조립은 확인되지 않습니다.
- 이번 작성에서는 real-server lane을 재실행하지 않았습니다.
DistributedLeasePort → adapter tryAcquire → 네 Lua → inner handle → adapter test 순으로 읽으면 owner identity와 certainty 경계를 놓치지 않습니다.
시리즈에서 이어 읽기
- 전체 흐름: 「Redis를 범용 클라이언트가 아니라 정책 경계로 다루기」
- 운영 흐름: 「Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지」
Redis Idempotency V2 상태 머신: Claim에서 Replay까지
이 글이 답하는 코드 질문
Redis Idempotency V2는 같은 scope에서 action을 언제 실행하고, 어떤 owner/revision으로 stale writer를 막으며, lost reply를 어떻게 reconcile합니까? 이 lifecycle이 exactly-once를 보장합니까? HTTP의 Idempotency-Key가 현재 V2 executor까지 연결됩니까?
production composition은 Redis IdempotencyStorePortV2와 IdempotencyExecutorV2를 함께 만듭니다. 그러나 inbound web helper는 V1 IdempotencyScope와 V1 executor용 입력을 만들며 V2 IdempotencyScopeDigest bridge는 확인되지 않습니다. V2 backend가 조립됐다는 사실과 HTTP 요청이 그 backend를 호출한다는 사실은 다릅니다. backend 내부에도 같은 retained attempt가 이미 EXECUTING인 record를 다시 만나면 action을 다시 호출할 수 있는 경로가 있고, Redis V2 renew는 성공처럼 보이는 no-op입니다.
먼저 보는 클래스 지도
| 코드 | 입력 | 출력 | 다음 호출 |
|---|---|---|---|
IdempotencyStorePortV2 |
digested scope, fingerprint, attempt, owner, TTL | claim/mutation/inspection outcome | provider adapter |
IdempotencyExecutorV2.execute |
scope digest, fingerprint, retained attempt, action, codec | action result 또는 replay | claim→start→action→complete |
RedisIdempotencyStoreAdapter |
V2 store calls | provider-neutral typed outcome | SCRIPT lane과 Lua |
IdempotencyScripts |
hash key와 transition args | 6-field reply | claim/transition/release/inspect |
IdempotencyScopeDigest |
lowercase SHA-256, digest version, operation code | opaque scope | physical Redis key |
IdempotencyKeySupport |
HTTP header/principal/body | V1 scope와 fingerprint | 현재 V1 경계 |
production 조립과 selection guard
ca-skeleton.capabilities.idempotency.provider=redis일 때 RedisCapabilityConfig는 store와 executor bean을 각각 만듭니다. store에는 namespace, key version, scripts, clock, command timeout을 넣고 executor에는 processing lease, replay TTL, failure retention, response codec ID, policy revision을 넣습니다. redisIdempotencyStore, idempotencyExecutorV2
기본값은 command timeout 200ms, processing lease 30초, replay TTL 24시간, failure retention 24시간, codec json-v2, policy revision 2입니다. RedisCapabilitySettings.Idempotency
IdempotencyProviderSelectionConfig는 provider가 REDIS일 때 V1 store/executor 0개, owner-safe V2 store/executor 각각 1개인지 startup에 검사합니다. 과거에는 같은 이름의 다른 V2 contract를 세어 Redis selection이 불완전하다고 실패하던 문제가 있었고, 현행은 실제 provider가 구현한 application.idempotency.v2 contract를 셉니다. idempotencyProviderExclusivity
Redis record와 scope key
physical key에는 raw client key나 principal이 들어가지 않습니다. IdempotencyKeys.recordKey는 공통 namespace 아래 idem, key layout version, d<scope.keyDigestVersion>, operation code, 64자 digest를 렌더링합니다. recordKey
record는 Redis hash입니다. claim script가 만드는 주요 field는 다음과 같습니다.
| field | 의미 |
|---|---|
state |
CLAIMED, EXECUTING, COMPLETED, FAILED_RETRYABLE, ABANDONED |
owner |
32 random bytes를 lowercase hex로 바꾼 64자 token |
attempt |
takeover마다 증가하는 claim attempt number |
rev |
confirmed transition마다 증가하는 state revision |
op |
caller의 OperationId |
fp |
request fingerprint |
codec / policy |
response codec ID와 policy revision |
leaseUntil |
processing lease absolute epoch millis |
resp |
completed response의 opaque payload |
hash를 쓰는 이유는 transition이 필요한 field만 owner/revision check와 함께 바꾸기 위해서입니다. serialized blob을 client에서 read-modify-write하지 않습니다.
전체 실행 순서
sequenceDiagram
participant C as Caller
participant E as IdempotencyExecutorV2
participant S as RedisIdempotencyStoreAdapter
participant R as Redis Lua/hash
participant A as Action
C->>E: execute(scope, fingerprint, attempt, action, codec)
E->>S: claim(request)
S->>R: CLAIM EVALSHA
alt completed
R-->>S: COMPLETED_REPLAY + resp
S-->>E: CompletedReplay
E-->>C: codec.deserialize(resp)
else acquired/taken over from CLAIMED
S-->>E: owner(attempt, rev)
E->>S: markExecutionStarted(owner)
S->>R: CLAIMED -> EXECUTING CAS
R-->>S: advanced owner revision
E->>A: run()
A-->>E: Success / RetryableNoEffect / EffectUnknown
E->>S: complete 또는 markFailed
S->>R: EXECUTING -> terminal CAS
E-->>C: result 또는 typed exception
else same attempt, record already EXECUTING
S-->>E: ReplayedAcquire (state 구분 없음)
E->>S: markExecutionStarted(owner)
S->>R: current EXECUTING, target EXECUTING
R-->>S: ALREADY (mutation 없음)
E->>A: run() 다시 호출
else claim response uncertain
E->>S: inspect(same attempt)
S->>R: INSPECT EVALSHA
alt EXECUTING_SAME_OPERATION
E->>A: run() 호출
else other observation
E->>E: resume/replay/recovery
end
end
claim Lua의 분기
CLAIM는 먼저 HGET state를 읽습니다.
- record가 없으면
CLAIMED, owner, attempt 1, rev 1, operation, fingerprint, codec, policy, leaseUntil을HSET하고 replay TTL로PEXPIRE합니다.ACQUIRED입니다. - fingerprint가 다르면 어떤 mutation보다 먼저
FINGERPRINT_MISMATCH를 반환합니다. COMPLETED이면 response와 PTTL을 담은COMPLETED_REPLAY입니다.ABANDONED면RECOVERY_REQUIRED입니다.- owner와 operation이 모두 같으면 현재 state가
CLAIMED인지EXECUTING인지 구분하지 않고 lost claim reply의 재호출로 보고REPLAYED_ACQUIRE입니다. - owner만 같고 operation이 다르면
OWNER_OPERATION_CONFLICT입니다. FAILED_RETRYABLE이거나 leaseUntil이 지났으면 owner를 교체하고 attempt/rev를 1씩 올려TAKEN_OVER를 반환합니다.- 그 밖에는
IN_PROGRESS와 남은 시간을 반환합니다.
adapter는 newClaimAttempt에서 owner token을 send 전에 만듭니다. claim exception은 전부 Indeterminate(operationId)입니다. clean unavailable이라고 하면 caller가 새 attempt로 action을 중복 실행할 수 있기 때문입니다. claim
owner와 state revision이 함께 필요한 이유
generic TRANSITION은 다음 순서로 비교합니다.
- record 존재
- owner token 일치
- operation ID 일치
- 이미 target state이면
ALREADY - state revision 일치
- expected source state 일치
HSET state,rev+1, optional response/leaseUntil과 optionalPEXPIRE
target state 확인이 revision보다 먼저인 점이 중요합니다. 첫 transition은 적용됐지만 reply가 사라진 caller는 이전 revision을 들고 같은 transition을 다시 보냅니다. owner·operation·target이 같다면 ALREADY로 복구합니다. 반대로 target이 다르고 revision이 오래됐으면 NOT_OWNER입니다. 이 순서는 start 같은 서로 다른 상태 전이의 lost reply를 복구하지만, source와 target이 같은 renew에는 다른 결과를 만듭니다.
confirmed start transition은 새 IdempotencyOwner를 돌려줍니다. executor는 claim에서 받은 owner를 계속 쓰지 않고 started.owner()의 advanced revision을 complete에 전달합니다. startAndRun
Redis store의 renew는 source와 target을 모두 EXECUTING으로 넘깁니다. record가 정상적인 EXECUTING 상태라면 Lua의 state == target 검사가 먼저 참이 되어 곧바로 ALREADY를 반환합니다. 뒤의 leaseUntil·TTL·revision mutation에는 도달하지 않습니다. adapter는 이를 ALREADY_RENEWED_SAME_OPERATION으로 매핑하고 현재 owner를 돌려주므로 호출자는 성공처럼 읽을 수 있지만 processing lease는 갱신되지 않습니다. RedisIdempotencyStoreAdapter.renew
flowchart LR
A[renew: EXECUTING → EXECUTING] --> B{state == target?}
B -->|yes| C[ALREADY]
C --> D[ALREADY_RENEWED_SAME_OPERATION]
C -.->|도달하지 않음| E[leaseUntil/TTL/revision mutation]
executor가 action을 실행하는 조건
execute는 claim outcome을 다음처럼 처리합니다.
CompletedReplay: action 없이 deserialize합니다.FingerprintMismatch:IdempotencyRequestMismatchException입니다.InProgress:IdempotencyInFlightException입니다.RecoveryRequired/OwnerOperationConflict: recovery required입니다.Unavailable:IdempotencyUnavailableException입니다.Indeterminate: 같은 attempt로 inspect합니다.Acquired/ReplayedAcquire/TakenOverClaimed: execution start를 먼저 confirm합니다.
action은 markExecutionStarted가 STARTED 또는 ALREADY_STARTED_SAME_OPERATION일 때만 실행됩니다. start가 indeterminate면 inspect로 CLAIMED_SAME_OPERATION, EXECUTING_SAME_OPERATION, COMPLETED_REPLAY 중 하나를 확인해 resume합니다. 두 번째에도 불확실하면 recovery required로 멈춥니다. resumeAfterIndeterminateStart
여기에는 동일 action을 다시 실행할 수 있는 두 경로가 있습니다. 첫째, record가 이미 EXECUTING인데 같은 retained attempt로 execute를 다시 호출하면 claim Lua가 state를 구분하지 않고 REPLAYED_ACQUIRE를 반환합니다. executor는 startAndRun으로 들어가고, EXECUTING -> EXECUTING start transition은 ALREADY가 됩니다. executor는 이를 confirmed start로 받아 runStarted에서 action을 다시 호출합니다. execute의 replay 분기, TRANSITION의 target 선검사
둘째, claim이나 start reply가 불확실한 뒤 inspect가 EXECUTING_SAME_OPERATION을 반환하면 executor는 곧바로 runStarted를 호출합니다. 이 관찰만으로는 앞선 action이 아직 실행 중인지, 실행 직전이었는지, 이미 effect를 냈는지 구분할 수 없습니다. 현행 코드는 이 상태를 재실행 권한으로 해석합니다. 따라서 owner·operation이 같다는 사실은 다른 owner를 막는 근거이지만, 같은 attempt의 두 Java invocation 사이에서 action을 한 번만 실행했다는 근거는 아닙니다. reconcile
success, retryable, unknown effect
action의 정상 반환은 세 종류입니다.
Success: response를 serialize하고EXECUTING -> COMPLETEDtransition을 보냅니다.RetryableNoEffect:FAILED_RETRYABLE로 기록해 다음 claim의 takeover를 허용합니다.EffectUnknown:ABANDONED로 남겨 자동 retry를 막습니다.
action이 분류 없이 RuntimeException을 던져도 executor는 unknown effect로 취급해 ABANDONED를 시도한 뒤 원래 exception을 다시 던집니다. runStarted
completion reply가 indeterminate면 inspect합니다. stored response가 방금 serialize한 payload와 같으면 성공으로 확정합니다. record가 여전히 같은 operation의 EXECUTING이면 현재 store가 준 owner로 complete를 한 번 더 시도합니다. stored response가 다르면 어느 결과도 반환하지 않고 recovery required입니다. reconcileCompletion
releaseBeforeExecution은 CLAIMED 상태에서 owner/revision/operation이 맞을 때만 DEL합니다. EXECUTING 이후 release는 거절합니다. 이미 effect가 시작된 record를 지우면 duplicate 방지 증거도 사라지기 때문입니다. RELEASE
NOSCRIPT와 failure certainty
claim/transition/release/inspect는 script별 digest를 cache하고 EVALSHA를 사용합니다. NOSCRIPT일 때만 reload 후 한 번 재시도합니다. IdempotencyScripts.run
claim과 mutation exception은 INDETERMINATE로 보존합니다. inspect exception은 UNAVAILABLE입니다. read-only inspect가 unavailable이면 executor도 새 action 실행을 추측하지 않고 unavailable/recovery로 멈춥니다.
exactly-once가 아닌 이유
exactly-once가 아닌 첫 이유는 같은 retained attempt의 재진입입니다. 앞서 본 REPLAYED_ACQUIRE 또는 EXECUTING_SAME_OPERATION 경로는 record가 이미 EXECUTING이어도 action을 다시 호출할 수 있습니다. 첫 action이 진행 중인 동안 같은 attempt로 두 번째 execute가 들어오는 경우를 state만으로 구분하지 못합니다.
두 번째 이유는 action의 외부 side effect와 Redis COMPLETED write가 하나의 transaction이 아니라는 점입니다. effect는 성공했지만 process가 죽어 completion을 기록하지 못하면 record는 EXECUTING lease expiry 뒤 takeover될 수 있습니다. action이 자신의 effect를 idempotent하게 만들거나 effect-point conditional write/outbox 등 별도 경계를 갖지 않으면 cross-store exactly-once도 성립하지 않습니다.
코드도 이 점을 명시합니다. action은 confirmed start 뒤 실행되지만 “cross-store exactly-once boundary”를 만들지 않습니다. IdempotencyExecutorV2 class contract
또한 executor는 store.renew를 호출하지 않습니다. long-running action의 processing lease를 자동 연장하지 않습니다. 이 미사용 공백과 별개로, 누군가 port의 Redis renew를 직접 호출해도 앞서 설명한 target-state short-circuit 때문에 현재 mutation은 no-op입니다.
inbound V1 bridge 공백
web의 IdempotencyKeySupport는 header를 trim하고 principal + raw key + use-case name으로 V1 IdempotencyScope를 만들며 body fingerprint와 JSON codec을 제공합니다. IdempotencyScopeDigest를 만들지 않고 IdempotencyExecutorV2도 참조하지 않습니다. production controller에서 이 helper 사용처도 검색되지 않습니다.
그러므로 Redis V2 store/executor bean 조립과 HTTP idempotency 적용을 같은 것으로 설명할 수 없습니다. 필요한 bridge는 raw scope를 versioned HMAC digest로 바꾸고 OperationId와 retained V2 attempt를 생성해 executor에 전달해야 하지만, 현행 production source에서는 확인되지 않습니다.
테스트가 고정하는 계약
IdempotencyV2ContractTest는 lowercase digest와 positive version, 분리된 processing/replay TTL, owner의 attempt/revision tuple을 검사합니다.IdempotencyExecutorV2Test는 confirmed start 이후 action 실행, advanced owner 전달, lost claim/start/completion reconciliation, conflicting replay, retryable와 unknown effect 분리를 fake store로 고정합니다.- 같은 테스트의
anIndeterminateClaimIsReconciled는 inspection이EXECUTING_SAME_OPERATION이면 start를 다시 호출하지 않지만 action은 실제로 실행한다고 assert합니다. 이 상태가 in-flight인지 resume 가능한 상태인지 구분하지 않습니다. - 같은 retained attempt로
execute를 두 번 호출해 action 중복 여부를 검사하는 concurrency/retry test는 없습니다. RedisIdempotencyStoreAdapterTest는 exclusion, replay, fingerprint mismatch, full happy path, stale owner 거절, response conflict, retryable takeover, abandoned recovery, pre-execution release와 lost reply inspection을 in-memory gateway로 검사합니다.- Redis adapter test에는 renew case가 없습니다. executor test의 fake store renew는
UnsupportedOperationException을 던져 executor가 renew를 호출하지 않는다는 사실만 고정합니다. LiveRedisSemanticPortsTest.theIdempotencyStoreClaimsOnceUnderTheAdvancedAccount는 standalone/cluster lane에서 첫 claim과 두 번째 in-progress를 검사하도록 태그되어 있습니다. 전체 executor lifecycle real-server 검증은 아닙니다.RedisCapabilityCompositionTest.idempotencyProviderComposesTheStore는 V2 store bean을 검사합니다. selector guard는 executor까지 요구하지만 이 test method 자체는 executor를 assert하지 않습니다.
현재 한계와 다음 source 순서
- 같은 retained attempt가 이미 EXECUTING인 record를 다시 만나면 executor가 action을 다시 호출할 수 있습니다.
EXECUTING_SAME_OPERATION은 in-flight evidence와 resume 권한을 구분하지 못합니다. - Redis V2
renew는EXECUTING -> EXECUTINGtarget 선검사에서ALREADY로 끝나leaseUntil, TTL, revision을 바꾸지 않는 no-op입니다. adapter renew test도 없습니다. - HTTP V1 helper에서 V2 scope digest/attempt/executor로 가는 production bridge가 확인되지 않습니다.
- Redis state와 외부 side effect 사이의 exactly-once transaction은 없습니다.
- executor는 processing lease renew를 호출하지 않습니다.
markFailed결과가 indeterminate여도preserveUnknown은 결과를 확인하지 않고 원래 exception을 던집니다. recovery evidence가 실제로 기록됐는지는 별도 reconciliation이 필요할 수 있습니다.- response는 opaque string payload이며 codec migration compatibility를 store가 검증하지 않습니다. hash에는 codec/policy가 기록되지만 claim/replay script가 현재 배포 값과 비교하지 않습니다.
- 이번 작성에서는 real-server lane을 재실행하지 않았습니다.
executor execute → claim Lua → generic transition Lua → store mapping → executor test → adapter test 순으로 읽으면 상태와 certainty를 함께 추적할 수 있습니다.
시리즈에서 이어 읽기
- 전체 흐름: 「Redis를 범용 클라이언트가 아니라 정책 경계로 다루기」
- 운영 흐름: 「Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지」
Redis Session 요청은 어디에서 멈추는가: Web 설정과 미완성 Repository
이 글이 답하는 코드 질문
ca-skeleton.security.auth-mode=redis-session으로 설정하면 어떤 web/security 객체가 생기며, HTTP session은 실제로 Redis에 저장됩니까? startup validator가 요구하는 redisVersionedSessionRepository는 어디에 구현되어 있습니까?
현행 답은 두 경계에서 멈춥니다. cookie, Spring Session filter activation annotation, primitive security-context repository, stateful session policy branch는 구현되어 있습니다. 그러나 production SessionRepository bean, 이름이 redisVersionedSessionRepository인 bean, Redis session adapter는 source에서 확인되지 않습니다. 별도로, 인증 snapshot이 없는 요청에서 최초 Authentication을 만드는 form login, HTTP Basic, custom authentication filter나 production login endpoint도 확인되지 않습니다. 따라서 Redis Session capability는 미완성이고 semantic capability composition은 cache/rate-limit/lease/idempotency V2의 4/5입니다.
먼저 보는 클래스 지도
| 코드 | 입력 | 출력 | 다음 호출 |
|---|---|---|---|
RedisSessionWebConfig |
auth-mode와 cookie settings | CookieSerializer, Spring Session filter configuration |
필요한 SessionRepository bean |
SecurityConfig.filterChain |
auth mode, error handlers, context repository | JWT stateless 또는 session stateful chain | Spring Security filters |
PrimitiveSessionSecurityContextRepository |
SecurityContext, HttpSession |
bounded byte snapshot 또는 empty context | session attribute |
AuthenticationModeCompositionConfig |
auth-mode, bean registry | startup pass/fail | 없음 |
RedisActivationValidator |
global switch와 role selectors | startup pass/fail | 없음 |
객체 조립에서 먼저 걸리는 두 validator
RedisActivationValidator는 auth mode redis-session을 Redis-selecting role로 등록합니다. app.redis.enabled=false인데 이 mode를 선택하면 startup에 모순으로 거절합니다. role selector가 Redis를 자동 활성화하지는 않습니다. REDIS_SELECTING_VALUES
그 다음 AuthenticationModeCompositionConfig는 bean 이름으로 완성도를 검사합니다.
- JWT mode:
jwtDecoder는 있어야 하고 session repository/filter는 없어야 합니다. - REDIS_SESSION mode:
jwtDecoder는 없어야 하고redisVersionedSessionRepository,springSessionRepositoryFilter가 둘 다 있어야 합니다.
검사는 type이 아니라 containsBean 이름입니다. validate
문제는 production source 전체에서 redisVersionedSessionRepository를 만드는 @Bean이나 SessionRepository 구현이 확인되지 않는다는 점입니다. 검색 결과는 validator와 그 unit test의 fake bean뿐입니다. 따라서 mode를 실제로 선택하면 web 설정이 활성화되더라도 composition validator가 repository와 filter가 갖춰지지 않았다고 판단해 startup을 거절하는 것이 현행 의도에 가까운 결과입니다.
web 설정이 제공하는 것
RedisSessionWebConfig는 auth mode가 redis-session일 때만 활성화됩니다. @EnableSpringHttpSession은 Spring Session filter infrastructure를 import하지만, filter를 만들려면 SessionRepository bean이 필요합니다. 이 configuration 자체는 repository를 만들지 않습니다. RedisSessionWebConfig
이 config의 유일한 explicit bean은 CookieSerializer입니다. 설정에서 cookie name, Secure, HttpOnly, SameSite, path를 읽고 max age -1, Base64 encoding을 적용합니다. domain/domain pattern을 지정하지 않으므로 host-only cookie입니다. cookie가 안전하게 구성됐다는 사실은 session data가 Redis에 저장된다는 증거가 아닙니다.
adapter:inbound:web은 spring-session-core만 의존합니다. Redis store 구현을 제공하는 Spring Data Redis dependency는 이 module에 없습니다. adapter/inbound/web/build.gradle
SecurityFilterChain의 mode 분기
flowchart TD
A[SecurityConfig.filterChain] --> B{authMode}
B -->|JWT| C[CSRF disabled]
C --> D[STATELESS]
D --> E[Bearer filter + JWT converter가 Authentication 생성]
B -->|REDIS_SESSION| F[Cookie CSRF repository]
F --> G[IF_REQUIRED + migrateSession]
G --> H[PrimitiveSecurityContext load/save]
G --> M{최초 Authentication mechanism?}
M -->|production source| N[form/basic/custom filter·login endpoint 미확인]
M -->|test 전용 controller| O[SecurityContext에 직접 설정]
O -.->|저장 대상 제공| H
H --> I[HttpSession primitive byte attribute]
I --> J[springSessionRepositoryFilter]
J --> K{SessionRepository bean?}
K -->|production source에서 없음| L[startup composition incomplete]
K -->|test MapSessionRepository| P[in-memory persistence]
JWT branch는 CSRF를 끄고 SessionCreationPolicy.STATELESS와 resource-server JWT converter를 설정합니다. session branch는 CSRF cookie/header, IF_REQUIRED, session fixation migration을 설정하고 PrimitiveSessionSecurityContextRepository를 Spring Security의 context repository로 지정합니다. SecurityConfig.filterChain
session CSRF cookie는 secure true, httpOnly false, configured SameSite/path입니다. JavaScript가 token을 읽어 header로 돌려보내는 double-submit 형태이므로 session ID cookie의 HttpOnly와 목적이 다릅니다.
PrimitiveSessionSecurityContextRepository bean도 auth mode 조건부입니다. session branch에서 ObjectProvider.getObject()를 호출하므로 mode는 session인데 bean이 없다면 filter chain 생성 자체가 실패합니다. 현행 조건은 같은 property를 쓰므로 정상적으로 함께 활성화됩니다.
session mode의 세 층은 서로 다른 책임입니다
첫째, springSessionRepositoryFilter와 SessionRepository는 HttpSession을 provider storage에 저장하고 다시 읽습니다. 이 filter는 session persistence filter이지 사용자를 인증하는 filter가 아닙니다.
둘째, PrimitiveSessionSecurityContextRepository는 이미 존재하는 authenticated context를 bounded bytes로 저장하고, 다음 요청에서 그 snapshot을 Authentication으로 복원합니다. 기존 snapshot을 복원할 수 있다는 사실은 최초 snapshot을 만들 수 있다는 뜻이 아닙니다. load
셋째, 인증 snapshot이 없는 요청에서는 credential이나 외부 identity를 검증해 최초 Authentication을 만드는 mechanism이 필요합니다. JWT branch는 oauth2ResourceServer와 JWT converter를 설정하지만 Redis-session branch는 CSRF, IF_REQUIRED, fixation migration, context repository만 설정합니다. formLogin, httpBasic, custom authentication filter, production login endpoint는 production source에서 확인되지 않습니다. SecurityConfig의 두 mode 분기
따라서 redisVersionedSessionRepository만 추가해 validator를 통과하더라도 persistence 조립만 채워집니다. 최초 인증 조립은 별도 공백으로 남습니다.
primitive snapshot의 저장 형식
이 repository는 Spring Security의 SecurityContext object graph를 session에 그대로 넣지 않습니다. attribute 이름은 dev.caskeleton.security.PRIMITIVE_SECURITY_CONTEXT_V1이고 값은 byte[]입니다. SNAPSHOT_ATTRIBUTE
binary layout은 다음 순서입니다.
- magic
0x43534543 - version 1
- length-prefixed principal ID
- nullable email
- role count와 정렬된 role strings
- authority count와 정렬된 authority strings
credential은 저장하지 않습니다. principal은 AuthenticatedPrincipal만 허용합니다. 전체 snapshot은 16,384 bytes, principal 256 UTF-8 bytes, email 320 bytes, token 128 bytes, roles 64개, authorities 128개로 제한됩니다. encode
load할 때 magic/version/길이/count/중복/trailing bytes를 검사합니다. 손상되거나 incompatible하면 exception을 밖으로 내보내지 않고 attribute를 삭제한 뒤 empty context를 반환합니다. 즉 corrupt session authentication은 authenticated로 복구되지 않습니다. load
request-time save와 load 순서
sequenceDiagram
participant F as SecurityContext filter
participant P as Primitive repository
participant H as HttpSession
participant S as Spring Session filter
participant X as SessionRepository
F->>P: loadContext(holder)
P->>H: getSession(false), get snapshot
P-->>F: decoded authentication 또는 empty
Note over P,F: response/request wrapper 설치
F->>P: saveContext(final context)
alt authenticated AuthenticatedPrincipal
P->>H: getSession(true), set byte[]
else empty/anonymous
P->>H: remove attribute if session exists
end
H->>S: session mutation
S->>X: save session
Note over X: production Redis repository는 확인되지 않음
loadContext는 response에 CommitSaveResponseWrapper를 씌웁니다. response가 commit될 때 현재 context를 저장하되, 이후 explicit final save가 빈 context면 앞서 저장한 snapshot을 제거합니다. async가 시작되면 commit hook 저장을 끄고 final save까지 미룹니다. CommitSaveResponseWrapper
인증이 없거나 anonymous면 기존 session을 새로 만들지 않고 attribute만 제거합니다. 인증된 context면 getSession(true)로 session을 만들고 bytes를 저장합니다. 이 시점의 HttpSession을 어느 backend에 persist할지는 Spring Session SessionRepository의 책임입니다.
정상과 실패 분기
구현된 web 경계의 정상 분기는 다음과 같습니다.
- JWT mode에는 session cookie serializer/filter가 생기지 않습니다.
- Redis-session mode에서 repository가 제공되면 Spring Session filter와 cookie serializer가 생깁니다. 이것만으로 새 사용자의 최초 인증이 생기지는 않습니다.
- authenticated primitive principal은 credential 없이 round-trip합니다.
- empty/anonymous context는 snapshot을 제거합니다.
- corrupt snapshot은 제거하고 unauthenticated 상태로 처리합니다.
- foreign principal graph, oversized authority count, control character·byte bound 위반은 save 시
IllegalArgumentException입니다.
현재 production 조립 실패는 Redis timeout이나 ambiguous write보다 앞에 있습니다. Redis로 session command를 보내는 repository 자체가 없으므로 Redis 명령, TTL, envelope/version migration, touch/save/delete certainty를 분석할 production code도 없습니다. repository를 보완한 뒤에도 최초 인증 mechanism이 없으면 새 unauthenticated 요청은 anyRequest().authenticated()에서 인증 entry point로 갈 뿐, 저장할 authenticated context를 만들지 못합니다.
PrimitiveSessionSecurityContextRepository의 이름에 Redis가 없다는 점도 중요합니다. 이 객체는 HttpSession attribute의 내용과 lifecycle만 소유하며 provider storage를 소유하지 않습니다.
테스트가 고정하는 계약
RedisSessionWebConfigTest.jwtModeCreatesNoSessionFilterOrCookieSerializer는 JWT에서 web session infrastructure가 비활성임을 검사합니다.- 같은 test의
redisSessionModeWritesSecureHttpOnlySameSiteHostOnlyCookie는 test가 직접MapSessionRepository를 제공한 뒤 cookie flags와 host-only 속성을 확인합니다. Redis repository 검증이 아닙니다. PrimitiveSessionSecurityContextRepositoryTest는 primitive bytes round-trip과 credential/framework-object 배제를 검사합니다.- 같은 test의 commit/final/async cases는 response commit 전에 session 생성이 필요한 경우와 최종 context가 앞선 snapshot을 교체·삭제하는 순서를 고정합니다.
savesThePrimitiveSnapshotBeforeAResponseCommitRequiresANewSession - corrupt/foreign test는 손상 bytes를 empty authentication으로 만들고 attribute를 제거하며 foreign principal save를 거절합니다.
rejectsForeignPrincipalGraphsAndFailsClosedOnCorruptSnapshots SecurityModeWebContractTest.redisSessionSecurityFilterPersistsAndRestoresOnlyThePrimitiveAuthenticationSnapshot는 primitive snapshot round-trip을 검사합니다. 하지만 최초 인증은 test 전용/login-testcontroller가SecurityContextHolder에 authenticated token을 직접 넣어 만듭니다.loginForContractAuthenticationModeCompositionConfigTest는 이름만 가진 fake repository/filter bean으로 exclusive composition rule을 검사합니다. repository 기능을 입증하지 않습니다.
현재 구현 공백과 잘못 읽기 쉬운 지점
redisVersionedSessionRepositoryproduction bean 또는 구현은 확인되지 않습니다.- Redis session record의 key, value envelope, session TTL, save/touch/delete command나 Lua도 production source에 없습니다.
- 인증 snapshot이 없는 요청에서 최초
Authentication을 만드는 production mechanism도 확인되지 않습니다. repository를 추가하는 것만으로 Redis Session 인증 mode가 완성되지 않습니다. - 따라서 Redis failure의 unavailable/indeterminate 분기와 session fail-closed 정책을 실행 코드 수준에서 확인할 수 없습니다.
@EnableSpringHttpSession은 repository 구현이 아닙니다. test는MapSessionRepository를 주입해 filter/cookie 조립만 확인합니다.PrimitiveSessionSecurityContextRepository는 이미 만들어진 security snapshot의 serializer/load-save 경계이며 provider repository나 최초 인증 mechanism이 아닙니다.- composition validator가 요구하는 bean 이름은 contract 역할을 하지만 type, 기능, 최초 인증 경로를 검사하지는 않습니다.
- semantic capability 5개 중 production adapter가 조립되는 것은 cache, rate-limit, lease, idempotency V2의 4개입니다. Session은 미완성입니다.
- real-server topology tests에는 Session repository flow가 없습니다. 이번 작성에서도 real-server lane을 실행하지 않았습니다.
다음에 열어볼 source 순서
RedisSessionWebConfig → SecurityConfig의 두 authentication branch → primitive repository → SecurityModeWebContractTest의 test-only login → composition validator 순으로 읽으면 “최초 인증”, “security-context snapshot”, “Redis persistence”를 섞지 않을 수 있습니다.
시리즈에서 이어 읽기
- 전체 흐름: 「Redis를 범용 클라이언트가 아니라 정책 경계로 다루기」
- 운영 흐름: 「Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지」
같은 Redis 장애가 DEGRADED와 DOWN으로 갈리는 코드
이 글이 답하는 코드 질문
Redis가 응답하지 않을 때 cache-only deployment는 왜 DEGRADED이고 session·idempotency·rate-limit·lease deployment는 왜 DOWN일까요? health contributor의 status만 다르게 만들면 readiness group이 안전하게 따라올까요? startup/capability probe와 command observation은 실제 production에 어디까지 조립됐을까요? 이 글은 probe 호출부터 Actuator group membership, low-cardinality tag까지 추적합니다.
코드 지도
| 코드 | 입력 | 출력 | production 상태 |
|---|---|---|---|
RedisHealthContributor |
runtime owner + fast timeout | reachable + bounded detail | 두 HealthIndicator가 사용 |
RedisSdkAutoConfiguration.redisOptional() |
health probe 결과 | UP 또는 DEGRADED |
Redis-on이면 항상 bean |
RedisSdkAutoConfiguration.redisRequired() |
correctness role predicate | UP 또는 DOWN |
correctness role에서만 bean |
RedisCorrectnessRoles |
Environment selectors | required contributor 생성 여부 | health/readiness 공통 predicate |
RedisReadinessGroupPostProcessor |
config data + same predicate | readiness include property source | spring.factories 등록됨 |
RedisStartupProbe |
server facts + required capability | confirmed RedisCapabilities |
production bean/collector 없음 |
RedisObservation |
descriptor, lane, mode, slot, outcome | closed tag map | 실행기가 생성, exporter bean 없음 |
NoThrowObservationSink |
observation consumer | telemetry failure 격리 + drop count | 실행기 constructor에서 wrapping 가능 |
request-time health probe
두 Actuator contributor는 같은 RedisHealthContributor.probe()를 호출합니다. probe 순서는 다음과 같습니다.
sequenceDiagram
participant A as Actuator HealthIndicator
participant H as RedisHealthContributor
participant O as RedisRuntimeOwner
participant R as Redis
A->>H: probe()
alt owner != OPEN
H-->>A: unreachable / shutting-down
else owner OPEN
H->>O: borrow(REGULAR)
O-->>H: lease
H->>R: PING
alt timeout 안에 reply
R-->>H: PONG 또는 reply
H-->>A: reachable=true
else interrupt/failure/timeout
H-->>A: reachable=false
end
H->>O: lease close
end
owner state가 OPEN이 아니면 connection을 빌리지 않고 shutting-down을 반환합니다. OPEN이면 REGULAR lane을 빌려 PING completion을 timeout.toNanos() 안에서 기다립니다. 단순 connection.isOpen() flag가 아니라 round trip을 검사합니다.
interrupt가 발생하면 thread interrupted flag를 복원하고 unreachable을 반환합니다. 다른 Exception도 health endpoint에 throw하지 않고 unreachable로 바꿉니다. health detail에는 다음 세 field만 있습니다.
mode:STANDALONE,SENTINEL,CLUSTERstate:reachable,unreachable,interrupted,shutting-downreason: PING reply, owner state, 또는 exception class simple name
endpoint, username, key, driver message는 detail에 넣지 않습니다. 다만 reachable의 reason에 String.valueOf(reply)를 쓰므로 보통 PONG이 들어갑니다.
owner borrow가 lane ceiling 때문에 거절되어도 catch에서 unreachable로 바뀝니다. Redis server가 살아 있어도 REGULAR lane saturation 때문에 health가 실패할 수 있습니다. health는 “별도 우선순위 connection으로 server만 검사”가 아니라 실제 application lane을 포함한 가용성을 봅니다.
같은 probe, 다른 status
optional contributor의 custom status는 DEGRADED입니다. reachable이면 UP, unreachable이면 DEGRADED입니다.
required contributor는 reachable이면 UP, unreachable이면 DOWN입니다. 차이는 probe 구현이 아니라 adapter가 health result를 Actuator status로 투영하는 한 줄입니다.
이 taxonomy의 기준은 role의 correctness 영향입니다.
| role | Redis 장애 의미 | status/readiness |
|---|---|---|
| cache | 원본 조회로 우회하면 느려짐 | redisOptional=DEGRADED, readiness 밖 |
| session | 인증 상태를 올바르게 판정할 수 없음 | redisRequired=DOWN, readiness 포함 |
| idempotency | 중복 실행 방지/재생 상태를 보장할 수 없음 | DOWN |
| rate limit | quota enforcement를 보장할 수 없음 | DOWN |
| lease | 단일 holder 가정을 보장할 수 없음 | DOWN |
cache가 RedisCorrectnessRoles.SELECTORS에 없는 것은 의도적입니다. SELECTORS는 session/idempotency/rate-limit/lease 네 개만 포함합니다.
Redis-on이면 optional contributor는 cache selector와 무관하게 항상 생깁니다. 즉 lease-only deployment에도 redisOptional과 redisRequired가 둘 다 존재합니다. readiness에는 required만 들어갑니다.
required bean과 readiness membership을 같은 predicate로 묶기
Actuator는 management.endpoint.health.validate-group-membership=true일 때 group include에 없는 contributor name이 들어가면 startup을 거절합니다. 반대로 validation을 끄면 오타나 absent contributor를 조용히 빼고 readiness가 false green이 될 수 있습니다.
애플리케이션의 shipped group은 application.yml health 구간에서 다음을 선언합니다.
- liveness:
livenessState - readiness:
readinessState,db - startup:
readinessState
redisRequired를 정적으로 쓰지 않습니다. 대신 RedisReadinessGroupPostProcessor가 config data 뒤에 실행되어 조건이 맞을 때만 append합니다. 이 class는 spring.factories에 등록되어 있습니다.
호출 순서는 다음과 같습니다.
- config data가
app.redis.enabled, role selector, 기존 readiness include를 해석합니다. - post-processor가 Redis-on인지 확인합니다.
RedisCorrectnessRoles.anySelected(environment)를 호출합니다.- 기존 comma-separated member를 순서 보존 set으로 만듭니다.
redisRequired를 중복 없이 append한 property source를 가장 앞에 둡니다.- context refresh 때
RedisCorrectnessRoleBoundcondition도 같은anySelected()를 호출해 bean을 만듭니다.
post-processor의 order는 ConfigDataEnvironmentPostProcessor.ORDER + 1입니다. config data 전에 실행되면 shipped base group을 읽지 못해 readinessState, db를 잃을 수 있기 때문입니다.
global switch가 off이거나 correctness role이 없으면 post-processor는 아무것도 하지 않습니다. cache-only일 때 optional contributor는 생겨도 readiness group에는 들어가지 않습니다.
startup/capability probe가 검사하도록 설계된 것
health PING은 지금 응답하는지만 봅니다. RedisStartupProbe.confirm()은 deployment 선언과 server fact가 일치하는지 확인하는 별도 type입니다.
입력 ServerFacts는 다음 네 값을 가집니다.
INFO server에서 파싱한RedisVersionCOMMAND LIST에서 얻은 lowercase command name setmin-replicas-to-writemin-replicas-max-lag
ServerFacts.from()은 version이 없으면 추측하지 않고 실패합니다. durability config 값이 없으면 0으로 간주하지 않고 admin account에 +config|get grant가 필요하다고 실패합니다.
RedisCapabilityProbe.probe()는 다음을 확인합니다.
- server version이 minimum supported 7.2.0 이상인지
- Cluster database가 0인지
- version상 가능한 capability의 witness command가 실제 server에 있는지
- deployment가 required로 선언한 capability가 available set에 있는지
version은 가능성 filter일 뿐 proof가 아닙니다. JSON/SEARCH/TIME_SERIES/PROBABILISTIC 같은 module capability는 해당 witness command가 실제 보고되어야 합니다.
requireWriteDurability()는 replicated mode에서 두 durability setting이 모두 양수인지 요구합니다. acknowledgedWriteLossAccepted=true이면 이 guard를 명시적으로 waive합니다.
그러나 production source에는 RedisStartupProbe나 RedisCapabilityProbe bean을 만드는 코드, INFO/COMMAND/CONFIG GET으로 ServerFacts를 수집하는 호출자가 확인되지 않습니다. 단위 계약은 구현됐지만 실제 startup에서 실행된다고 말할 수 없습니다.
command observation의 bounded cardinality
RedisObservation.starting()은 descriptor, lane, deployment mode, optional Cluster slot으로 observation을 만듭니다. 결과는 started, success, failure, ambiguous, rejected 중 하나입니다.
metric/span 이름 상수는 다음과 같습니다.
- span:
redis.command - duration:
backend.redis.command.duration - request bytes:
backend.redis.command.request.bytes - reply bytes:
backend.redis.command.reply.bytes - rejection:
backend.redis.policy.rejections - retry:
backend.redis.retry.count
lowCardinalityTags()은 정확히 열 개 key를 반환합니다.
family, risk, access, operation, mode, connection.kind, outcome, retries, ambiguous, slot.bucket입니다. raw key, field, member, value, user id는 없습니다. 16,384개 Cluster slot은 1,024로 나눠 b0~b15 bucket으로 축소합니다. slot이 없으면 none입니다.
Sync/Reactive/Queueing executor와 batch 실행 source는 observation을 생성하고 sink에 전달합니다. 다만 aggregate executor/operations의 production DI가 확인되지 않고, app-bootstrap에 MeterRegistry나 tracer로 연결하는 Consumer<RedisObservation> bean도 없습니다. 상수와 tag model이 있다는 사실은 실제 metric이 export된다는 뜻이 아닙니다.
NoThrowObservationSink는 telemetry failure가 command result를 바꾸지 않게 합니다. delegate가 RuntimeException 또는 LinkageError를 던지면 observation을 drop하고 LongAdder를 올립니다. 첫 drop은 warning, 이후는 debug입니다. drop metric 이름은 backend.redis.observation.drops이지만 이 counter를 metric backend에 bind하는 production 코드 역시 확인되지 않습니다.
정상·실패·degraded 분기
| 상황 | optional health | required health | readiness 영향 |
|---|---|---|---|
| PING 성공 | UP | UP | required role이면 정상 |
| PING timeout/driver failure | DEGRADED | DOWN | required role이면 unready |
| owner DRAINING/CLOSED | DEGRADED | DOWN | shutdown 중 새 traffic 차단 가능 |
| REGULAR lane saturation | DEGRADED | DOWN | server 생존과 무관하게 실제 lane unavailable |
| cache-only outage | DEGRADED | bean 없음 | readiness 유지 |
| correctness role outage | DEGRADED도 존재 | DOWN | readiness DOWN |
startup probe가 production에 조립된다면 version/capability/durability mismatch는 startup failure여야 합니다. 현재는 이 branch가 unit-tested type에 머뭅니다.
observation sink 실패는 command 성공/실패와 분리되어 observation drop으로 끝납니다. executor timeout 뒤 write가 적용됐는지는 ambiguous outcome으로 표현할 수 있지만, production exporter가 없으므로 운영 backend에서 이 tag를 볼 수 있다고 보장할 수 없습니다.
테스트가 고정하는 계약
LiveRedisCompositionTest.theOptionalContributorReportsUp()는 real server에서 optional contributor가 UP임을 확인합니다. unreachable에서 DEGRADED/DOWN을 직접 검증하는 전용 test는 현재 config test package에서 확인되지 않았습니다.
RedisReadinessGroupPostProcessorTest는 ApplicationContextRunner가 아니라 실제 SpringApplication을 띄웁니다. runner는 EnvironmentPostProcessor를 실행하지 않기 때문입니다.
redisOffStartsAndDoesNotNameTheContributor(): Redis-off context와 group membership 검증cacheOnlyDoesNotGateReadiness(): optional bean은 존재하지만 readiness 밖correctnessRoleGatesReadiness(): required bean과 group membership 동시 존재, base member 보존eachCorrectnessRoleGatesReadiness(): 네 correctness selector 전부 확인
RedisStartupProbeTest는 matching standalone, absent capability, replicated durability 두 조건, unreadable setting, missing version, explicit waiver를 고정합니다. 모두 pure unit test입니다.
RedisObservationTest는 raw key 부재, closed tag key set, slot bucket, outcome, 이름 상수를 고정합니다.
기본 module test는 이전 root 세션에서 성공했다는 공통 기록이 있지만, 이번 문서 작업에서는 real-server standalone/Sentinel/Cluster/TLS lane을 실행하지 않았습니다.
현재 구현 공백과 잘못 읽기 쉬운 지점
RedisStartupProbe와RedisCapabilityProbe는 production 미조립입니다. server capability/durability fail-fast는 현재 runtime 보장이 아닙니다.- health PING은 request-time Actuator 호출이며 application startup의 endpoint validation을 대신하지 않습니다.
- optional contributor는 Redis-on이면 cache 선택 여부와 무관하게 생깁니다.
redisOptional이라는 이름은 “cache bean만의 health”가 아니라 degradation-only 투영입니다. - correctness predicate에는 미완성 Redis Session selector도 포함됩니다. readiness가 Redis를 gate한다고 session repository request path가 완성되는 것은 아닙니다.
- observation model과 no-throw sink는 있으나 Micrometer/OTel exporter production 조립은 확인되지 않습니다.
- metric name 상수 중 duration/request/reply/rejection/retry를 실제 backend에 record하는 adapter도 확인되지 않습니다.
- unreachable optional=
DEGRADED, required=DOWN분기의 직접 단위 테스트가 부족합니다. 구현은 명확하지만 test contract 강도는 readiness membership보다 낮습니다.
다음에 열어볼 source와 관련 글
RedisHealthContributor.probe()redisOptional()과redisRequired()RedisCorrectnessRoles.anySelected()RedisReadinessGroupPostProcessorRedisStartupProbe.confirm()RedisObservation.lowCardinalityTags()
관련 시리즈 주제는 command executor의 timeout·ambiguous execution과 semantic capability별 failure policy입니다.
시리즈에서 이어 읽기
- 전체 흐름: 「Redis를 범용 클라이언트가 아니라 정책 경계로 다루기」
- 운영 흐름: 「Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지」
Redis 테스트가 증명하는 것과 증명하지 않는 것
이 글이 답하는 코드 질문
기본 check, topology-tagged test, Docker fixture, GitHub Actions matrix, support matrix 문서는 각각 어떤 사실을 증명합니까? Redis 7.2·7.4·8.2와 standalone·Sentinel·Cluster·TLS를 모두 “현재 인증됨”이라고 말할 수 있습니까?
아닙니다. 현행 source가 선언하는 CI matrix와 repository가 기록한 historical certification은 구분해야 합니다.
- production topology는 standalone, Sentinel, Cluster 세 가지입니다.
- TLS는 topology가 아니라 standalone shape의 transport qualification lane입니다.
- historical evidence는 Redis 7.4의 세 topology입니다.
- TLS 7.4 실행 기록은 infra README에 있습니다.
- 7.2와 8.2는 workflow에 선언되어 있지만 repository evidence상 declared-only입니다.
- 이번 문서 작성에서는 어느 real-server lane도 실행하지 않았습니다.
테스트 층 지도
| 층 | 진입점 | 실제로 묻는 질문 | 증명하지 않는 것 |
|---|---|---|---|
| deterministic unit/contract | module test·check |
policy, key rendering, codec, typed outcome, in-memory state transition | Lettuce wire behavior, ACL, failover, redirects, TLS handshake |
| composition test | ApplicationContextRunner |
property selector가 어떤 bean을 만들고 startup을 거절하는가 | server connection, command success |
| topology test | redisTopologyTest |
real Redis·Lettuce·ACL·topology behavior | 실행하지 않은 version/lane, production SLO |
| Docker fixture | infra/redis-sdk/*/compose.yml |
repeatable standalone/Sentinel/Cluster/TLS environment | production persistence·backup·capacity architecture |
| CI workflow | redis-sdk-topology.yml |
어떤 trigger에서 어떤 lane/version을 실행하도록 선언했는가 | 과거 또는 현재 run 성공 자체 |
| support matrix | docs/redis/support-matrix.md |
package/version/topology와 historical evidence 기록 | artifact digest의 현재 보존·최근 재실행 |
기본 test가 사용하는 deterministic gateway
cache, rate-limit, lease, idempotency adapter tests는 InMemoryGatewayAccess에서 얻은 RedisCommandGateway를 RedisRuntimeOwner에 넣습니다. 실제 Redis process나 Lettuce socket을 사용하지 않습니다. 이 구조는 state transition과 typed outcome을 빠르고 결정적으로 검사하지만 서버 parser, ACL, replication, cluster redirect는 재현하지 않습니다.
예를 들어 RedisCacheRegionAdapterTest는 soft/hard TTL, envelope category, generation invalidation, conditional writes를 검사합니다. RedisEdgeRateLimitAdapterTest는 세 algorithm과 fail-closed 결과를 고정합니다. RedisDistributedLeaseAdapterTest와 RedisIdempotencyStoreAdapterTest는 owner/reply-loss state를 검사합니다.
default test task는 redis-topology tag를 제외합니다. 따라서 module check가 성공해도 real server lane이 실행됐다는 뜻은 아닙니다. build.gradle default test
composition tests도 서버를 연결하지 않습니다. connection lane은 lazy하게 열리며 ApplicationContextRunner가 확인하는 것은 bean cardinality와 startup validation입니다. RedisCapabilityCompositionTest class contract
topology task가 fail-closed하는 방식
redisTopologyTest는 standalone, sentinel, cluster, tls만 allowlist로 받습니다. TLS는 deployment mode로는 standalone에 매핑하고 tag와 trust-material requirement만 TLS lane으로 유지합니다. REDIS_TOPOLOGY_MODES
flowchart TD
A[redisTopologyTest selected] --> B{mode allowlist?}
B -->|no| X[Gradle failure]
B -->|yes| C{required endpoint properties?}
C -->|no| X
C -->|yes| D{lane tag class exists?}
D -->|no| X
D -->|yes| E[run redis-topology AND lane-mode]
E --> F{executed count >= floor?}
F -->|no| X
F -->|yes| G{required classes all ran?}
G -->|no| X
G -->|yes| H{skipped == 0?}
H -->|no| X
H -->|yes| I[pass]
필수 property는 모든 lane의 host/port, Sentinel의 master, TLS의 trust material입니다. unknown mode, missing endpoint, 해당 tag class 없음, 0 tests 모두 실행 전에 실패합니다. doFirst
실행 뒤에는 required class와 minimum test count를 검사합니다.
| lane | required class | 최소 실행 수 |
|---|---|---|
| standalone | LiveRedisCompositionTest, LiveRedisSemanticPortsTest, RedisTopologyContractTest, LiveRedisGuardrailTest |
20 |
| sentinel | LiveRedisCompositionTest, LiveRedisSentinelPromotionTest, RedisTopologyContractTest |
20 |
| cluster | LiveRedisCompositionTest, LiveRedisClusterTest, LiveRedisClusterTransactionTest, LiveRedisSemanticPortsTest |
24 |
| tls | LiveRedisTlsTest |
4 |
이 선언은 REDIS_TOPOLOGY_REQUIRED_CLASSES와 MINIMUM_TESTS에 있습니다. skipped test 하나라도 있으면 task가 실패합니다. class 이름과 count를 함께 쓰므로 trivial test 하나만 남은 lane이 green이 되는 일을 막습니다.
네 fixture가 제공하는 환경
standalone
standalone/compose.yml은 Redis 한 대, AOF/save 없음, 공통 ACL file, published 6379를 사용합니다. persistence나 replication을 검증하는 fixture가 아닙니다.
Sentinel
sentinel/compose.yml은 data node 두 대와 sentinel 세 대를 host network에 둡니다. data node는 role이 바뀌어도 같은 설정을 쓰도록 anchor를 공유하고 min-replicas-to-write 1, min-replicas-max-lag 1을 적용합니다. sentinel quorum은 2이며 down-after 2000ms, failover timeout 10000ms입니다.
host network가 필요한 이유는 Sentinel이 proxy가 아니라 새 primary address를 알려 주고 client가 직접 연결하기 때문입니다. bridge 내부 address를 반환하면 host의 test client가 접근할 수 없습니다.
Cluster
cluster/compose.yml은 primary 3, replica 3인 6-node cluster입니다. 7100~7105와 cluster bus를 host network에 열고, init helper가 --cluster-replicas 1로 slot을 배치합니다. 별도 ready service가 authenticated cluster_state:ok까지 기다립니다. node health만으로는 slot assignment 완료를 증명할 수 없기 때문입니다.
TLS
tls/compose.yml은 standalone shape입니다. ephemeral CA/server certificate를 만들고 plaintext --port 0, TLS port만 켭니다. 따라서 client가 plaintext로 fallback하면 lane이 통과할 수 없습니다. client certificate authentication은 끄고 server certificate/trust/hostname path를 검증합니다.
real-server test가 맡는 증거
RedisTopologyContractTest는 real server에서 PING, ACL account 존재, blocked command denial, RAW_ONLY/Admin/TYPED/script account 분리를 검사합니다. 특히 advanced account는 EVALSHA만 허용하고 EVAL은 허용하지 않습니다. scriptPathIsAdvancedOnly
LiveRedisSemanticPortsTest는 standalone과 cluster에서 cache read/write, rate-limit enforcement, idempotency first/second claim, lease contention을 advanced/application ACL account로 호출합니다. LiveRedisSemanticPortsTest tags Session은 이 class에 없습니다.
LiveRedisSentinelPromotionTest는 promotion과 acknowledged-write-loss 경계를 관찰합니다. support matrix의 historical 기록에 따르면 guardrail 적용 전에는 superseded primary가 2,086 writes를 success로 응답한 뒤 잃었고, min-replicas-* 적용 후 같은 유형의 loss가 1로 줄었습니다. 이는 현행 코드를 이번에 재실행해 얻은 수치가 아니라 repository에 남은 historical evidence입니다. support matrix Sentinel evidence
LiveRedisClusterTest는 client slot 계산과 server CLUSTER KEYSLOT, cross-slot 양방향 refusal, MOVED/ASK/TRYAGAIN 관찰을 맡습니다. LiveRedisClusterTransactionTest는 cluster transaction lane의 slot 제약을 맡습니다.
LiveRedisTlsTest는 filesystem/classpath CA로 handshake 후 PING, unreadable trust material startup failure, TLS-only server에 plaintext로 연결 실패를 검사합니다. LiveRedisTlsTest
CI version matrix: 선언과 증거를 분리합니다
GitHub Actions workflow는 trigger에 따라 matrix를 계산합니다.
- pull request: standalone 7.4 한 lane
- schedule: standalone/Sentinel/Cluster 각각 7.2, 7.4, 8.2와 TLS 7.4, 8.2
- manual release-candidate: schedule과 같은 full matrix
- manual normal: 입력한 topology/version 한 조합
근거는 redis-sdk-topology.yml matrix selection입니다. workflow는 image tag뿐 아니라 resolved image digest와 commit SHA를 JUnit artifact에 기록하고 90일 보존을 선언합니다. evidence manifest/upload
하지만 workflow YAML에 row가 있다는 사실은 row가 성공했다는 증거가 아닙니다. source 안의 support matrix는 “세 topology는 7.4에서 실행됐고 7.2/8.2는 실행되지 않았다”고 명시합니다. Certified versions
따라서 현행 qualification 표현은 다음과 같이 제한해야 합니다.
| 대상 | 현재 말할 수 있는 상태 |
|---|---|
| standalone 7.4 | historical certified evidence 기록 있음 |
| Sentinel 7.4 | historical certified evidence 기록 있음 |
| Cluster 7.4 | historical certified evidence 기록 있음 |
| TLS 7.4 | infra README에 실행 기록 있음; support matrix certified topology table에는 별도 row 없음 |
| 7.2 | CI declared-only |
| 8.2 | CI declared-only |
| TLS 8.2 | CI declared-only |
infra README는 “all four have now run on Redis 7.4”라고 기록합니다. infra/redis-sdk/README.md 이 문구를 TLS historical evidence로 사용할 수 있지만, 현재 run artifact를 이 작업에서 확인한 것은 아닙니다.
support matrix gate의 범위와 drift
RedisSupportMatrixTest는 구현된 SDK package와 enum capability가 표에 모두 있는지, topology evidence cell이 실제 test class 이름을 가리키는지 검사합니다. RedisSupportMatrixTest
그러나 test class가 존재한다고 해당 version의 run artifact가 존재하는 것은 아닙니다. 이 gate는 evidence claim의 형식과 source reference를 검사하지만 workflow history는 조회하지 않습니다.
문서 drift도 있습니다.
- support matrix는 Lettuce
6.8.2라고 쓰지만 lockfile은6.8.1.RELEASE입니다.gradle.lockfile - support matrix module row는 connection을 “five lanes”라고 쓰지만 현행
RedisConnectionKind에는 REGULAR/BLOCKING/TRANSACTION/SCRIPT/PUBSUB/ADMIN 여섯 lane이 있습니다. - CI quality gate 주석은 real-server lane이 “아직 없다”고 하지만 별도 topology workflow가 이미 존재합니다.
ci-quality-gates.yml - topology workflow는 nightly 7.2/7.4/8.2를 선언하지만 support matrix의 Sentinel/Cluster declared versions에는 7.2가 빠져 있습니다.
이런 drift 때문에 README나 table 하나만으로 current implementation을 판정하면 안 됩니다. production/test/lock/workflow를 먼저 보고 historical 문서는 qualification label에만 사용해야 합니다.
이번 작업에서 실행한 것과 실행하지 않은 것
이번 문서 작성은 source HEAD 3b5aee50e33c44c02d08c94bb39ad34814482010을 정적으로 조사했습니다. root의 이전 세션에서 기본 module test가 성공했다는 공통 전제는 있지만, 이 작성자가 default Gradle tests나 standalone/Sentinel/Cluster/TLS lane을 새로 실행하지 않았습니다.
따라서 이 글은 test code가 고정한 계약, fixture와 CI가 선언한 실행 방식, repository에 기록된 historical evidence를 설명합니다. 현재 외부 CI run의 green 상태나 image digest는 확인하지 않았습니다.
현재 공백과 다음 source 순서
- real-server semantic test는 cache/rate-limit/idempotency/lease를 다루지만 Session은 다루지 않습니다.
- rate-limit live test 주석은 evaluation dedupe를 주장하지만 production Lua가 evaluation ID를 소비하지 않습니다. test 자체도 dedupe assertion을 하지 않습니다.
- support-matrix test는 artifact provenance를 조회하지 않으므로 “test class 존재”와 “version certified” 사이에 사람이 유지하는 historical 기록이 남습니다.
- Docker fixtures는 production architecture가 아닙니다. persistence, backup, capacity, multi-region을 증명하지 않습니다.
- minimum test count는 coverage shrink guard이지 statement/branch coverage 수치가 아닙니다.
- 이번 작업은 real-server current qualification을 갱신하지 않았습니다.
build.gradle task → topology workflow → 각 compose → tagged test → support matrix와 gate test 순으로 읽으면 선언, 실행 계약, historical evidence를 분리할 수 있습니다.
시리즈에서 이어 읽기
- 전체 흐름: 「Redis를 범용 클라이언트가 아니라 정책 경계로 다루기」
- 운영 흐름: 「Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지」
Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지
Redis를 애플리케이션에 붙이는 일은 호스트와 비밀번호를 설정하는 것으로 끝나지 않습니다. 캐시는 Redis가 잠시 끊겨도 원본 저장소로 우회할 수 있지만, 세션·멱등성·요청 제한·분산 lease는 같은 장애를 전혀 다르게 해석해야 합니다. Sentinel은 primary를 승격해 가용성을 회복하지만, 교체된 primary가 자신이 교체됐다는 사실을 늦게 알아차리면 이미 성공으로 응답한 쓰기가 사라질 수 있습니다. Cluster에서는 여러 키가 같은 slot에 있어야 하고, blocking 명령과 일반 명령을 한 connection pool에 섞으면 한 종류의 부하가 전체 요청을 멈출 수 있습니다.
이 글은 clean-architecture-backend-template의 Redis 모듈을 플랫폼·SRE 관점에서 해부합니다. 핵심 질문은 “어떤 Redis 명령을 제공하는가”보다 다음에 가깝습니다.
- Redis를 쓰지 않는 배포는 Redis 설정과 리소스에서 정말 자유로운가?
- Redis를 쓰는 역할은 무엇이며, 장애 시 pod를 계속 서비스에 남겨도 되는가?
- 잘못된 topology, credential, TLS, ACL, timeout, capacity 설정은 언제 실패하는가?
- timeout과 failover 뒤 쓰기를 안전하게 재시도할 수 있는가?
- 실서버 검증과 CI 행렬이 실제로 무엇을 증명하며, 무엇은 아직 증명하지 못했는가?
- 이 저장소를 운영 배포 템플릿으로 쓰려면 어떤 공백을 별도로 메워야 하는가?
먼저 구분할 세 가지 근거
이 글은 근거의 강도를 섞지 않습니다.
- 현 HEAD 확인은 커밋
3b5aee50e33c44c02d08c94bb39ad34814482010의 코드, 설정, 테스트, Compose, workflow를 직접 읽어 확인한 내용입니다. root 작업 세션에서:adapter:outbound:cache-redis:test기본 테스트는 성공했습니다. 이 task는redis-topology태그를 제외하며, Standalone·Sentinel·Cluster·TLS topology lane은 실행하지 않았습니다. - 저장소의 과거 실측 기록은
docs/redis/와 테스트 주석에 남아 있는 이전 실서버 실행 결과입니다. 수치와 결론을 그대로 구분해 인용하지만, 이번 세션에서 재현했다고 주장하지 않습니다. - 워크플로 정의는 GitHub Actions가 어떤 행렬을 실행하도록 작성됐는지를 뜻합니다. 행렬에 Redis 7.2·7.4·8.2가 들어 있다는 사실만으로 모든 조합이 통과했다고 보지 않습니다.
이 구분은 특히 버전 지원과 Sentinel 쓰기 손실을 읽을 때 중요합니다. 저장소 문서 사이에도 시점 차이가 있기 때문입니다.
현재 기술 기준선과 문서 드리프트
현 HEAD의 빌드 기준선은 다음과 같습니다.
| 항목 | 현 HEAD 값 | 근거 |
|---|---|---|
| Java | 21 | src/build.gradle |
| Gradle | 9.0.0 | gradle-wrapper.properties |
| Spring Boot | 4.0.0 | src/build.gradle |
| Lettuce | 6.8.1.RELEASE |
gradle.lockfile |
| Reactor | 3.8.0 | gradle.lockfile |
| Netty | 4.2.17.Final | src/build.gradle |
| 최소 Redis 버전 | 7.2.0 | RedisCapabilityProbe.java |
Redis leaf는 Spring Data Redis를 사용하지 않고 Lettuce와 Reactor를 직접 의존합니다. typed API, command catalog, admission guard를 우회하는 범용 command surface를 만들지 않으려는 선택입니다. Micrometer core도 leaf에서 제외하고 관측 이벤트를 composition root 쪽으로 내보냅니다. 자세한 의존성 의도는 cache-redis/build.gradle에 적혀 있습니다.
여기서 첫 번째 드리프트가 보입니다. support-matrix.md는 Lettuce를 6.8.2로 고정했다고 쓰지만 실제 lock은 6.8.1.RELEASE입니다. 운영 기준선은 문서의 설명보다 lockfile을 우선해야 합니다. 업그레이드 검토에서도 “문서상 버전”이 아니라 dependency lock diff를 출발점으로 삼아야 합니다.
1. 전역 스위치는 하나이고, 역할 선택기는 그 아래에 있습니다
이 구조의 가장 중요한 정책은 APP_REDIS_ENABLED가 유일한 전역 activation switch라는 점입니다. 기본값은 false입니다.
app:
redis:
enabled: ${APP_REDIS_ENABLED:false}
전역 스위치가 꺼져 있으면 Redis 설정을 바인딩하지 않습니다. cross-field validation, credential 해석, raw policy와 TLS material 읽기, client·connection·thread·health contributor 생성도 하지 않습니다. Redis를 사용하지 않는 배포가 잘못된 Redis 설정 때문에 시작에 실패하지 않게 한 것입니다. 이 동작은 application.yml과 RedisSdkAutoConfiguration.java에서 확인할 수 있습니다.
역할 selector는 Redis 자체를 켜는 스위치가 아닙니다. 어떤 application port를 Redis 구현으로 조립할지 정합니다.
| 역할 | selector와 Redis 값 | 기본값 | 장애 분류 | 현재 조립 상태 |
|---|---|---|---|---|
| cache | ca-skeleton.capabilities.cache.bindings.default=redis |
disabled |
optional, 성능 저하 | RedisCacheRegionAdapter 조립 |
| session | ca-skeleton.security.auth-mode=redis-session |
jwt |
correctness predicate에 포함 | Redis repository와 최초 인증 mechanism이 없어 선택 불가 |
| idempotency | ca-skeleton.capabilities.idempotency.provider=redis |
jdbc |
correctness | owner·operation-aware V2 store와 executor 조립; same-attempt 중복 실행·renew 공백 존재 |
| rate limit | ca-skeleton.capabilities.rate-limit.provider=redis |
disabled |
correctness | fail-closed 정책만 지원 |
| lease | ca-skeleton.capabilities.lease.provider=redis |
disabled |
readiness상 correctness | adapter는 efficiency-only이며 fencing을 제공하지 않음 |
현 HEAD에서 실제 semantic provider가 조립되는 역할은 cache, idempotency, rate limit, lease의 4/5입니다. redis-session은 selector가 존재하더라도 redisVersionedSessionRepository producer가 없고, snapshot이 없는 요청에서 인증된 Authentication 객체를 최초로 만드는 production mechanism도 확인되지 않아 사용할 수 없습니다.
기본 selector와 세부 정책은 application.yml, selector 전체 목록은 RedisActivationValidator.java에서 확인할 수 있습니다.
전역 스위치가 false인데 역할 하나가 Redis를 선택하면 startup validator가 모순된 selector를 모두 모아 한 번에 실패시킵니다. 역할 selector가 Redis를 암묵적으로 켜지도 않고, missing bean 오류가 첫 요청까지 밀리지도 않습니다.
APP_REDIS_ENABLED=false
APP_IDEMPOTENCY_PROVIDER=redis
위 조합은 “idempotency bean이 없다”가 아니라 “Redis가 꺼졌지만 idempotency가 Redis를 선택했다”는 설정 오류로 시작 단계에서 종료됩니다.
cache와 correctness 역할을 다르게 다루는 이유
cache가 끊기면 보통 원본 저장소를 더 많이 읽어 응답이 느려집니다. 이때 pod를 readiness에서 제거하면 남은 pod의 부하가 커져 장애를 악화시킬 수 있습니다. 반면 idempotency가 사라지면 같은 결제가 재처리될 수 있고, rate limit이 사라지면 quota를 강제하지 못하며, session이 사라지면 인증 상태의 정합성이 무너집니다. 따라서 코드는 cache를 optional로, session·idempotency·rate-limit·lease를 correctness로 분류합니다. 기준과 selector는 RedisCorrectnessRoles.java에 모여 있습니다.
lease에는 주의가 필요합니다. readiness 분류는 보수적으로 correctness 쪽에 두지만, 실제 adapter 계약은 “efficiency only”이며 fencing token을 제공하지 않습니다. 따라서 데이터베이스 쓰기처럼 correctness-sensitive한 임계 구역을 Redis lease 하나로 보호하면 안 됩니다. RedisCapabilityConfig.java의 계약을 readiness 명칭보다 우선해 해석해야 합니다.
2. 부팅은 bind가 아니라 검증 파이프라인입니다
활성화된 Redis의 부팅 순서는 다음처럼 정리할 수 있습니다.
flowchart LR
A[APP_REDIS_ENABLED] --> B[role selector 모순 검사]
B --> C[app.redis 설정 bind]
C --> D[cross-field validation]
D --> E[secret reference 해석]
E --> F[TLS / raw policy resource 검사]
F --> G[topology별 client 생성]
G --> H[connection lane과 capacity 구성]
H --> I[semantic adapter 조립]
I --> J[optional / required health 구성]
설정은 Redis가 켜졌을 때만 존재합니다
RedisSdkSettings는 애플리케이션 전체의 @ConfigurationPropertiesScan 대상이 아니라 conditional auto-configuration 안에서만 등록됩니다. Redis가 켜지면 app.redis를 바인딩하고, 이후 validation bean이 cross-field 규칙을 실행합니다. raw gateway를 켰다면 allowlist resource의 존재와 가독성까지 확인한 뒤에야 client를 만듭니다. 기본 raw allowlist 위치는 모듈이 실제로 제공하지 않으므로, raw를 활성화하면서 resource를 명시하지 않으면 startup failure가 됩니다. 관련 순서는 RedisSdkAutoConfiguration.java에 구현돼 있습니다.
세부 APP_REDIS_* 키를 기본 application.yml이나 .env에 모두 나열하지 않은 것도 같은 정책입니다. Redis를 쓰지 않는 배포가 Redis 설정을 운반하지 않게 하고, configuration metadata와 env registry가 속성 계약을 맞춥니다. 이 정책은 env-keys.yaml에 명시돼 있습니다.
topology와 namespace 기본값
현 HEAD의 주요 기본값은 다음과 같습니다.
| 설정 | 기본값 | 운영 의미 |
|---|---|---|
| mode | STANDALONE |
topology fallback은 없음 |
| nodes | localhost:6379 |
standalone은 정확히 한 노드만 허용 |
| database | 0 |
Cluster는 DB 0만 허용 |
| namespace | local:sample-service:shared |
모든 capability가 한 namespace 규칙을 공유 |
| acknowledged write loss accepted | false |
구현·테스트된 durability probe의 opt-out 기본값. 현 production에는 probe가 미조립 |
근거는 RedisSdkSettings.java와 env registry의 mode, namespace, nodes 항목입니다.
namespace는 {environment}:{service}:{domain}의 한 규칙으로 모든 capability에 적용됩니다. per-capability prefix 조립을 제거한 이유는 ACL의 ~pattern과 애플리케이션이 실제 생성하는 key prefix가 어긋나는 일을 막기 위해서입니다. cache의 외부 식별자는 HMAC-SHA256으로 digest하고, namespace를 HMAC material에 함께 묶습니다. staging dump의 digest가 production과 일대일 대응하지 않게 하는 조치입니다. 구현은 RedisCapabilityConfig.java에 있습니다.
secret은 값이 아니라 reference로 전달합니다
credential 설정에는 비밀번호 자체가 아니라 다음 형식의 포인터가 들어갑니다.
secret://<source>/<name>
secret://<redis-acl-user>@<source>/<name>
첫 번째 형식은 ACL username을 default로 봅니다. 두 번째 형식은 named ACL user를 명시합니다. resolver는 secret:// 외 scheme, 잘못된 경로, 빈 해석 결과를 모두 startup error로 처리하고, toString()에서도 password를 ***로 가립니다. RedisCredentialResolver.java를 참고하면 됩니다.
application credential이 없으면 기본적으로 실패합니다. 의도적으로 anonymous Redis를 쓸 때만 APP_REDIS_AUTHENTICATION_ANONYMOUS_ACCESS_ACCEPTED=true로 trade-off를 기록합니다. advanced, pub/sub, admin, raw, Sentinel control credential은 역할별 reference를 둘 수 있습니다. 설정 계약은 env-keys.yaml과 Sentinel credential에 있습니다.
production secret validator에서 발견되는 현재 불일치
현 HEAD에는 두 종류의 secret 계약이 공존합니다.
- Redis SDK는
app.redis.authentication.*-credential-reference를 해석합니다. SecretSourceValidator는 prod profile에서APP_CACHE_REDIS_PASSWORD,APP_RATE_LIMIT_REDIS_PASSWORD같은 이전 role 단위 secret과 HMAC material을 검사합니다.
또한 application.yml은 idempotency와 lease의 key-hmac-secret-reference를 선언하고 validator도 이 secret을 요구하지만, 현 RedisCapabilitySettings.Idempotency와 .Lease 및 composition code는 이 필드를 소비하지 않습니다. rate-limit도 별도 HMAC secret을 실제 조립에 사용하지 않습니다. cache만 HMAC secret을 해석합니다. 근거는 application.yml, SecretSourceValidator.java, RedisCapabilityConfig.java입니다.
따라서 production 배포 전에 다음을 정리해야 합니다.
- SDK credential reference가 가리키는 secret과 prod validator의 legacy password key를 하나의 계약으로 통합합니다.
- idempotency·lease·rate-limit key HMAC secret을 실제 구현에 연결하거나, 사용하지 않는 설정과 필수 secret 요구를 제거합니다.
- env registry와 generated configuration metadata가 이 결정을 같은 이름과 조건으로 표현하게 합니다.
이 상태를 그대로 두면 “필수 secret을 주입했지만 runtime이 쓰지 않는” 설정과 “runtime이 필요한 credential reference인데 prod validator의 목록에는 없는” 설정이 동시에 생길 수 있습니다.
3. topology는 선택이고 fallback이 아닙니다
runtime deployment mode는 STANDALONE, SENTINEL, CLUSTER 세 가지입니다. TLS는 네 번째 topology가 아니라 standalone 형태에서 transport를 검증하는 qualification lane입니다.
RedisTopologyClientFactory.java는 선언한 mode에서 다른 mode로 fallback하지 않습니다. Sentinel로 선언했는데 Sentinel prerequisite가 빠졌다면 standalone으로 연결해 일단 부팅하지 않습니다. 그렇게 하면 첫 promotion 전까지는 정상처럼 보이다가, promotion 후 교체된 primary에 계속 쓸 수 있기 때문입니다.
Standalone
- 정확히 한
host:port만 허용합니다. - 여러 endpoint를 넣으면 어느 노드를 쓸지 임의로 고르지 않고 실패합니다.
- primary promotion 개념이 없으므로 replicated write durability 검사 대상이 아닙니다.
구현은 RedisTopologyClientFactory.java에 있습니다.
Sentinel
- Sentinel endpoint와 monitored master name으로 primary를 찾습니다.
- data node account와 Sentinel control account를 분리할 수 있습니다.
- Sentinel node 목록이 없으면 일반
nodes목록을 Sentinel endpoint로 사용합니다. - write durability를 확인하는
RedisStartupProbe구현과 단위 테스트가 있습니다. 다만 현 production composition에는 연결되지 않았습니다.
실제 Sentinel client 조립은 RedisTopologyClientFactory.java, 아직 조립되지 않은 검사 객체는 RedisStartupProbe.java에 있습니다.
Cluster
- seed node에서 cluster topology를 발견합니다.
maxRedirects기본값은 5입니다.- periodic refresh 기본값은 30초이며 adaptive refresh trigger를 모두 켭니다.
- cluster node membership validation을 활성화합니다.
- database는 0만 허용합니다.
CommandPolicyGuard구현과 테스트는 multi-key 요청이 서로 다른 slot을 가리키면 전송 전에 거절합니다. 현 production semantic adapter에는 이 guard가 조립되지 않았습니다.
production client 설정은 RedisTopologyClientFactory.java, 미조립 cross-slot admission 구현은 CommandPolicyGuard.java에 있습니다.
TLS
TLS 기본값은 비활성화이고 hostname verification 기본값은 true입니다. private CA라면 trust material resource를 지정할 수 있고, client certificate를 지정하면 client key도 반드시 있어야 합니다. material은 classpath resource와 filesystem path를 모두 처리하며 읽을 수 없는 material은 연결 시점이 아니라 startup에 실패합니다. 설정은 RedisSdkSettings.java, client 적용은 RedisTopologyClientFactory.java에 있습니다.
4. connection lane은 성능 최적화가 아니라 장애와 권한의 격리선입니다
Redis 연결은 여섯 lane으로 나뉩니다.
| lane | 용도 | 기본 credential role | 기본/주요 한도 |
|---|---|---|---|
REGULAR |
일반 non-blocking 명령 | application | in-flight command 64 |
BLOCKING |
blocking pop·stream read | application | connection 32, server block 최대 30초 |
TRANSACTION |
MULTI부터 EXEC까지 독점 |
application | connection 16 |
SCRIPT |
등록된 Lua/script 실행 | advanced | regular capacity ceiling 사용 |
PUBSUB |
subscribe lifecycle | pub/sub | buffer 1,024, overflow는 error |
ADMIN |
read-only 진단 | admin | enabled일 때 2 |
lane 정의와 credential mapping은 RedisConnectionKind.java, pool ceiling 조립은 RedisSdkAutoConfiguration.java에 있습니다.
blocking 명령은 server-side block 동안 connection을 점유합니다. transaction은 MULTI와 EXEC 사이에 connection을 독점합니다. subscribe 상태의 connection은 일반 명령을 처리할 수 없습니다. admin은 다른 권한을 사용합니다. 이를 한 pool에 섞으면 blocking consumer 포화가 cache get을 멈추거나, 진단 권한이 request path로 새어 나갑니다.
별도 credential reference가 설정된 역할마다 별도 Lettuce client와 event loop가 생깁니다. advanced와 Pub/Sub credential이 없으면 application account로 fallback하지만 경고 범위는 서로 다릅니다. advanced fallback은 startup warning을 남기고, Pub/Sub fallback은 현재 경고를 남기지 않습니다. raw와 admin은 enabled 상태에서 전용 credential이 없으면 fallback하지 않고 startup이 실패합니다. 따라서 단일 account 배포는 가능하지만 startup warning만 보고 모든 역할의 권한 분리를 확인했다고 판단하면 안 됩니다. client-per-role 조립은 RedisTopologyClientFactory.java, 검증 범위는 RedisSdkSettings.java와 RedisSdkSettings.java에 있습니다.
운영자는 lane별로 서로 다른 saturation 신호를 읽어야 합니다. blocking lane이 포화됐지만 regular traffic이 정상이라면 Redis 전체 장애가 아니라 consumer 동시성 산정 문제입니다. blocking pool은 요청률이 아니라 동시에 대기할 consumer 수로 산정합니다. 이 운영 해석은 operations.md에 기록돼 있습니다.
5. ACL과 TLS는 client-side policy의 마지막 방어선입니다
SDK가 command catalog와 permit으로 요청을 거르더라도 Redis account가 넓으면 실수나 우회 경로가 마지막 경계에서 막히지 않습니다. qualification fixture는 다음 named account를 둡니다.
ca-skeleton-application: 일반 read/write, transaction, pub/sub의 허용된 범위ca-skeleton-application-advanced:SCRIPT LOAD,EVALSHA, function 등 script 경로ca-skeleton-raw-gateway: 승인된 raw 범위ca-skeleton-admin-readonly:INFO,SLOWLOG,MEMORY USAGE,CONFIG GET,ACL DRYRUN등 read-only 진단- replication, Sentinel, cluster bootstrap 전용 계정
fixture는 default user를 끄고 account별 비밀번호와 key/channel pattern을 적용합니다. 실제 ACL은 all-accounts.acl에 있습니다. 이 파일의 fixture-* password는 throwaway qualification container용이며 배포 템플릿이 아닙니다. 운영 credential은 앞서 설명한 secret:// reference로 해석해야 합니다.
여기에도 문서 드리프트가 있습니다. infra/redis-sdk/acl/README.md는 비밀번호 material을 파일에 두지 않는다고 설명하지만, 현 ACL fixture에는 실제로 fixture-* 값이 있습니다. 반대로 상위 infra/redis-sdk/README.md는 이 값이 test fixture라고 정확히 설명합니다. 보안 검토에서는 상위 README의 범위를 적용하되, 하위 README는 갱신해야 합니다.
TLS qualification lane은 plaintext port를 0으로 꺼서 TLS 설정이 잘못됐는데 평문으로 fallback하는 거짓 성공을 막습니다. CA와 server key는 시작 시 named volume에 생성하며 repository에 private key를 커밋하지 않습니다. hostname에는 localhost와 127.0.0.1 SAN을 넣고, client는 생성된 CA를 전달받아 검증합니다. Compose는 infra/redis-sdk/tls/compose.yml에서 확인할 수 있습니다.
다만 이 lane은 alpine/openssl:latest를 사용합니다. image digest가 고정되지 않아 certificate generation 환경이 바뀔 수 있습니다. CI manifest가 Redis image digest를 보존하더라도 certificate helper image까지 같은 수준으로 재현하려면 tag 또는 digest 고정이 필요합니다.
6. command admission은 구현·테스트됐지만 production path에는 아직 연결되지 않았습니다
CommandPolicyGuard와 관련 테스트는 Redis에 보내기 전 다음 순서로 요청을 검사하는 계약을 구현합니다.
command catalog
→ 서버 capability와 최소 버전
→ risk와 permit provenance
→ namespace
→ Cluster slot
→ request/reply 예상 budget
→ connection lane
→ timeout
→ invocation
→ 일부 typed decoder의 관측 reply 검사
→ batch의 decoded-shape 근사 측정
→ exception translation
→ telemetry
현 HEAD의 구현 순서는 CommandPolicyGuard.java에 있습니다. application이 permit interface를 임의로 구현했다고 해서 승인하지 않고, 누가 어떤 policy에 대해 발급했는지를 검증합니다. R2 operation은 permit과 OperationBudget을 함께 요구하며 multi-key fan-out에는 별도 multi-key permit이 필요합니다.
이 순서가 모든 reply의 실제 byte ceiling을 뜻하지는 않습니다. 기본 GET, script, function, raw, admin, extension은 관측한 reply byte를 decoder 전에 공통 검사하지 않습니다. extension은 policy name이 있을 때만 budget을 가지며 null-policy path에는 budget 자체가 없습니다. batch는 wire bytes가 아니라 decode된 result shape를 근사해 누적합니다. 따라서 설정된 reply ceiling을 모든 surface의 memory 보호선으로 간주하면 안 됩니다.
그러나 main source에서 CommandPolicyGuard나 이를 사용하는 executor를 생성하는 production composition은 확인되지 않습니다. 현재 네 semantic adapter는 RedisRuntimeOwner에서 lane을 빌려 gateway를 직접 호출합니다. 따라서 이 절의 capability·permit·namespace·slot·budget·timeout 검사는 구현되고 테스트된 SDK 계약이지, 현 production request path의 보장이 아닙니다.
OperationBudget은 다음 네 값을 호출자가 명시하게 합니다.
new OperationBudget(maxElements, maxRequestBytes, maxReplyBytes, timeout)
해당 R2 typed API 계약은 이를 생략하거나 무한대로 default할 수 없게 설계됐습니다. 호출자가 Redis 작업에 허용할 최대 비용을 선언하게 합니다. 다만 production semantic adapter가 이 admission path를 사용한다고 볼 조립 근거는 없습니다. 계약은 OperationBudget.java에 있습니다.
기본 timeout profile
| profile | 기본 timeout | 대상 |
|---|---|---|
FAST |
500ms | single-key get/set, membership, score |
COLLECTION |
2s | bounded range, scan page, set algebra |
SCRIPT |
1s | 등록된 script/function |
BATCH |
2s | pipeline과 명시적 batch |
ADMIN |
3s | read-only 진단 |
BLOCKING |
server block + 2s | blocking command |
값은 TimeoutProfile.java와 RedisSdkSettings.java에 있습니다. 구현된 guard path에서는 blocking command가 server block 시간을 양수의 유한값으로 선언해야 하며, 설정된 최대 30초를 넘으면 전송 전에 거절됩니다. effective client timeout에는 2초 margin을 더합니다. 이 enforcement 역시 production에는 미조립입니다. CommandPolicyGuard.java를 참고하면 됩니다.
기본 size와 cardinality 한도
| 한도 | 기본값 |
|---|---|
| key | 512 bytes |
| value | 1 MiB |
| stream payload | 256 KiB |
| hash field | 512 KiB |
| collection 결과 | 1,000 elements |
| scan page | 500 elements |
| batch | 500 commands |
| request | 4 MiB |
| reply | 16 MiB |
offlineQueueCommands 설정 |
기본 1,000, 현재 production client option에서 미사용 |
| 실제 Lettuce request queue | maximumInFlightCommands와 같은 기본 64 |
| bitmap bit index | 10,000,000 |
설정은 RedisSdkSettings.java에 있습니다. 이 가운데 offlineQueueCommands=1_000은 현재 validation과 getter/setter에만 남아 있고 production client option에는 소비되지 않습니다. 실제 Lettuce requestQueueSize는 maximumInFlightCommands에 연결되므로 기본값은 64입니다. connection capacity의 나머지 기본값은 in-flight bytes 4 MiB, reply 16 MiB입니다. RedisTopologyClientFactory.java와 RedisSdkSettings.java를 함께 봐야 합니다.
여기서 registry 드리프트도 확인됩니다. APP_REDIS_CAPACITY_MAXIMUM_IN_FLIGHT_BYTES와 APP_REDIS_CAPACITY_MAXIMUM_REPLY_BYTES는 code default가 4 MiB와 16 MiB인데 env registry의 default는 null입니다. 플랫폼이 registry를 바탕으로 Helm values나 secret/config schema를 생성한다면 실제 runtime default와 다른 계약을 배포할 수 있습니다. env-keys.yaml을 코드와 함께 수정해야 합니다.
연결이 끊겼을 때 queue를 키우지 않습니다
Lettuce의 disconnected queue에 쓰기를 쌓았다가 reconnect 후 몰아서 재생하면 outage 중 발생한 작업과 재생 작업의 상대 순서가 불명확해집니다. 이 모듈은 기본적으로 disconnected 상태에서 command를 거절하고, request queue size를 in-flight command ceiling으로 제한하며 auto-reconnect는 유지합니다. caller가 오류를 보고 재시도·보상 여부를 정하게 합니다. 적용 코드는 RedisTopologyClientFactory.java에 있습니다.
7. 실행 확실성 모델도 production 조립 여부를 구분해야 합니다
write timeout 뒤 가장 위험한 대응은 무조건 재시도하는 것입니다. client가 reply를 받지 못했을 뿐 server에는 write가 적용됐을 수 있습니다. 이 모듈의 ExecutionCertainty와 translator는 실패를 다음 네 단계로 모델링하고 테스트합니다.
ExecutionCertainty |
의미 | 자동 재시도 |
|---|---|---|
CONFIRMED_SUCCESS |
server가 성공 응답 | 하지 않음 |
CONFIRMED_FAILURE |
server가 명시적으로 거절, 적용되지 않음 | pipeline이 임의 재시도하지 않음 |
SAFE_TO_RETRY_FAILURE |
server에 도달하지 않았음이 증명됨 | 허용 |
AMBIGUOUS_FAILURE |
실행됐을 수도 있고 아닐 수도 있음 | command가 retry-safe일 때만 허용 |
정의는 ExecutionCertainty.java에 있습니다.
LettuceExceptionTranslator 구현은 non-idempotent write의 timeout, connection loss, 분류할 수 없는 in-flight failure를 RedisAmbiguousExecutionException으로 바꿉니다. NOREPLICAS, OOM, MISCONF, EXECABORT, READONLY처럼 server가 명시적으로 거절한 오류는 definite rejection으로 분류합니다. ACL 오류, CROSSSLOT, redirect, busy, NOSCRIPT도 안정된 SDK exception hierarchy로 번역하고 raw server message 대신 error code만 남깁니다. 그러나 이 translator를 생성해 현재 semantic adapter에 연결하는 production composition도 확인되지 않습니다. 자세한 분류는 LettuceExceptionTranslator.java에 있습니다.
따라서 다음은 현재 runtime이 모두 강제한다고 볼 수 있는 목록이 아니라, 저장소가 정의한 failure-semantics 원칙이자 production 조립의 완료 조건입니다.
- non-idempotent write의 ambiguous failure는 재시도가 아니라 조회·대사·보상 대상입니다.
- SDK는 cross-slot command를 자동 분할하지 않습니다. shared hash tag로 key를 같은 slot에 배치해야 합니다.
- collection, stream, index 전체 읽기를 제공하지 않습니다. 모든 읽기에 bound가 필요합니다.
- 현재 rate-limit·lease·idempotency semantic script는 첫 요청에서
SCRIPT LOAD된 뒤EVALSHA로 실행됩니다.NOSCRIPT이면 digest cache를 비우고 script를 한 번만 다시 load·평가합니다. 따라서 advanced account에는 request path에서도SCRIPT LOAD권한이 필요합니다. caller가 임의 script body를 전달할 수 없다는 정책과 server가 first-use에 script를 load한다는 동작은 별개입니다. - Redis function library는 request path에서 load하지 않는 배포 artifact입니다.
- transaction은 rollback이 아닙니다.
EXECreply를 잃으면 transaction 전체가 실행됐는지 ambiguous할 수 있습니다. - Pub/Sub은 at-most-once입니다. reconnect 중 message replay가 필요하면 consumer group 기반 stream과 idempotent consumer를 사용해야 합니다.
운영 제한의 원문은 operations.md, transaction queue semantics는 QueueingRedisCommandExecutor.java에 있습니다.
8. Sentinel은 성공으로 응답한 쓰기도 잃을 수 있습니다
이 절의 수치는 이번 조사에서 재실행한 결과가 아니라 저장소의 과거 실측 기록입니다.
저장소 기록에 따르면 Redis 7.4 Sentinel lane에서 replica가 승격된 뒤 기존 primary가 약 11초 동안 자신이 교체됐음을 인지하지 못했습니다. client는 기존 primary에 계속 write했고, server는 2,086건에 +OK를 반환했습니다. 이후 기존 primary가 새 primary에서 resync하면서 이 write가 폐기됐고, client가 본 command failure는 한 건뿐이었습니다.
이 손실은 client-side metric이나 retry로 감지할 수 없습니다. server가 성공으로 응답했으므로 driver, SDK, caller 모두 CONFIRMED_SUCCESS로 볼 수밖에 없습니다. 이 기록은 operations.md, 더 자세한 run 설명은 support-matrix.md에 남아 있습니다.
서버의 모든 primary 후보에 다음을 적용한 기록도 있습니다.
min-replicas-to-write 1
min-replicas-max-lag 1
같은 promotion에서 acknowledged-and-discarded write는 2,086건에서 1건으로 줄고, 2,020건이 NOREPLICAS로 명시적으로 거절됐다고 문서는 기록합니다. silent loss를 caller가 대응할 수 있는 visible failure로 바꾼 것입니다. Sentinel Compose는 primary와 replica가 역할을 바꾸더라도 두 설정을 모두 유지하도록 공통 node definition에 넣습니다. sentinel/compose.yml을 참고하면 됩니다.
한 번은 이 설정을 시작 시 primary였던 노드에만 적용해 첫 promotion은 통과했지만 반대 방향 promotion에서 acknowledged write 2,099건이 손실됐다는 기록도 있습니다. “현재 primary”가 아니라 primary가 될 수 있는 모든 노드에 적용해야 하는 이유입니다. support-matrix.md에 당시 수정 경위가 있습니다.
현 HEAD에는 이 경험을 검사하는 RedisCapabilityProbe.requireWriteDurability와 RedisStartupProbe가 구현돼 있고 단위 테스트도 있습니다. 이 검사는 Sentinel과 Cluster 같은 replicated mode에서 다음 조건을 요구하도록 설계됐습니다.
min-replicas-to-write >= 1min-replicas-max-lag >= 1- 또는 손실을 의도적으로 수용하는
app.redis.acknowledged-write-loss-accepted=true
구현상 CONFIG GET 권한이 없어 값을 확인할 수 없는 경우도 보장을 입증하지 못한 것으로 보고 실패합니다. 다만 현 RedisSdkAutoConfiguration과 application composition은 이 probe를 생성하거나 호출하지 않습니다. 그러므로 현 production 시작 과정은 이 조건을 자동으로 거절하지 않습니다. 조립이 추가되기 전에는 배포 파이프라인이나 외부 정책 검사에서 같은 조건을 검증해야 합니다. 검사 로직은 RedisCapabilityProbe.java, server fact 수집은 RedisStartupProbe.java에 있습니다.
두 설정으로도 min-replicas-max-lag만큼의 잔여 window는 남습니다. 저장소의 operations.md는 개별 write에 Redis WAIT를 사용하는 대안을 적지만, 현재 command catalog에는 WAIT가 없고 typed·semantic 실행 표면도 없습니다. 미분류 명령은 default-deny이므로 이 SDK에서는 지금 적용할 수 없습니다. 이 대안이 필요하면 command 분류, typed API, ACL, production composition, Sentinel qualification을 먼저 추가해야 하며, 현재 운영 절차는 min-replicas-* 검증과 ambiguous write 대사에 한정해야 합니다.
9. health와 readiness는 “Redis가 한 대인가”가 아니라 “어떤 역할인가”를 묻습니다
health probe는 driver connection의 isOpen() flag를 믿지 않고 regular lane을 빌려 실제 PING round trip을 수행합니다. TCP가 단절을 아직 감지하지 못한 순간에도 실제 응답 여부를 확인하려는 선택입니다. health detail에는 mode, state, 예외 class name만 넣고 endpoint, username, key, payload를 넣지 않습니다. 구현은 RedisHealthContributor.java에 있습니다.
활성 역할에 따라 contributor가 달라집니다.
- cache만 사용하면
redisOptional이 생성됩니다. Redis가 끊기면DEGRADED이지만 readiness를 내리지 않습니다. - correctness 역할이 하나라도 Redis를 선택하면
redisRequired가 생성됩니다. Redis가 끊기면DOWN이며 readiness group에 포함됩니다.
redisRequired=UP은 timeout 안에 PING 한 번이 성공했다는 reachability 신호입니다. semantic script, 전체 ACL scope, module capability, CONFIG GET, min-replicas-*를 검증하지 않으며 미조립 startup probe를 대신하지 않습니다. redis-session selector가 required contributor를 만들 수 있다는 사실도 session provider가 존재한다는 증거가 아닙니다.
readiness group membership은 정적으로 redisRequired를 적지 않습니다. 동일한 correctness predicate를 읽는 environment post-processor가 contributor가 실제 생성될 때만 기존 readiness include 목록에 추가합니다. membership validation을 끄지 않기 때문에 오타나 존재하지 않는 contributor는 startup에서 드러납니다. RedisReadinessGroupPostProcessor.java와 RedisSdkAutoConfiguration.java를 함께 보면 흐름이 명확합니다.
운영 신호의 cardinality 정책
관측 이벤트는 command family, deployment mode, latency, 성공/실패와 ambiguity를 다루며 key, field, member, value를 metric label로 올리지 않습니다. tenant identifier가 dashboard로 새거나 label cardinality가 무한히 늘어나는 일을 막습니다.
따라서 “어느 command family가 느린가”는 metric으로 답하고, “어느 key가 hot한가”는 admin plane의 SLOWLOG와 특정 key의 MEMORY USAGE로 조사합니다. 아래 표는 SDK가 정의한 신호의 해석입니다. 미조립 guard·translator에서 나오는 신호가 관찰되지 않는다고 해서 위반이나 ambiguous execution이 없었다고 판단하면 안 됩니다.
| 신호 | 해석 | 1차 대응 |
|---|---|---|
RedisCommandRejectedException |
SDK가 전송 전에 bound·policy 위반을 거절 | reason에 나온 budget, permit, namespace를 수정 |
RedisCrossSlotException |
multi-key가 여러 slot에 분산 | shared hash tag 설계 점검 |
RedisAmbiguousExecutionException |
write 적용 여부 불명 | 자동 재시도 중단, 대사·보상 |
RedisCapabilityUnavailableException |
server capability와 선언 불일치 | version, module, startup probe 확인 |
SentinelFailoverObserver.ambiguousWriteCount |
promotion 근처 non-retry-safe write | 건별 reconciliation workload 산정 |
ClusterTopologyObserver.reshardingObserved |
ASK·TRYAGAIN 관찰 |
migration 종료까지 latency 편차 감시 |
저장소의 alert 해석표는 operations.md에 있습니다.
10. deterministic test와 real topology qualification을 분리합니다
기본 Gradle test는 redis-topology tag를 제외합니다. 설정, policy, typed API, key rendering, slot 계산, exception translation, composition은 빠른 deterministic test로 검증하고, Sentinel promotion·Cluster redirect·ACL·TLS처럼 실제 server와 driver가 결정하는 동작은 별도 lane으로 보냅니다. 태그 분리는 cache-redis/build.gradle에 있습니다.
현 HEAD에는 59개의 Redis module test class가 있고, 실 Redis server를 사용하는 topology class는 다음 여덟 개입니다. root 작업 세션에서 기본 :adapter:outbound:cache-redis:test는 성공했지만, 아래 topology class를 선택하는 lane은 실행하지 않았습니다.
LiveRedisSemanticPortsTestLiveRedisCompositionTestRedisTopologyContractTestLiveRedisTlsTestLiveRedisClusterTransactionTestLiveRedisClusterTestLiveRedisGuardrailTestLiveRedisSentinelPromotionTest
이 lane은 Testcontainers를 test class 안에서 띄우는 방식이 아니라 infra/redis-sdk/<lane>/compose.yml로 외부 topology를 시작하고 endpoint를 Gradle property로 전달합니다.
lane별 qualification 범위
| lane | fixture | 핵심 검증 | task의 최소 실행 건수 |
|---|---|---|---|
| standalone | Redis 1대 | composition, semantic port, ACL, guardrail | 20 |
| sentinel | data node 2대 + Sentinel 3대 | promotion, reconnect, write-loss bound | 20 |
| cluster | primary 3대 + replica 3대 | slot, cross-slot, redirect, transaction | 24 |
| tls | plaintext-off standalone | CA trust, hostname verification, command over TLS | 4 |
redisTopologyTest는 단순히 tag를 선택하지 않습니다. 알 수 없는 mode, 필수 endpoint·Sentinel master·TLS trust material 누락, 발견한 test 0건, 필수 class 누락, 최소 건수 미달, skip 한 건 이상을 모두 실패로 처리하고 매번 다시 실행합니다. cache-redis/build.gradle에 gate가 구현돼 있습니다.
fixture가 운영 배포를 뜻하지는 않습니다
qualification Compose에는 의도적인 제약이 있습니다.
- 모든 data node가 AOF와 snapshot을 끕니다.
- standalone은 replication과 persistence를 검증하지 않습니다.
- Sentinel과 Cluster는 topology가 광고한 주소를 host의 test client가 그대로 접근하도록 host networking과 고정 포트를 씁니다.
- Sentinel은 7010·7011과 27010
27012, Cluster는 71007105와 bus port 17100~17105를 점유합니다. - TLS 인증서는 하루짜리이고 mTLS client authentication은 fixture에서 끕니다.
즉 이 Compose는 topology behavior qualification 도구이지 production durability template가 아닙니다. lane의 목적과 port는 infra/redis-sdk/README.md, 실제 fixture는 standalone, sentinel, cluster, tls에서 확인할 수 있습니다.
11. CI 행렬은 “정의”와 “증거”를 나눠 읽어야 합니다
일반 quality workflow의 redis-sdk job은 다음을 실행하도록 정의돼 있습니다.
./gradlew \
:shared-contract:edgeRateLimitContractTest \
:adapter:outbound:cache-redis:check \
verifyCleanArchitectureDependencies \
verifyEnvKeys \
verifyPublicPathSnapshot \
verifyConfigurationPropertiesProcessor \
--no-daemon --stacktrace
정의 위치는 .github/workflows/ci-quality-gates.yml입니다. release gate는 이 redis-sdk job을 요구하지만 별도 topology workflow의 결과를 직접 needs로 묶지는 않습니다. 따라서 일반 release gate 성공과 모든 real topology lane의 최신 성공은 같은 명제가 아닙니다.
별도 redis-sdk-topology workflow는 다음 행렬을 실행하도록 정의합니다.
- Redis 관련 PR: standalone 7.4
- nightly 및 release-candidate: standalone·Sentinel·Cluster의 7.2, 7.4, 8.2
- nightly 및 release-candidate: TLS의 7.4, 8.2
각 job은 topology, Redis version, commit SHA, workflow run ID, Redis image digest를 manifest로 남기고 JUnit 결과와 함께 90일 보존하도록 정의돼 있습니다. workflow는 .github/workflows/redis-sdk-topology.yml에 있습니다.
그러나 workflow에 행이 있다는 사실은 통과 이력이 아닙니다. 현 support-matrix.md는 7.4의 standalone·Sentinel·Cluster 과거 evidence만 명시하고 7.2와 8.2는 declared but not certified라고 적습니다. 반면 infra/redis-sdk/README.md는 TLS를 포함한 네 lane 모두 7.4에서 실행됐다고 기록합니다. 즉 TLS에는 infra README의 과거 실행 기록이 있지만 support matrix의 certified table에는 TLS row가 없습니다. 승인 source를 하나로 정하고 artifact로 대조하기 전에는 TLS 7.4도 certified로 강화하지 않습니다.
따라서 지원 버전 승인은 다음 증거를 함께 확인해야 합니다.
- 해당 commit의 topology artifact가 존재합니다.
- manifest의 topology, Redis version, image digest가 승인 대상과 일치합니다.
- JUnit XML에 skip이 없고 Gradle minimum test floor를 충족합니다.
support-matrix.md의 certified row와 test class가 artifact와 일치합니다.- 문서 행만 있고 artifact가 없으면 “declared”로 남깁니다.
12. 플랫폼 운영 runbook
배포 전 확인 순서
- 역할을 먼저 정합니다. 현 HEAD에서 조립되는 cache·idempotency·rate-limit·lease 4개 중 필요한 역할을 정합니다.
redis-session은 Redis repository와 최초 인증 mechanism을 모두 구현하고 end-to-end로 검증하기 전까지 선택하지 않습니다. - 전역 스위치를 맞춥니다. 역할이 Redis를 선택하면
APP_REDIS_ENABLED=true가 필요합니다. - namespace를 고정합니다. environment, service, domain이 ACL
~pattern과 일치하는지 확인합니다. - topology를 명시합니다. standalone, Sentinel, Cluster 중 하나를 선택하고 endpoint의 의미가 data node인지 Sentinel인지 구분합니다.
- credential role을 설계합니다. application, advanced, pub/sub, admin, raw, Sentinel control account의 실제 분리가 필요한지 결정하고 reference를 secret backend에 연결합니다.
- TLS를 검증합니다. hostname verification을 기본적으로 유지하고 private CA material의 mount path와 읽기 권한을 확인합니다.
- replicated write durability를 확인합니다. primary가 될 수 있는 모든 노드에서
min-replicas-to-write와min-replicas-max-lag를 조회합니다. - timeout과 capacity를 서비스 SLO에 맞게 조정합니다. 늘리기 전에 느린 command를 숨기는지, outage queue를 키우는지 검토합니다.
- readiness 구성을 확인합니다. cache-only 배포는
redisOptional, correctness 역할 배포는redisRequired가 의도대로 존재해야 합니다.redisRequired=UP은PINGreachability만 뜻하므로 capability·ACL·min-replicas-*는 별도로 검증합니다. - 멱등성 effect 경계를 확인합니다. Redis V2의 same retained attempt가 action을 다시 실행할 수 있고 long-running action의 processing lease도 현재 renew되지 않습니다. effect 자체의 idempotency, effect-point CAS 또는 outbox 같은 별도 경계가 없다면 correctness capability로 승인하지 않습니다.
- 대상 버전·topology artifact를 확인합니다. workflow 정의가 아니라 실제 manifest와 JUnit result를 확인합니다.
로컬 qualification 실행
다음 명령은 저장소 루트에서 각각 독립적으로 실행할 수 있습니다. 이번 작업에서는 기본 module test만 성공했으며 topology lane은 실행하지 않았습니다.
Standalone:
# 저장소 루트에서 실행
REDIS_VERSION=7.4 docker compose -f infra/redis-sdk/standalone/compose.yml up -d --wait
(cd src && ./gradlew :adapter:outbound:cache-redis:redisTopologyTest \
-Predis.topology.host=localhost \
-Predis.topology.port=6379 \
-Predis.topology.mode=standalone \
--console=plain)
Sentinel:
# 저장소 루트에서 실행
REDIS_VERSION=7.4 docker compose -f infra/redis-sdk/sentinel/compose.yml up -d --wait
(cd src && ./gradlew :adapter:outbound:cache-redis:redisTopologyTest \
-Predis.topology.host=localhost \
-Predis.topology.port=27010 \
-Predis.topology.mode=sentinel \
-Predis.topology.master=skeleton \
--console=plain)
Cluster:
# 저장소 루트에서 실행
REDIS_VERSION=7.4 docker compose -f infra/redis-sdk/cluster/compose.yml up -d --wait
(cd src && ./gradlew :adapter:outbound:cache-redis:redisTopologyTest \
-Predis.topology.host=localhost \
-Predis.topology.port=7100 \
-Predis.topology.mode=cluster \
--console=plain)
TLS:
# 저장소 루트에서 실행
REDIS_VERSION=7.4 docker compose -f infra/redis-sdk/tls/compose.yml up -d --wait
docker compose -f infra/redis-sdk/tls/compose.yml \
cp redis:/tls/ca.crt /tmp/redis-lane-ca.pem
(cd src && ./gradlew :adapter:outbound:cache-redis:redisTopologyTest \
-Predis.topology.host=127.0.0.1 \
-Predis.topology.port=6390 \
-Predis.topology.mode=tls \
-Predis.topology.trust-material=/tmp/redis-lane-ca.pem \
--console=plain)
종료할 때는 실제로 실행한 lane만 지정합니다. down -v는 해당 테스트 fixture의 volume과 데이터까지 제거합니다.
# 저장소 루트에서 실행
REDIS_LANE=standalone # sentinel, cluster, tls 중 실행한 lane으로 변경
case "${REDIS_LANE}" in
standalone|sentinel|cluster|tls) ;;
*) echo "unsupported Redis lane: ${REDIS_LANE}" >&2; exit 2 ;;
esac
docker compose -f "infra/redis-sdk/${REDIS_LANE}/compose.yml" down -v
원본 명령과 topology별 endpoint 설명은 infra/redis-sdk/README.md와 infra/redis-sdk/README.md에 있습니다.
장애 시 분기
redisOptional=DEGRADED이고 correctness 역할이 없다면 pod를 제거하기 전에 원본 저장소 부하와 cache bypass율을 확인합니다.redisRequired=DOWN이면 신규 traffic을 받지 않게 하고 Redis endpoint와 TLS 상태를 확인합니다. 반대로UP이어도 확인된 것은PINGreachability뿐이므로 ACL·capability·durability는 별도 검사 결과를 봅니다.NOREPLICAS가 증가하면 write를 억지로 재시도하기보다 replica 연결·lag와min-replicas-*를 복구합니다. 이는 silent loss를 막는 의도된 거절입니다.- ambiguous write가 발생하면 command family별 reconciliation 절차를 실행합니다. increment, charge, enqueue 같은 non-idempotent write는 단순 재시도하지 않습니다.
ASK·TRYAGAIN과 resharding observer가 함께 보이면 slot migration 진행 상태와 tail latency를 확인합니다.- blocking lane만 포화되면 consumer 수와 max connection을 비교하고 regular lane 상태를 별도로 봅니다.
NOSCRIPT가 발생하면 semantic script는 request path에서 한 번 자동으로 reload·재평가됩니다. 계속 실패하면 caller가 반복 재시도하지 말고 advanced credential의SCRIPT LOAD·EVALSHAACL, Redis의 script cache flush·restart, 배포된 script source와 digest 상태를 확인합니다.
13. 업그레이드와 rollback gate
Redis server나 Lettuce 버전 변경은 일반 dependency bump로 다루기 어렵습니다. command metadata, reply shape, ACL category, driver failover behavior가 함께 달라질 수 있기 때문입니다. 저장소의 upgrade-guide.md는 다음 순서를 요구합니다.
1단계: command metadata diff
새 server가 보고하는 모든 command를 redis-command-policy.yml과 비교합니다. 새 command가 자동 허용되지는 않지만, upstream에서 기존 command의 risk가 달라졌는데 local catalog가 오래된 경우를 찾아야 합니다.
2단계: ACL regression
모든 account와 SDK가 발행할 수 있는 command 조합을 ACL DRYRUN으로 확인합니다. Redis version이 command의 ACL category를 바꾸면 첫 실요청에서야 권한 오류가 날 수 있습니다.
3단계: serializer golden bytes
새 코드의 round-trip만 보지 말고 이전 version이 쓴 byte를 새 version이 decode하는지 확인합니다. 저장 형식 변경은 topology test와 별도의 data migration 문제입니다.
4단계: support matrix와 topology evidence
support-matrix.md를 갱신하고 standalone·Sentinel·Cluster·TLS 중 claim하는 lane을 실제로 실행합니다. 더 높은 version number가 이전 behavior를 자동으로 보장하지 않습니다.
5단계: rollback material 기록
변경 전 다음을 보존합니다.
- 이전 Redis server image와 digest
- 이전 Lettuce lock version
- 등록된 모든 script의
SCRIPT LOADdigest - topology별 JUnit evidence와 manifest
rollback 후 이전 script digest가 다시 resolve되는지 확인해야 합니다. process가 이전 server에 없는 digest를 cache하면 모든 scripted call이 NOSCRIPT로 실패할 수 있습니다. data shape가 바뀌는 upgrade는 이 gate의 범위 밖이므로 별도 migration·backfill·rollback plan이 필요합니다.
14. 현재 저장소가 운영 배포에 남겨 둔 공백
이 모듈은 application-side guardrail과 qualification에는 많은 결정을 담고 있지만, production Redis 자체를 배포하는 저장소는 아닙니다.
Redis가 기본 application Compose에 없습니다
루트 docker-compose.yml과 docker-compose.local.yml은 application과 PostgreSQL 중심이며 Redis service를 제공하지 않습니다. local compose가 읽는 .env에서도 Redis와 역할 selector는 기본적으로 비활성화돼 있습니다. 즉 개발자가 APP_REDIS_ENABLED=true만 켜도 함께 시작되는 Redis는 없습니다. 별도 instance나 qualification lane을 준비해야 합니다.
Redis용 Helm·Kubernetes·Kustomize 배포 정의가 없습니다
현 HEAD의 저장소 전체를 확인했지만 Redis용 chart, StatefulSet, Service, PDB, NetworkPolicy, PVC, backup job은 없습니다. 따라서 플랫폼 계층에서 최소한 다음을 별도로 소유해야 합니다.
- topology별 workload와 service discovery
- persistence와 storage class
- backup, restore, point-in-time 요구
- memory limit,
maxmemory, eviction policy - replica placement, anti-affinity, PDB
- TLS certificate 발급·rotation과 secret mount
- ACL user·password rotation
min-replicas-*의 모든 primary 후보 적용- monitoring, alert, maintenance와 resharding runbook
qualification fixture는 durability를 검증하지 않습니다
Standalone·Sentinel·Cluster·TLS fixture는 모두 AOF와 snapshot을 끕니다. container 종료 후 데이터 보존, disk full, AOF rewrite, RDB restore, backup consistency를 검증하지 않습니다. host networking과 고정 포트를 쓰는 Sentinel·Cluster lane은 로컬 qualification에 맞춘 선택이며 multi-tenant CI runner나 desktop 환경에서 port conflict가 날 수 있습니다.
Lease replay handle이 server lease보다 오래 살아 있다고 판단할 수 있습니다
same-attempt acquire replay에서 Lua는 TTL을 연장하지 않고 현재 PTTL을 반환합니다. 그러나 adapter는 그 PTTL을 버리고 request TTL로 local validity를 다시 만듭니다. Redis key가 곧 만료되더라도 replay handle은 더 오래 ACTIVE라고 판단할 수 있고, observedServerExpiry도 실제 server PTTL이 아닌 local 계산값입니다. 이는 fencing 부재와 별개의 local-validity 공백입니다. LeaseScripts.java, RedisDistributedLeaseAdapter.java
Idempotency V2는 same-attempt action을 한 번으로 합치지 못합니다
같은 owner·operation의 record가 이미 EXECUTING이어도 claim은 REPLAYED_ACQUIRE를 반환할 수 있고, executor는 ALREADY_STARTED_SAME_OPERATION이나 inspect의 EXECUTING_SAME_OPERATION을 action 실행 권한으로 해석합니다. 따라서 같은 retained attempt의 두 Java invocation이 action을 중복 실행할 수 있습니다. 또한 Redis renew는 EXECUTING -> EXECUTING transition이라 target-state 선검사에서 ALREADY로 끝나 leaseUntil, Redis TTL, revision을 갱신하지 않습니다. effect 자체가 idempotent하거나 effect-point CAS·outbox가 없다면 이 조립만으로 exactly-once 또는 correctness를 승인하면 안 됩니다. IdempotencyExecutorV2.java, IdempotencyScripts.java, RedisIdempotencyStoreAdapter.java
Redis session 구현이 완결되지 않았습니다
redis-session selector와 filter configuration은 있지만 redisVersionedSessionRepository bean의 실제 producer를 찾을 수 없습니다. web config test도 Redis repository 대신 MapSessionRepository를 주입합니다. 이 repository만 추가해도 완성되지는 않습니다. session branch는 CSRF, IF_REQUIRED, fixation migration, primitive context repository를 설정하지만 snapshot이 없는 요청에서 인증된 Authentication 객체를 최초로 만드는 production login mechanism은 확인되지 않습니다. 따라서 현 조립 상태는 5개 역할 중 4개이며, session은 persistence와 최초 인증 두 공백을 해결하고 end-to-end로 검증할 때까지 blocked입니다. correctness predicate가 redisRequired를 readiness에 넣더라도 provider나 인증 경로의 존재를 증명하지 않습니다. consumer 쪽 요구는 AuthenticationModeCompositionConfig.java, web 설정은 RedisSessionWebConfig.java, security branch는 SecurityConfig.java, test fixture는 RedisSessionWebConfigTest.java에서 확인할 수 있습니다.
raw credential isolation은 composition 연결을 재검토해야 합니다
현 HEAD는 raw credential을 해석해 RedisCredentialRole.RAW client를 만들 수 있지만, RedisConnectionKind에는 RAW lane이 없고 RAW_GATEWAY command access는 REGULAR lane으로 분류됩니다. 또한 LettuceRedisRawGateway의 production bean composition을 찾을 수 없습니다. 즉 설정·ACL fixture에 표현된 raw account가 실제 runtime path에 연결되는지는 완결된 조립 근거가 부족합니다. RedisConnectionKind.java, RedisSdkAutoConfiguration.java, LettuceRedisRawGateway.java를 함께 검토해야 합니다.
README와 registry를 code보다 먼저 믿으면 안 됩니다
현 module README는 client, semantic port, health가 아직 없다고 설명하지만 실제 현 HEAD에는 구현과 테스트가 있습니다. topology mode 설명도 TLS lane을 빠뜨립니다. support matrix의 Lettuce 6.8.2 기록은 실제 6.8.1 lock과 다르고, 일부 cache env key는 registry에서 orphaned라고 표시됐지만 application.yml이 계속 사용합니다. 운영 문서 갱신 전까지 우선순위는 다음처럼 두는 편이 안전합니다.
dependency lock / runtime code / executable test gate
> generated metadata와 env registry
> README와 과거 계획 문서
문서도 build gate의 일부여야 하지만, 현재는 서로 다른 시점의 사실이 섞여 있습니다.
마무리: Redis 운영 계약은 성공 경로보다 거절 경로에 드러납니다
이 Redis 모듈의 중심은 빠른 get/set wrapper가 아닙니다. Redis를 사용하지 않는 배포에는 리소스를 만들지 않고, 사용하는 배포에는 역할과 topology를 명시하게 합니다. cache와 correctness 역할에 서로 다른 readiness 정책을 적용하고 실제 PING reachability를 조립한 부분은 현 production 동작입니다. capability·permit·namespace·slot·budget·timeout admission과 실행 확실성 translator, Sentinel durability probe는 구현과 테스트가 있지만 production path에는 아직 연결되지 않았습니다.
동시에 production deployment는 아직 완성품이 아닙니다. Redis용 Helm/Kubernetes, persistence, backup/restore, eviction과 resource 정책, credential rotation이 없고, session persistence·최초 인증과 일부 secret·raw composition 계약에는 공백이 있습니다. Lease replay의 local validity와 Idempotency V2의 same-attempt 중복 실행·renew도 운영 승인 전에 보완하거나 상위 effect 경계로 제한해야 합니다. CI workflow가 넓은 version matrix를 정의하지만 실제 certification은 artifact와 support matrix가 함께 증명해야 합니다. Lettuce도 문서의 6.8.2가 아니라 lockfile의 6.8.1.RELEASE가 현재 기준입니다.
플랫폼 팀이 이 템플릿을 채택할 때의 완료 조건은 “애플리케이션이 Redis에 연결됐다”가 아닙니다. 4/5 capability 상태와 session 차단을 명시하고, 역할별 failure policy, 모든 primary 후보의 durability 설정, ACL과 TLS, lane별 capacity, 실제 topology evidence, 복구 가능한 persistence, upgrade와 rollback artifact를 하나의 운영 계약으로 맞춰야 합니다. 여기에 현재 미조립인 capability·durability probe와 command guard를 production path에 연결하고 검증하는 작업도 포함됩니다.
시리즈에서 다시 찾기
-
전체 지도: 「Redis를 범용 클라이언트가 아니라 정책 경계로 다루기」
-
런타임 조립: 「app.redis.enabled에서 capability bean까지」
-
장애 판정: 「같은 Redis 장애가 DEGRADED와 DOWN으로 갈리는 코드」