22 KiB
Raw key와 영구 쓰기를 막는 코드: Namespace·Hash Slot·TTL
Redis 코드 상세 시리즈 08/20 · 전체 지도 · 이전: YAML 한 줄이 Redis 명령을 거절하기까지: Policy Loader·Catalog·Guard · 다음: Redis 값의 스키마를 코드로 고정하기: Registry·Envelope·Version
이 글이 답하는 코드 질문
호출자가 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입니다.