# Raw key와 영구 쓰기를 막는 코드: Namespace·Hash Slot·TTL > **Redis 코드 상세 시리즈 08/20** · [전체 지도](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-backend-policy-boundary.md) · 이전: [YAML 한 줄이 Redis 명령을 거절하기까지: Policy Loader·Catalog·Guard](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-command-policy-admission.md) · 다음: [Redis 값의 스키마를 코드로 고정하기: Registry·Envelope·Version](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-codec-schema-evolution.md) ## 이 글이 답하는 코드 질문 호출자가 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](/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:13) | environment, service, domain | namespace prefix | `QualifiedRedisKey` | | [QualifiedRedisKey](/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:16) | namespace, name, optional slot tag | 논리 key | renderer·guard | | [RedisKeyRenderer](/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:16) | qualified key | wire key 또는 slot source | gateway·slot calculator | | [RedisKeyRules](/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:16) | key part, rendered key | 검증된 문자열 | key value object | | [Expiration](/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) | permit, duration, instant | persistent/relative/absolute expiry | value request builder | | [RedisOperationContext](/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/operations/RedisOperationContext.java:119) | namespace, renderer, verifier, authority, limits | render·encode·permit helper | operation request builder | | [KeyOperationRequests](/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/operations/KeyOperationRequests.java:113) | key와 expiry 변경 요청 | guarded `CommandRequest` | executor | ## Key는 문자열이 아니라 구조입니다 `RedisNamespace`는 세 token을 가집니다. ```text environment : service : domain ``` [prefix](/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:21)는 `prod:order:shared` 같은 prefix를 만듭니다. 각 token은 lower-case alphanumeric과 `-`만 허용하며 길이는 1..64자입니다. `QualifiedRedisKey`는 다음을 묶습니다. - `RedisNamespace` - entity와 identifier를 가진 `RedisKeyName` - 선택적인 `RedisSlotTag` typed operation signature에는 이미 render된 `String key`가 없습니다. [QualifiedRedisKey의 경계](/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:6)는 namespace와 slot 검사를 건너뛸 public typed path를 만들지 않습니다. ## Renderer가 고정하는 wire 형식 [RedisKeyRenderer.render](/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:39)는 두 형식만 만듭니다. ```text plain: environment:service:domain:entity:identifier tagged: environment:service:domain:{slotTag}:entity:identifier ``` brace는 caller가 넣지 않고 renderer만 넣습니다. `RedisSlotTag` 자체는 [RedisKeyRules.requireIdentifier](/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/RedisSlotTag.java:12)을 통과해야 하므로 nested brace나 separator를 넣을 수 없습니다. `slotSource`는 tagged key에서 tag value만 반환하고, plain key에서는 전체 rendered key를 반환합니다. 이 값이 Redis Cluster CRC16 계산 입력입니다. ## Key rule이 잡는 것과 잡지 못하는 것 [RedisKeyRules](/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:18)은 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`/`eyj` prefix 금지 이 검사는 구조적으로 알아볼 수 있는 민감 정보만 거절합니다. `42` 같은 bare digit나 이미 fingerprint된 surrogate id는 개인 정보인지 기계적으로 판별할 수 없으므로 허용합니다. caller가 원본 식별자를 fingerprint해야 하는 책임은 남습니다. ## Request-time key 검증 순서 ```mermaid 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](/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:179)에서 각 key의 namespace를 process-bound namespace와 다시 비교하고 render합니다. 여러 key가 하나의 slot에 있어야 하는지는 topology에 따라 다릅니다. [requireSameSlot](/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:188)은 Cluster에서만 여러 slot을 `RedisCrossSlotException`으로 거절합니다. standalone과 Sentinel은 여러 slot 개념으로 요청을 막지 않습니다. ## Expiration은 세 상태를 표현합니다 [Expiration](/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)은 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](/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/operations/RedisOperationContext.java:243)이 `Persistent`를 발견하면 `persistent-key` policy에 대해 verifier를 호출합니다. 이 검사는 guard가 아니라 operation context에 있습니다. `SET`과 `PERSIST`는 catalog에서 R1이므로 guard의 R2 permit 검사에 걸리지 않습니다. [그 이유를 적은 코드](/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/operations/RedisOperationContext.java:225)가 별도 경계를 둔 이유를 설명합니다. ## Value write의 호출 순서 `LettuceRedisValueOperations.set`은 [ValueOperationRequests.set](/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/operations/ValueOperationRequests.java:80)으로 위임합니다. 1. key, value, expiration이 null인지 검사합니다. 2. `Expiration.Persistent`이면 permit provenance를 검증합니다. 3. key를 render합니다. 4. codec으로 value를 encode하고 byte ceiling을 검사합니다. 5. `SET` `CommandRequest`를 만듭니다. 6. invocation에는 `gateway.set(..., expiration)`을 지연 저장합니다. 7. executor가 guard admission 후 invocation을 실행합니다. `setIfAbsent`, `setIfPresent`, `getAndSet`, `getAndExpire`도 같은 expiration 경계를 사용합니다. nontransactional integer/double increment는 persistent면 `INCRBY`/`INCRBYFLOAT`, expiring이면 TTL을 함께 다루는 등록 script로 분기합니다. [increment 분기](/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/operations/ValueOperationRequests.java:139)를 보면 expiry를 increment 뒤 별도 명령으로 붙이는 race를 피합니다. 이 설명은 value API의 모든 write로 넓힐 수 없습니다. [APPEND request](/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/operations/ValueOperationRequests.java:182)와 [SETRANGE request](/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/operations/ValueOperationRequests.java:226)는 expiration이나 persistent permit을 받지 않습니다. 두 Redis 명령은 absent key를 새 string으로 만들 수 있으므로 TTL 없는 key가 생길 수 있습니다. transaction queue도 별도 경계입니다. transaction의 `set`은 expiration을 받지만 [queued increment](/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/LettuceRedisTransactionOperations.java:240)은 plain `INCRBY`만 enqueue합니다. 이어지는 hash/list/set/zset write도 expiry나 permit 없이 absent key를 만들 수 있습니다. nontransactional increment가 expiry-aware script로 분기한다는 계약을 transaction increment에 적용하면 안 됩니다. ## Expiry 변경 API의 정상·실패 분기 [ExpirationCondition](/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/ExpirationCondition.java:4)은 `ALWAYS`, `IF_NO_EXPIRY`, `IF_HAS_EXPIRY`, `IF_GREATER`, `IF_LESS`를 노출합니다. [ExpirationResult](/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/ExpirationResult.java:4)은 결과를 `APPLIED`, `CONDITION_NOT_MET`, `ABSENT`, `DELETED`로 구분합니다. ### 상대 TTL [KeyOperationRequests.expire](/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/operations/KeyOperationRequests.java:113)은 0 또는 음수 TTL을 전송하지 않습니다. Redis가 즉시 삭제하도록 맡기는 대신 “삭제는 명시적으로 호출하라”고 SDK에서 거절합니다. ### 절대 expiry `expireAt`은 현재 시각보다 과거인지 request builder에서 계산하고, server가 적용했다고 답하면 `DELETED`로 매핑합니다. 이 비교는 [Instant.now 사용 지점](/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/operations/KeyOperationRequests.java:134)에 있으며 injected `Clock`을 쓰지 않습니다. ### 영구 전환 `persist`는 [permit 검증 후 `PERSIST`](/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/operations/KeyOperationRequests.java:167)을 만듭니다. 위조 permit이면 server에 가지 않습니다. ## Raw gateway에서도 key 검사가 사라지지 않습니다 raw surface는 아무 byte sequence나 통과시키는 우회로가 아닙니다. [LettuceRedisRawGateway](/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/LettuceRedisRawGateway.java:89)는 policy `KeySpec`으로 key argument 위치를 찾고 `RedisOperationContext.parseKey`로 다시 qualified key를 만듭니다. [parseKey](/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/operations/RedisOperationContext.java:165)는 bound namespace prefix가 아니거나 key grammar가 틀리면 거절합니다. movable key 위치를 결정할 수 없는 shape도 best guess하지 않습니다. ## 테스트가 고정하는 계약 renderer 테스트는 [slot tag의 brace 위치](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisKeyRendererTest.java:13), [plain key 형식](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisKeyRendererTest.java:24), [tagged key의 공통 slot source](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisKeyRendererTest.java:34), [configured byte ceiling 초과 거절](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisKeyRendererTest.java:45), [1..512 밖의 maximum 거절](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisKeyRendererTest.java:58)을 각각 고정합니다. key rule 테스트는 [mail](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisKeyRulesTest.java:11), [JWT/auth material](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisKeyRulesTest.java:17), [international phone](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisKeyRulesTest.java:30), [separator injection](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisKeyRulesTest.java:36), [malformed namespace](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisKeyRulesTest.java:46)를 거절하고 [ordinary surrogate identifier](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/api/key/RedisKeyRulesTest.java:55)는 허용한다고 고정합니다. guard 쪽에서는 [foreign namespace가 전송되지 않는 사례](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/CommandPolicyGuardTest.java:158), [Cluster cross-slot의 client-side 거절](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/CommandPolicyGuardTest.java:168), [standalone의 slot 불일치 허용](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/command/CommandPolicyGuardTest.java:194)을 서로 다른 테스트가 고정합니다. [RedisRawGatewayContractTest의 namespace 사례](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/lettuce/operations/RedisRawGatewayContractTest.java:140)는 raw key도 parse-back과 namespace 검사를 통과해야 한다고 고정합니다. 이 테스트는 이번 문서 작업에서 실행하지 않았고 정적으로 읽었습니다. ## 현재 구현 공백과 잘못 읽기 쉬운 지점 1. `Expiration` Javadoc은 “every write”를 말하지만 TTL 의무는 전체 typed write에 완결되지 않았습니다. APPEND, SETRANGE, transaction INCRBY, transaction의 collection write뿐 아니라 [RedisHashOperations.put](/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/RedisHashOperations.java:35)과 [RedisListOperations.pushLeft](/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/RedisListOperations.java:18)도 expiration이나 persistent permit을 받지 않습니다. 2. `Expiration.At` constructor는 과거 시각을 거절하지 않습니다. `expireAt` 결과가 `DELETED`일 수 있습니다. 3. raw gateway는 approved command만 받지만, production raw approvals와 gateway bean 조립은 확인되지 않습니다. 4. aggregate `RedisOperations` production bean도 확인되지 않으므로 typed key 경계가 실제 application entry point로 조립됐다고 단정할 수 없습니다. 5. key rule은 인식 가능한 민감 정보만 잡습니다. caller-side pseudonymization 책임이 남습니다. 다음에 source를 열 때는 `RedisNamespace`, `QualifiedRedisKey`, renderer, rules, `RedisOperationContext`, value/key request builder 순으로 보면 됩니다. ## 시리즈의 관련 문서 관련 범위는 command admission, codec schema, typed operations, raw surface입니다. ## 시리즈에서 이어 읽기 - 이전 글: [YAML 한 줄이 Redis 명령을 거절하기까지: Policy Loader·Catalog·Guard](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-command-policy-admission.md) - 다음 글: [Redis 값의 스키마를 코드로 고정하기: Registry·Envelope·Version](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-codec-schema-evolution.md) - 전체 흐름: [Redis를 범용 클라이언트가 아니라 정책 경계로 다루기](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-backend-policy-boundary.md) - 운영 흐름: [Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-platform-sre-operations.md)