# Redis 캐시 한 요청의 전 생애: Generation·Envelope·Soft/Hard TTL > **Redis 코드 상세 시리즈 13/20** · [전체 지도](./redis-backend-policy-boundary.md) · 이전: [Timeout 뒤 쓰였는지 모를 때: Executor와 실행 확실성 모델](./redis-execution-failure-certainty.md) · 다음: [세 가지 Redis Rate Limit Lua를 코드로 추적하기](./redis-rate-limit-code-walkthrough.md) ## 이 글이 답하는 코드 질문 `ca-skeleton.capabilities.cache.bindings.default=redis`인 애플리케이션에서 캐시 조회 한 번은 어디에서 시작하고, 어떤 Redis 명령을 거쳐, 언제 원본 저장소로 내려갑니까? 이 글은 Spring이 만드는 `CacheRegionPort`와 애플리케이션의 `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`](src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java:95) | `RedisRuntimeOwner`, namespace, cache 설정, Secret, `Clock` | `CacheRegionPort` bean | `RedisCacheRegionAdapter` 생성자 | | [`CacheRegionPort`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRegionPort.java:7) | semantic key/value | typed lookup·record·invalidate 결과 | provider adapter | | [`CacheAsideExecutor.getOrLoad`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:53) | key, region, source loader | `CacheResult` | lookup, single-flight, source load, record | | [`RedisCacheRegionAdapter.lookup`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:118) | semantic key | `Hit`, `NegativeHit`, `Miss`, `IncompatibleSchema`, `Unavailable` | generation 확인, `GET`, envelope 해석 | | [`RedisCacheRegionAdapter.write`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:208) | value/absence, source revision, write intent | `CacheRecordOutcome` | 조건 확인 후 `SET` + TTL | | [`CacheEnvelope`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/CacheEnvelope.java:29) | schema, revision, generation, 두 expiry, absence, payload | pipe header + payload bytes | `interpret` | | [`CacheRefreshCoordinationPort`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshCoordinationPort.java:13) | 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:`입니다. 근거는 [`KeyDigest.of`와 `of`](src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java:312)에서 확인할 수 있습니다. 기본 설정은 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`](src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilitySettings.java:70) `CacheAsideExecutor`는 생성 시 region별 정책으로 local `CacheSingleFlight`와 `CacheSourceBulkhead`를 만듭니다. 2인자 생성자는 refresh coordinator를 주입하지 않습니다. 4인자 생성자만 coordinator와 `CacheRefreshCoordinationPolicy`를 받습니다. [`CacheAsideExecutor` 생성자](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:25) ## 요청 시 호출 순서 ```mermaid 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`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:322), [`CacheKeys.resolved`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:358) 예를 들어 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`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:383) ### 2. `GET` 결과를 다섯 종류로 나눕니다 저장값이 없으면 `Miss(ABSENT)`입니다. 값이 있으면 `CacheEnvelope.decode`가 여섯 개의 `|` 경계를 찾고 schema version, source revision, generation, soft/hard absolute epoch millis, absence marker와 payload를 복원합니다. 현행 schema는 v1입니다. [`CacheEnvelope.encode`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/CacheEnvelope.java:104) 해석 순서는 다음과 같습니다. 1. future schema는 adapter에서 `QUARANTINE_AND_RELOAD`로 분류합니다. 그러나 이 2인자 `IncompatibleSchema`에는 usable observation token과 write condition이 없습니다. 2. retired, unknown, corrupt envelope는 `FAIL_FAST`입니다. 3. envelope generation이 현재 generation과 다르면 `Miss(INVALIDATED)`입니다. 4. hard expiry가 지났으면 `Miss(EXPIRED)`입니다. 5. absence marker가 있으면 `NegativeHit`입니다. 6. 그 밖에는 soft expiry 전이면 `FRESH`, soft와 hard 사이면 `STALE`입니다. 이 순서는 [`interpret`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:144)에 그대로 드러납니다. 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`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheLookup.java:94), [`getOrLoad`의 schema 분기](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:81) ### 3. fresh와 negative는 source를 호출하지 않습니다 `CacheAsideExecutor.getOrLoad`는 `FRESH`를 `FreshHit`로, `NegativeHit`를 그대로 반환합니다. stale 값은 hard expiry와 observation token을 가진 후보로 보존합니다. miss와 unavailable은 source refill 대상으로 넘어갑니다. [`getOrLoad` 분기](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:59) 같은 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`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:206) 새 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`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:230) `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`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordMetadata.java:6), [`executor의 metadata 전달`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:231), [`write`의 조건 비교](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:208) 따라서 감지 범위는 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`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:290), [`observeGeneration`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:412) ## stale refresh와 실패 분기 `CacheAsideExecutor`는 stale source load가 transient failure이고 policy가 허용하며 hard expiry 전이면 `StaleFallbackAfterTransientFailure`를 반환합니다. permanent failure에는 stale을 쓰지 않습니다. [`toResult`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:346) 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`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:144) 이 executor는 비동기 background refresh scheduler가 아닙니다. owner가 동기 refresh를 수행하고 contender만 stale을 즉시 받습니다. hard miss의 bounded wait는 `Thread.sleep` 뒤 한 번 다시 읽는 구현입니다. [`boundedWait`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:300) Redis 장애는 cache에 한해 degraded로 처리됩니다. lookup은 `Unavailable(UNAVAILABLE, NOT_APPLIED)`, record와 invalidation은 `DEGRADED_UNAVAILABLE`을 반환합니다. cache miss처럼 source로 내려갈 수 있다는 정책입니다. 다만 `CacheRecordOutcome`과 `CacheInvalidationOutcome`에는 `INDETERMINATE`가 정의되어 있어도 이 adapter의 catch-all은 이를 반환하지 않습니다. [`unavailable`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:335) ## 테스트가 고정하는 계약 - [`RedisCacheRegionAdapterTest`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:72)는 absent→record→fresh hit, soft/hard expiry, negative expiry, schema label, 같은 adapter의 generation invalidation, entry-byte 조건부 기록과 Redis 장애 degradation을 in-memory gateway에서 고정합니다. - 같은 테스트의 [`regionInvalidationBumpsTheGeneration`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:165)는 하나의 adapter와 하나의 `CacheKeys`로 record→invalidate→lookup을 검사합니다. 두 adapter가 generation을 각각 resolve한 뒤 한쪽만 invalidate하는 regression test는 없습니다. - [`onlyIfObservedRefusesAStaleWrite`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:207)는 entry bytes 자체가 바뀐 경우를 검사합니다. generation bump와 in-flight `ONLY_IF_OBSERVED`를 결합하지 않습니다. - [`aFutureSchemaIsQuarantined`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:130)는 adapter의 category와 policy label만 검사합니다. 실제 adapter와 executor를 결합해 source reload를 확인하지 않습니다. - [`CacheAsideExecutorTest`](src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java:29)는 fresh/negative의 source bypass와 typed source 결과를 검사합니다. - 같은 테스트의 [`invalidationDuringLoadRejectsTheOldCapturedWriteCondition`](src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java:302)는 condition을 직접 교체하고 `metadata.writeCondition()`을 검사하는 fake region의 application-core 계약입니다. Redis adapter가 이 condition을 소비한다는 증거는 아닙니다. - 같은 테스트의 [`distributedSoftLeaseLetsOnePodRefreshWhileAContenderReturnsStale`](src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java:341)는 두 executor와 test coordinator로 owner 하나만 source를 호출하는 계약을 고정합니다. Redis 구현을 검증하는 테스트는 아닙니다. - [`LiveRedisSemanticPortsTest`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LiveRedisSemanticPortsTest.java:138)는 standalone/cluster real-server lane에서 application ACL account로 record/read가 동작함을 확인하도록 태그되어 있습니다. - [`RedisCapabilityCompositionTest.cacheBindingComposesTheCacheRegion`](src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/redis/RedisCapabilityCompositionTest.java:67)는 연결하지 않고 cache bean 한 개만 생기는지를 검사합니다. ## 현재 구현 공백과 잘못 읽기 쉬운 지점 1. semantic Redis composition은 cache, rate-limit, lease, idempotency V2 네 개가 있고 Session이 빠진 4/5입니다. 2. `CacheRegionPort` bean은 있지만 `CacheAsideExecutor`를 이 bean과 묶어 실제 use case에 주입하는 production 조립은 검색되지 않습니다. 3. `CacheRefreshCoordinationPort` production 구현은 없습니다. `DisabledCacheRefreshCoordinationPort`와 테스트 내부 fake coordinator만 확인됩니다. 따라서 “분산 refresh가 Redis lease로 동작한다”고 말할 근거는 없습니다. 4. 각 instance는 region generation을 최초 한 번만 읽습니다. 다른 instance의 bump를 관찰하지 못하므로 multi-instance semantic invalidation은 완성되지 않았고, 이를 재현하는 test도 없습니다. 5. Redis adapter의 `ONLY_IF_OBSERVED`는 `CacheWriteCondition`과 generation을 조건에 포함하지 않습니다. entry-byte 비교만 하며 `GET`과 `SET`도 원자적이지 않습니다. application-core의 invalidation-during-load fake test를 Redis 구현 증거로 확대할 수 없습니다. 6. future schema의 `QUARANTINE_AND_RELOAD`는 adapter label입니다. unusable observation 때문에 executor는 `FAIL_FAST`를 반환하고 source를 호출하지 않습니다. 7. `CacheEnvelope` 주석에는 background refresh 표현이 있으나 executor 구현은 동기 owner refresh입니다. 현행 method body가 우선 근거입니다. 8. 이번 문서 작업에서는 real-server lane을 실행하지 않았습니다. 위 live test 설명은 코드와 historical evidence의 범위이며 현재 HEAD 재실행 결과가 아닙니다. ## 다음에 열어볼 source 순서 다음 읽기 순서는 `RedisCapabilityConfig` → `CacheAsideExecutor` → `RedisCacheRegionAdapter` → `CacheEnvelope` → 두 test class가 적절합니다. SDK의 command admission과 connection lane은 별도 문서가 소유할 범위입니다. ## 시리즈에서 이어 읽기 - 이전 글: [Timeout 뒤 쓰였는지 모를 때: Executor와 실행 확실성 모델](./redis-execution-failure-certainty.md) - 다음 글: [세 가지 Redis Rate Limit Lua를 코드로 추적하기](./redis-rate-limit-code-walkthrough.md) - 전체 흐름: [Redis를 범용 클라이언트가 아니라 정책 경계로 다루기](./redis-backend-policy-boundary.md) - 운영 흐름: [Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지](./redis-platform-sre-operations.md)