refactor: 문서 개선 중

This commit is contained in:
donghyeon-ka
2026-09-21 14:30:55 +09:00
parent c93cdea150
commit 805a18f486
1497 changed files with 525837 additions and 59152 deletions
+571
View File
@@ -0,0 +1,571 @@
# Redis를 범용 클라이언트가 아니라 정책 경계로 다루기
> **Redis 코드 상세 시리즈 01/20** · 다음: [Redis 모듈 해부: Gradle leaf에서 app-bootstrap까지](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-module-package-boundaries.md) · 마지막: [Redis를 켠다는 말의 운영적 의미](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-platform-sre-operations.md)
> 이 글은 `document-haness/.run/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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfiguration.java:53), [RedisCapabilityConfig.java](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java:54), [cache-redis build.gradle](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/build.gradle:6)에서 확인할 수 있습니다. 반면 모듈의 기존 [README.md](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/README.md:23)는 여러 세대의 설계가 섞여 있으므로 현행 구현의 SSOT로 사용하지 않는 편이 안전합니다.
## Redis 코드 상세 시리즈 20편
이 글은 20편의 출발점이자 전체 지도입니다. 처음 읽는다면 01→06에서 모듈과 런타임 조립을 잡고, 07→12에서 SDK의 정책 경계를 따라간 뒤, 13→19에서 capability와 검증 코드를 읽는 순서가 자연스럽습니다. 특정 문제를 조사하는 중이라면 아래 표에서 바로 해당 글로 이동해도 됩니다.
| 순서 | 문서 | 코드에서 확인할 경계 |
| ---: | --- | --- |
| 01 | **현재 글 — Redis를 범용 클라이언트가 아니라 정책 경계로 다루기** | 전체 구조, 구현 상태, 정책의 출발점 |
| 02 | [Redis 모듈 해부: Gradle leaf에서 app-bootstrap까지](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-module-package-boundaries.md) | Gradle leaf, package, bootstrap 의존 방향 |
| 03 | [app.redis.enabled에서 capability bean까지: Spring 조립 코드 읽기](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-spring-composition.md) | auto-configuration, 조건부 bean, 4/5 capability |
| 04 | [Redis 설정은 어떻게 실패하는가: 바인딩·검증·Secret·Credential 추적](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-settings-secrets-credentials.md) | 설정 검증, secret 해석, 역할별 credential |
| 05 | [하나의 설정에서 세 topology로: RedisTopologyClientFactory 코드 읽기](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-topology-client-factory.md) | standalone, Sentinel, Cluster 생성 분기 |
| 06 | [Redis 연결을 여섯 lane으로 나눈 이유: Pool과 RuntimeOwner 생명주기](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-connection-lanes-lifecycle.md) | lane별 pool, borrow·drain·close, capacity |
| 07 | [YAML 한 줄이 Redis 명령을 거절하기까지: Policy Loader·Catalog·Guard](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-command-policy-admission.md) | command SSOT, default-deny, admission 순서 |
| 08 | [Raw key와 영구 쓰기를 막는 코드: Namespace·Hash Slot·TTL](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-keyspace-expiration.md) | typed key, namespace, same-slot, expiration |
| 09 | [Redis 값의 스키마를 코드로 고정하기: Registry·Envelope·Version](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-codec-schema-evolution.md) | codec registry, framing, version 실패 |
| 10 | [문자열 명령 대신 타입을 노출하는 RedisOperations 코드 지도](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-typed-operations.md) | operation 요청 모델, driver 변환, reply 한계 |
| 11 | [Batch·Transaction·Script·Function·Pub/Sub·Admin·Raw를 분리한 이유](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-advanced-surfaces.md) | 고급 surface별 권한·연결·budget 경계 |
| 12 | [Timeout 뒤 쓰였는지 모를 때: Executor와 실행 확실성 모델](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-execution-failure-certainty.md) | guard→driver→translator, retryable·ambiguous |
| 13 | [Redis 캐시 한 요청의 전 생애: Generation·Envelope·Soft/Hard TTL](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-cache-code-walkthrough.md) | lookup·record·invalidate, stale와 generation 공백 |
| 14 | [세 가지 Redis Rate Limit Lua를 코드로 추적하기](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-rate-limit-code-walkthrough.md) | fixed·sliding·token bucket 원자 연산 |
| 15 | [Redis Lease는 왜 Lock이 아닌가: Acquire·Renew·Release 코드 읽기](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-lease-code-walkthrough.md) | efficiency lease, 불확실 상태, fencing 부재 |
| 16 | [Redis Idempotency V2 상태 머신: Claim에서 Replay까지](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-idempotency-v2-code-walkthrough.md) | Lua 상태 전이, owner·operation, 중복 실행 위험 |
| 17 | [Redis Session 요청은 어디에서 멈추는가: Web 설정과 미완성 Repository](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-session-composition-gap.md) | web·security 조립과 repository·인증 공백 |
| 18 | [같은 Redis 장애가 DEGRADED와 DOWN으로 갈리는 코드](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-health-readiness-observability.md) | optional·required health, readiness, 관측 공백 |
| 19 | [Redis 테스트가 증명하는 것과 증명하지 않는 것](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-testing-topology-ci.md) | 단위·계약·실서버 lane, 지원 근거의 범위 |
| 20 | [Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-platform-sre-operations.md) | 운영 계약, 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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/config/architecture/modules.json:115)과 [app-bootstrap build.gradle](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/build.gradle:61)에 있습니다.
구조를 호출 방향으로 정리하면 다음과 같습니다.
~~~text
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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRegionPort.java:7), HTTP edge 제한은 [EdgeRateLimitPort.java](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/shared-contract/src/main/java/dev/caskeleton/shared/ratelimit/EdgeRateLimitPort.java:9), 리스는 [DistributedLeasePort.java](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/lease/DistributedLeasePort.java:9), owner-safe 멱등성은 [IdempotencyStorePortV2.java](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyStorePortV2.java:13)를 기준으로 호출합니다.
실제 공개 시그니처도 provider 명령보다 업무 의미를 먼저 드러냅니다.
~~~java
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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/build.gradle:33)은 spring-data-redis 의존을 의도적으로 제외하고, 자체 typed API와 command policy를 우회하는 untyped command surface를 만들지 않겠다고 기록합니다.
이 모듈이 해결하려는 제약은 다음과 같습니다.
1. 모든 물리 키에 같은 namespace와 크기 제한을 적용해야 합니다.
2. ordinary value `SET` 계열처럼 정책이 적용된 쓰기에서는 expiration을 생략하지 못하게 해야 합니다.
3. R2 수준 명령은 permit과 request/reply budget이 있을 때만 실행해야 합니다.
4. Cluster의 multi-key 작업은 전송 전에 same-slot을 확인해야 합니다.
5. blocking, transaction, Pub/Sub, script, admin은 connection과 ACL 경계를 분리해야 합니다.
6. timeout 또는 연결 손실 이후 mutation의 실행 여부를 함부로 성공이나 실패로 바꾸지 않아야 합니다.
7. 모듈 명령과 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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfiguration.java:54)에 있습니다.
전역 스위치만 켠다고 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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RedisActivationValidator.java:58)는 전역 Redis가 꺼진 상태에서 Redis provider를 선택하면 startup을 실패시킵니다. selector가 전역 스위치를 암묵적으로 켜지 않으므로, 설정 누락이 첫 요청의 bean 부재나 연결 오류로 늦게 나타나지 않습니다.
개념을 보여 주는 최소 설정은 다음과 같습니다. credential 값이 아니라 secret reference를 설정한다는 점이 중요합니다.
~~~yaml
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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisCredentialResolver.java:45), 전체 설정 검증은 [RedisSdkSettings.java](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettings.java:58), 기본 capability 설정은 [application.yml](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/main/resources/application.yml:327)에서 확인할 수 있습니다.
애플리케이션 계정 외에 advanced, Pub/Sub, raw, admin 계정을 별도로 지정할 수 있습니다. 설정된 계정은 client 생성 전에 해결됩니다. raw와 admin을 활성화했는데 전용 credential reference가 없으면 startup이 실패합니다. advanced account가 없으면 application account가 script 권한까지 가져야 한다는 경고가 남습니다.
## 4. topology와 connection lane을 한 client처럼 다루지 않습니다
[RedisTopologyClientFactory.java](/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:154)는 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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/docs/redis/support-matrix.md:60)에 기록된 certified 실서버 증거는 Redis 7.4에서 실행한 standalone, Sentinel, Cluster 세 topology의 결과입니다. TLS transport lane도 Redis 7.4에서 실행됐다는 기록은 [infra/redis-sdk/README.md](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/infra/redis-sdk/README.md:7)에 있지만 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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/connection/RedisRuntimeOwner.java:123)는 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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisCorrectnessRoles.java:32)와 [RedisReadinessGroupPostProcessor.java](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RedisReadinessGroupPostProcessor.java:54)에 있습니다.
## 5. typed API와 semantic API는 용도가 다릅니다
semantic port는 application use case가 사용합니다. typed SDK는 Redis 자료구조를 안전한 primitive로 제공하기 위한 표면입니다. [RedisOperations.java](/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/api/RedisOperations.java:24)는 values, hashes, lists, sets, sortedSets, bitmaps, bitFields, hyperLogLogs, geo, streams, keys, batches 그룹을 노출합니다. [ReactiveRedisOperations.java](/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/api/ReactiveRedisOperations.java:23)도 같은 방향의 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 경로의 자동 조합을 가정하면 안 됩니다.
~~~java
// 계약은 존재하지만 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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/resources/redis-sdk/redis-command-policy.yml:20)은 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](/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/command/RedisCommandPolicyLoader.java:25)는 일반 YAML parser처럼 느슨하게 읽지 않습니다. anchor, merge, 중복 command, 알 수 없는 field와 잘못된 enum을 거부합니다. [RedisCommandCatalog.java](/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/command/RedisCommandCatalog.java:62)는 모르는 명령을 default deny합니다.
[CommandPolicyGuard.java](/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/command/CommandPolicyGuard.java:89)를 SyncRedisCommandExecutor 또는 ReactiveRedisCommandExecutor와 함께 조합했을 때의 admission 순서는 다음과 같습니다.
1. command가 catalog에 있고 차단되지 않았는지 확인합니다.
2. 현재 server version과 배포 mode가 command capability를 만족하는지 확인합니다.
3. R2 operation permit의 발급 주체와 policy name을 확인합니다.
4. 모든 key가 허용 namespace에 속하는지 확인합니다.
5. Cluster multi-key 작업이 같은 slot인지 확인합니다.
6. 예상 element 수, request bytes, reply bytes가 operation budget 안인지 확인합니다.
7. caller timeout과 command profile 중 더 짧은 effective timeout을 계산합니다.
8. 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입니다.
~~~text
{environment}:{service}:{domain}
~~~
[RedisNamespace.java](/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/api/key/RedisNamespace.java:15)는 세 token을 소문자 영숫자와 하이픈 규칙으로 검증합니다. [QualifiedRedisKey.java](/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/api/key/QualifiedRedisKey.java:9)는 SDK가 받는 유일한 logical key 형태입니다. 이미 렌더링한 임의 문자열을 넣는 공개 overload가 없습니다.
[RedisKeyRenderer.java](/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/api/key/RedisKeyRenderer.java:42)가 만드는 물리 형식은 다음과 같습니다.
~~~text
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](/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/api/key/RedisKeyRules.java:10)는 e-mail, JWT 형태, 국제 전화번호, bearer token처럼 식별 가능한 민감 정보 패턴을 거부합니다. 다만 짧은 숫자처럼 겉모양만으로 개인정보 여부를 판단할 수 없는 값은 호출자가 먼저 pseudonymize해야 합니다.
semantic adapter는 [CapabilityKeyspace.java](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/keyspace/CapabilityKeyspace.java:48)를 사용해 다음 형식을 만듭니다.
~~~text
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](/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/codec/RedisCodecRegistry.java:15)에 schema를 명시적으로 등록합니다. 중복 schema를 거부하고, 조회 시 등록한 Java type과 요청 type이 일치하는지 검사합니다. class name을 저장 값에서 읽어 decoder를 동적으로 고르는 경로가 없습니다.
[VersionedJsonCodec.java](/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/codec/VersionedJsonCodec.java:64)은 다음 네 field의 envelope를 사용합니다.
~~~json
{
"schema": "order-summary",
"version": 1,
"createdAt": "2026-08-07T00:00:00Z",
"payload": "base64..."
}
~~~
framing은 [JsonEnvelopeFraming.java](/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/codec/JsonEnvelopeFraming.java:39)에서 고정 순서로 기록하고 정확히 네 field만 읽습니다. schema나 readable version이 맞지 않으면 cache miss처럼 넘기지 않고 serialization failure로 처리합니다. encode 전과 decode 전에 maxValueBytes도 확인합니다.
semantic cache는 이 일반 JSON codec과 다른 [CacheEnvelope.java](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/CacheEnvelope.java:32)를 사용합니다. 현재 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](/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/api/operations/Expiration.java:15)의 다음 선택지 중 하나를 받습니다.
- 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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:53)는 CacheRegionPort를 구현합니다. 물리 키는 다음과 같습니다.
~~~text
namespace:cache:v{keyVersion}:{region}:{hmacDigest}
namespace:cache:v{keyVersion}:{region}:generation
~~~
lookup 흐름은 다음과 같습니다.
1. REGULAR lane connection을 빌립니다.
2.`CacheKeys`가 아직 unresolved일 때만 `INCRBY generation 0`으로 server generation을 최초 한 번 읽고, 이후에는 instance-local generation을 사용합니다.
3. cache key를 GET하고 envelope를 decode합니다.
4. envelope generation이 현재 값과 다르면 invalidated miss로 처리합니다.
5. hard expiry가 지났으면 miss로 처리합니다.
6. absence envelope이면 negative hit를 반환합니다.
7. soft expiry 전이면 fresh, soft expiry 이후 hard expiry 전이면 stale을 반환합니다.
Redis 연결·timeout 오류는 ordinary miss로 합치지 않고 unavailable outcome으로 반환합니다. cache-aside orchestration은 [CacheAsideExecutor.java](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:53)가 담당합니다.
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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RedisEdgeRateLimitAdapter.java:84)는 [RateLimitScripts.java](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/ratelimit/RateLimitScripts.java:148)의 Lua를 SCRIPT lane에서 실행합니다. 지원 algorithm은 fixed-window, sliding-counter, token-bucket입니다.
한 요청의 읽기·계산·갱신을 하나의 Lua 실행에 넣어 concurrent 요청 사이의 원자성을 확보합니다. EVALSHA에서 NOSCRIPT가 오면 script를 load하고 한 번만 다시 실행합니다. key에는 policy ID, policy revision, subject digest가 포함됩니다.
흐름은 다음과 같습니다.
1. policy ID가 설정 map에 있는지 확인합니다.
2. 요청 cost가 policy maximumCost를 넘지 않는지 확인합니다.
3. caller deadline이 이미 끝났으면 command를 보내지 않습니다.
4. SCRIPT lane에서 해당 algorithm Lua를 평가합니다.
5. reply를 allowed, limit, remaining, retryAfter, resetAt으로 변환합니다.
6. unknown policy나 잘못된 cost는 incompatible, Redis failure는 unavailable 계열 outcome으로 보존합니다.
failure policy는 fail-closed만 허용합니다. [RedisCapabilityConfig.java](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java:198)는 다른 값을 설정하면 startup을 실패시킵니다. inbound 쪽의 [EdgeRateLimitTransportBridge.java](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportBridge.java:57)는 principal, API key, client IP와 operation을 [VersionedEdgeSubjectPseudonymizer.java](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/VersionedEdgeSubjectPseudonymizer.java:29)로 HMAC 처리한 후 provider에 전달합니다. [RateLimitInterceptor.java](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitInterceptor.java:52)는 결과를 통과, 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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/RedisDistributedLeaseAdapter.java:43)와 [LeaseScripts.java](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/lease/LeaseScripts.java:15)는 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 형식은 다음과 같습니다.
~~~text
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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/RedisIdempotencyStoreAdapter.java:52)는 [IdempotencyScripts.java](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/idempotency/IdempotencyScripts.java:16)의 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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyExecutorV2.java:137)는 confirmed start 뒤 action을 실행하고 mutation이 모호하면 inspect로 reconcile합니다. 그러나 같은 retained attempt가 이미 `EXECUTING`인 record를 다시 만나거나, 불확실한 응답 뒤 inspect가 `EXECUTING_SAME_OPERATION`을 반환하면 action을 다시 호출할 수 있습니다. 이 상태 머신만으로 exactly-once를 보장한다고 해석하면 안 됩니다.
key는 다음 정보를 포함합니다.
~~~text
namespace:idem:v{keyVersion}:d{digestVersion}:{operationCode}:{scopeDigest}
~~~
[IdempotencyScopeDigest.java](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/application-core/src/main/java/dev/caskeleton/application/idempotency/v2/IdempotencyScopeDigest.java:12)는 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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/IdempotencyKeySupport.java:17)는 기존 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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/RedisSessionWebConfig.java:11)는 @EnableSpringHttpSession을 활성화하고 Secure, HttpOnly, SameSite, path, session-only, Base64, host-only cookie 정책을 설정합니다.
[PrimitiveSessionSecurityContextRepository.java](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/PrimitiveSessionSecurityContextRepository.java:35)는 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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/security/AuthenticationModeCompositionConfig.java:22)는 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](/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/programmability/RedisTransactionOperations.java:6)는 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](/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/programmability/RedisScriptOperations.java:6)는 arbitrary script body를 인자로 받지 않습니다. deployment에서 검토·등록한 RegisteredRedisScript만 실행하며, script가 만지는 모든 key를 QualifiedRedisKey 목록으로 선언해야 합니다.
[RedisFunctionOperations.java](/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/programmability/RedisFunctionOperations.java:6)도 이미 배포된 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](/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/raw/RedisRawGateway.java:6)는 execute(String, byte[]...) 형태를 제공하지 않습니다. ApprovedRawCommand, bounded argument, RawCommandPolicyToken이 있어야 합니다. 설정에서 raw를 켜면 별도 credential과 readable allowlist resource가 필요합니다.
기본 raw policy resource 경로는 classpath:redis-sdk/raw-command-allowlist.yml이지만 이 모듈은 해당 파일을 기본으로 제공하지 않습니다. [RedisSdkAutoConfiguration.java](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfiguration.java:110)는 raw가 켜진 상태에서 resource가 없거나 읽을 수 없으면 startup을 실패시킵니다. 따라서 raw.enabled=true만 설정해 즉시 사용할 수 있는 기능이 아닙니다.
### Admin plane
[RedisAdminOperations.java](/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/admin/RedisAdminOperations.java:9)은 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](/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/extensions/ExtensionCommandRunner.java:17)에는 RedisJSON, Search, TimeSeries, probabilistic 자료구조용 interface와 Lettuce 구현이 있습니다. probabilistic 표면은 Bloom, Cuckoo, Count-Min Sketch, Top-K, t-digest 계열을 포함합니다.
[ExtensionCommandRunner.java](/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/extensions/ExtensionCommandRunner.java:69)는 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](/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/command/LettuceExceptionTranslator.java:26)는 typed SDK executor와 함께 조합됐을 때 timeout, connection loss, LOADING, BUSY, NOSCRIPT, READONLY, redirection, CROSSSLOT, WRONGTYPE, OOM, MISCONF 등을 안정된 RedisOperationException 하위 타입으로 바꿉니다. [RedisFailureMetadata.java](/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/api/error/RedisFailureMetadata.java:11)는 다음 정보를 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](/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/command/SyncRedisCommandExecutor.java:58)는 guard와 translator를 주입해 조합한 경로에서 guard를 통과한 뒤 driver invocation 구간의 예외만 실행 ambiguity 판단 대상으로 삼습니다. command가 성공한 뒤 observation sink가 실패했다고 해서 적용된 write를 Redis 실패로 바꾸지 않습니다. reactive class는 [ReactiveRedisCommandExecutor.java](/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/command/ReactiveRedisCommandExecutor.java:56)가 같은 원칙을 구현합니다.
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. 적용 전에 확인해야 할 조건
이 모듈을 실제 서비스에서 선택하려면 코드 존재 여부 외에 다음을 확인해야 합니다.
1. app.redis.enabled와 capability selector가 함께 설정되어야 합니다.
2. namespace environment/service/domain이 ACL key pattern과 일치해야 합니다.
3. application, advanced, Pub/Sub, raw, admin 계정의 권한을 실제 전송 command와 대조해야 합니다.
4. Sentinel은 master name, Cluster는 database 0과 same-slot key 계획이 필요합니다.
5. TLS trust material과 hostname verification 정책을 정해야 합니다.
6. command timeout, queue, in-flight command/bytes, blocking connection, transaction connection 상한을 workload에 맞게 검증해야 합니다.
7. cache key HMAC secret과 rate-limit subject HMAC secret의 rotation 전략을 정해야 합니다.
8. lease resourceDigest와 idempotency scopeDigest를 누가 생성하는지 application 경계에서 명시해야 합니다.
9. semantic response payload 크기 제한을 별도로 확인해야 합니다.
10. 사용하는 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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/infra/redis-sdk/README.md:27)와 [cache-redis build.gradle](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/build.gradle:68)에 있습니다.
이 문서를 검토한 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](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/docs/redis/support-matrix.md:53)에 기록된 기존 결과입니다. TLS 7.4는 [infra/redis-sdk/README.md](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/infra/redis-sdk/README.md:7)에 과거 실행 기록이 있지만 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에 직접 영향을 주는 저장소에서는 이 경계를 기능 목록보다 먼저 문서화해야 합니다.
## 시리즈에서 이어 읽기
- 다음 글: [Redis 모듈 해부: Gradle leaf에서 app-bootstrap까지](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-module-package-boundaries.md)
- SDK 정책부터 읽기: [YAML 한 줄이 Redis 명령을 거절하기까지](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-command-policy-admission.md)
- capability 코드부터 읽기: [Redis 캐시 한 요청의 전 생애](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-cache-code-walkthrough.md)
- 운영 관점으로 마무리하기: [Redis를 켠다는 말의 운영적 의미](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-platform-sre-operations.md)