# 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 명령을 거치고 언제 원본 저장소로 내려가는지를 코드로 따라간 기록입니다. Redis와 Spring은 알지만 이 저장소의 캐시 코드는 처음 보는 분을 대상으로 합니다. Spring이 조립하는 `CacheRegionPort` 빈과 애플리케이션 쪽 `CacheAsideExecutor`를 함께 읽습니다. 값 하나를 감싸는 봉투와 리전 세대를 먼저 정의하고, 빈이 만들어지는 조건, 조회 한 번의 호출 순서, 무효화, 갱신과 실패 분기, 테스트가 고정한 범위 순서로 살펴보겠습니다. ## 값을 감싸는 봉투와 리전 세대 `CacheEnvelope`는 캐시에 넣을 값을 그대로 저장하지 않고 앞에 머리말을 붙여 감싸는 형식입니다. 머리말과 페이로드는 `|` 경계 여섯 개로 나뉘고, 스키마 버전·원본 리비전·세대·소프트 만료 시각·하드 만료 시각·부재 표시가 차례로 들어간 뒤 마지막에 페이로드 바이트가 옵니다. 두 만료 시각은 절대 에폭 밀리초로 적습니다. 현행 스키마는 v1입니다. [`CacheEnvelope`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/CacheEnvelope.java:29), [`CacheEnvelope.encode`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/CacheEnvelope.java:104) 세대(generation)는 리전마다 Redis에 두는 카운터입니다. 값을 기록할 때 그 시점의 세대를 봉투에 함께 적어 두고, 나중에 읽을 때 봉투의 세대가 현재 세대와 다르면 그 값을 지나간 값으로 처리합니다. 만료는 두 단계로 나뉩니다. 소프트 TTL이 지나면 값은 아직 남아 있되 갱신 후보가 되고, 하드 TTL이 지나면 만료로 처리됩니다. 이 글에서는 소프트와 하드 사이에 있는 값을 '묵은 값'이라고 부르겠습니다. 원본에 값이 없다는 사실 자체를 적어 두는 항목은 부재 표시를 켜서 기록하고, 여기에는 별도의 네거티브 TTL을 씁니다. 기본값은 소프트 TTL 30초, 하드 TTL 5분, 네거티브 TTL 10초, 명령 타임아웃 200ms입니다. 시작 시점에 `positiveSoftTtl <= positiveHardTtl`, 설정된 하드 TTL 하한, 양수 명령 타임아웃, 양수 키 버전을 검사합니다. [`RedisCapabilitySettings.Cache.validate`](src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilitySettings.java:70) ## 캐시 리전 빈이 만들어지는 조건 전역 `app.redis.enabled=true`이고 기본 캐시 바인딩이 `redis`일 때만 `redisDefaultCacheRegion` 빈이 생깁니다. 이 메서드는 `RedisRuntimeOwner`, 네임스페이스, 캐시 설정, `Secret`, `Clock`을 받아 캐시 설정을 검증하고, 공통 `app.redis.namespace` 아래의 `CacheKeys`를 만든 다음, `RedisCacheRegionAdapter` 생성자에 넘겨 `CacheRegionPort` 빈을 내놓습니다. [`RedisCapabilityConfig.redisDefaultCacheRegion`](src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java:95) `CacheRegionPort`는 애플리케이션이 넘긴 원래 키와 값을 받아 조회·기록·무효화 결과를 타입으로 구분해 돌려주는 계약입니다. 실제 동작은 공급자 어댑터가 맡습니다. [`CacheRegionPort`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRegionPort.java:7) Redis 키에는 원래 키가 들어가지 않습니다. 빈을 만들 때 원래 키를 `HMAC-SHA-256`으로 바꾸는 함수를 함께 주입하는데, 해시 재료에 환경·서비스·도메인이 같이 들어가기 때문에 같은 식별자라도 네임스페이스가 다르면 다이제스트도 달라집니다. 출력은 `hv1:`입니다. [`KeyDigest.of`와 `of`](src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/redis/RedisCapabilityConfig.java:312) 실제로 Redis에 들어가는 항목 키는 공통 네임스페이스, `cache` 기능 이름, 키 배치 버전, 리전, 다이제스트를 이어 붙여 만듭니다. [`CacheKeys.entryKey`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:383) `CacheAsideExecutor`는 생성 시점에 리전별 정책으로 `CacheSingleFlight`와 `CacheSourceBulkhead`를 만듭니다. 둘 다 프로세스 안에서만 돕니다. 2인자 생성자는 갱신 조정자를 주입하지 않고, 4인자 생성자만 조정자와 `CacheRefreshCoordinationPolicy`를 받습니다. [`CacheAsideExecutor` 생성자](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:25) Redis 조회와 기록은 여기까지 운영 빈으로 조립됩니다. [`RedisCapabilityCompositionTest.cacheBindingComposesTheCacheRegion`](src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/redis/RedisCapabilityCompositionTest.java:67)은 Redis에 연결하지 않고 캐시 빈이 한 개만 생기는지를 검사합니다. 다만 이 빈과 `CacheAsideExecutor`를 묶어 실제 유스케이스에 주입하는 운영 조립은 찾지 못했으므로, 아래 호출 순서는 두 클래스를 이어 읽은 결과입니다. ## 조회 한 번의 호출 순서 ```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단계: 세대를 한 번만 읽습니다 `lookup`은 `REGULAR` 갈래를 빌린 뒤 `resolveGeneration`을 부릅니다. 서버 값을 읽는 시점은 `CacheKeys`마다 최초 접근 한 번뿐이어서, `resolved`가 `true`가 되면 이후 조회와 기록은 Redis 카운터를 다시 읽지 않고 프로세스 안에 남은 세대 값을 씁니다. 최초 호출의 `INCRBY generationKey 0`은 키가 없으면 0을 만들고 그 시점의 출발값을 맞추지만, 그 뒤 다른 인스턴스가 올린 값까지 가져오지는 않습니다. [`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) 다른 인스턴스가 세대를 올리면 어떻게 될까요? 인스턴스 A와 B가 모두 세대 0을 읽어 둔 뒤 A가 리전 세대를 1로 올리면 A의 `CacheKeys`만 1로 갱신됩니다. B는 계속 0을 쓰기 때문에 세대 0으로 적힌 항목을 그대로 맞히거나, 세대 0으로 다시 기록할 수 있습니다. 지금의 리전 무효화를 모든 인스턴스에 즉시 반영되는 무효화로 읽을 수 없는 이유입니다. 이 상황을 재현하는 테스트도 없습니다. [`regionInvalidationBumpsTheGeneration`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:165)은 어댑터 하나와 `CacheKeys` 하나로 기록→무효화→조회만 검사하고, 어댑터 둘이 세대를 따로 읽어 둔 뒤 한쪽만 무효화하는 회귀 테스트는 없습니다. ### 2단계: `GET` 결과를 다섯 갈래로 나눕니다 `lookup`이 원래 키 하나를 받아 돌려주는 값은 `Hit`, `NegativeHit`, `Miss`, `IncompatibleSchema`, `Unavailable` 다섯 가지입니다. [`RedisCacheRegionAdapter.lookup`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:118) 저장된 값이 없으면 `Miss(ABSENT)`입니다. 값이 있으면 `CacheEnvelope.decode`가 `|` 경계 여섯 개를 찾아 스키마 버전, 원본 리비전, 세대, 소프트·하드 만료 시각, 부재 표시와 페이로드를 되살립니다. 해석 순서는 [`interpret`](src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapter.java:144)에 그대로 드러납니다. 1. 지금보다 높은 스키마 버전은 어댑터에서 `QUARANTINE_AND_RELOAD`로 분류합니다. 다만 여기에 쓰는 2인자 `IncompatibleSchema`에는 쓸 수 있는 관측 토큰과 쓰기 조건이 없습니다. 2. 폐기된 버전, 모르는 버전, 깨진 봉투는 `FAIL_FAST`입니다. 3. 봉투의 세대가 현재 세대와 다르면 `Miss(INVALIDATED)`입니다. 4. 하드 만료 시각이 지났으면 `Miss(EXPIRED)`입니다. 5. 부재 표시가 있으면 `NegativeHit`입니다. 6. 그 밖에는 소프트 만료 전이면 `FRESH`, 소프트와 하드 사이면 `STALE`입니다. 높은 스키마 버전을 보통의 미스로 바꾸지 않는 이유는 구버전 인스턴스가 신버전 값을 덮어쓰는 일을 막기 위해서입니다. `CacheAsideExecutor`까지 따라가면 결과가 달라집니다. 어댑터가 높은 스키마 버전에 쓰는 2인자 생성자는 관측 토큰과 쓰기 조건을 모두 `unavailable()`로 채우고, 실행기는 정책이 `QUARANTINE_AND_RELOAD`여도 관측 토큰을 쓸 수 없으면 정책을 `FAIL_FAST`로 바꾼 `IncompatibleSchema`를 즉시 돌려줍니다. 원본 로더는 부르지 않습니다. 지금 조합에서 실제로 일어나는 일은 `FUTURE_VERSION` → `QUARANTINE_AND_RELOAD` 라벨 → 실행기의 `FAIL_FAST`입니다. [`CacheLookup.IncompatibleSchema`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheLookup.java:94), [`getOrLoad`의 스키마 분기](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:81) [`aFutureSchemaIsQuarantined`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:130)는 어댑터의 분류와 정책 라벨만 검사하고, 어댑터와 실행기를 결합해 원본을 다시 읽는지는 확인하지 않습니다. ### 3단계: 신선한 값과 부재 표시는 원본을 부르지 않습니다 `CacheAsideExecutor.getOrLoad`는 키, 리전, 원본 로더를 받아 `CacheResult`를 돌려줍니다. [`CacheAsideExecutor.getOrLoad`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:53) `FRESH`는 `FreshHit`로 바꾸고 `NegativeHit`는 그대로 돌려주므로 원본 호출이 없습니다. 묵은 값은 하드 만료 시각과 관측 토큰을 가진 후보로 남겨 두고, 미스와 `Unavailable`은 원본에서 다시 채울 대상으로 넘깁니다. [`getOrLoad` 분기](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:59) 같은 프로세스에서 같은 키를 요청하면 `CacheSingleFlight`가 하나로 합칩니다. 동시에 진행할 수 있는 키 수, 키 하나당 대기자 수, 대기 시간을 넘기면 각각 `MAXIMUM_IN_FLIGHT_KEYS`, `MAXIMUM_WAITERS`, `WAIT_TIMEOUT`으로 거절됩니다. `CacheSourceBulkhead`가 차면 `SOURCE_OVERLOADED`, 마감 시각을 넘기면 `LOAD_TIMEOUT`입니다. ### 4단계: 원본 결과를 봉투에 담아 기록합니다 원본이 `Loaded`를 주면 `region.record`를, `AuthoritativeAbsent`를 주면 `recordAbsent`를 부릅니다. 일시적 실패와 영구 실패는 캐시에 쓰지 않습니다. 원본 결과는 `RetryableNoEffect` 같은 멱등성 의미를 가져다 쓰지 않고 캐시 전용 `SourceLoadOutcome`으로 나뉘어 있습니다. [`invokeSourceDirect`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:206) `write`는 값 또는 부재, 원본 리비전, 쓰기 의도를 받아 조건을 확인한 뒤 `SET`에 TTL을 붙여 실행하고 `CacheRecordOutcome`을 돌려줍니다. 새로 쓰는 항목에는 `CacheEnvelope.CURRENT_SCHEMA_VERSION`, 원본 리비전, 현재 세대, `now + effectiveSoft`, `now + ttl`, 부재 여부, 페이로드가 들어갑니다. Redis에 거는 실제 TTL은 하드 TTL과 같고, 값이 있는 항목은 하드 TTL을, 부재를 적는 항목은 별도의 네거티브 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`에서는 조회 시점의 항목 바이트로 `CacheObservationToken`과 `CacheWriteCondition`을 모두 만들고, 실행기도 두 값을 `CacheRecordMetadata`에 실어 보냅니다. 그런데 Redis 어댑터의 `write`는 `metadata.writeCondition()`을 읽지 않고, 지금 저장된 항목 바이트의 `SHA-256` 앞 16바이트와 `metadata.observedToken()`만 비교합니다. [`CacheRecordMetadata`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRecordMetadata.java:6), [실행기가 넘기는 `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) 그래서 이 비교가 잡아내는 것은 항목 바이트가 통째로 바뀐 경우뿐입니다. 리전 세대를 올려도 기존 항목 바이트는 바뀌지 않기 때문에, 원본을 읽는 도중에 무효화가 일어나도 비교는 통과합니다. 같은 어댑터라면 새 세대로 원본 결과를 써서 무효화 직후 값을 다시 채울 수 있고, 다른 인스턴스라면 앞서 읽어 둔 이전 세대로 쓸 수 있습니다. 세대와 바이트 관측을 한 번의 원자적 비교·교환(`CAS`)으로 묶지 않았고, 비교용 `GET`과 최종 `SET`도 Lua나 트랜잭션으로 묶지 않았습니다. [`onlyIfObservedRefusesAStaleWrite`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/cache/RedisCacheRegionAdapterTest.java:207)는 항목 바이트 자체가 바뀐 경우를 검사하고, 세대 올리기와 진행 중인 `ONLY_IF_OBSERVED`를 함께 놓지는 않습니다. [`invalidationDuringLoadRejectsTheOldCapturedWriteCondition`](src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java:302)은 조건을 직접 바꿔 놓고 `metadata.writeCondition()`을 확인하는 가짜 리전으로 `application-core` 쪽 계약을 고정한 것이어서, Redis 어댑터가 이 조건을 실제로 읽는다는 근거는 되지 않습니다. ## 무효화는 키를 지우거나 세대를 올립니다 키 하나를 무효화할 때는 값을 읽으면서 그 자리에서 지우는 `GETDEL`을 부르는데, 지워진 값이 있었으면 `INVALIDATED`를, 처음부터 없었으면 `ALREADY_ABSENT`를 돌려줍니다. 리전 무효화는 `KEYS`나 `SCAN`으로 항목을 훑어 지우지 않고, 세대 키에 `INCRBY 1`을 적용한 뒤 이 호출에 쓰인 `CacheKeys`만 반환값으로 갱신합니다. 기존 항목은 Redis에 그대로 남아서 하드 TTL이 지나야 사라집니다. 무효화를 실행한 인스턴스에서는 다음 조회가 세대 불일치가 되지만, 이미 이전 세대를 읽어 둔 다른 인스턴스에는 이 결론이 적용되지 않습니다. [`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) ## 묵은 값 갱신과 실패 분기 묵은 값을 갱신하러 간 원본 호출이 일시적 실패로 끝나고 정책이 허용하며 하드 만료 전이면 `CacheAsideExecutor`는 `StaleFallbackAfterTransientFailure`를 돌려줍니다. 영구 실패에는 묵은 값을 쓰지 않습니다. [`toResult`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:346) 갱신 조정자를 주입한 경우에는 묵은 값이거나 설정된 하드 미스일 때 선점을 시도합니다. `CacheRefreshCoordinationPort`는 키, 시도, 리스 TTL을 받아 `claimed`·`contended`·`unavailable`·`indeterminate` 중 하나를 돌려주고, 실행기는 그 결과로 원본 갱신을 허용할지 정합니다. [`CacheRefreshCoordinationPort`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheRefreshCoordinationPort.java:13) `Indeterminate`는 같은 시도로 한 번만 다시 부릅니다. 선점에 밀린 쪽이거나 `unavailable`·`indeterminate`를 받은 쪽이 묵은 값을 갖고 있으면 원본을 부르지 않고 `StaleRefreshDeferred`를 돌려줍니다. 선점한 쪽은 캐시를 다시 읽어 다른 인스턴스가 이미 채웠는지 확인하고, 자기 원본 호출을 마친 뒤 `finally`에서 반납합니다. [`invokeSource`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:144) 이 경로는 계약과 테스트 대역까지만 있습니다. `CacheRefreshCoordinationPort`의 운영 구현은 없고 `DisabledCacheRefreshCoordinationPort`와 테스트 안의 가짜 조정자만 확인됩니다. [`distributedSoftLeaseLetsOnePodRefreshWhileAContenderReturnsStale`](src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java:341)도 실행기 둘과 테스트용 조정자로 한쪽만 원본을 부르는 계약을 고정할 뿐 Redis 구현을 검증하지는 않습니다. 그래서 분산 갱신이 Redis 리스로 동작한다고 말할 근거는 없습니다. 이 실행기는 비동기 백그라운드 갱신 스케줄러가 아닙니다. 선점한 쪽이 동기로 갱신하고 밀린 쪽만 묵은 값을 즉시 받습니다. 하드 미스에서 정해진 시간만 기다리는 부분도 `Thread.sleep` 뒤에 한 번 다시 읽는 구현입니다. [`boundedWait`](src/application-core/src/main/java/dev/caskeleton/application/cache/CacheAsideExecutor.java:300) `CacheEnvelope` 주석에는 백그라운드 갱신이라는 표현이 있지만, 실제로 도는 것은 메서드 본문에 있는 동기 갱신입니다. 캐시에서는 Redis 장애를 성능 저하로 다룹니다. `lookup`은 `Unavailable(UNAVAILABLE, NOT_APPLIED)`를, 기록과 무효화는 `DEGRADED_UNAVAILABLE`을 돌려주고, 부른 쪽은 캐시 미스처럼 원본으로 내려가도 된다는 정책입니다. `CacheRecordOutcome`과 `CacheInvalidationOutcome`에는 `INDETERMINATE`도 정의되어 있지만, 이 어댑터에서 예외를 모두 받는 자리는 이 값을 돌려주지 않습니다. [`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)는 인메모리 게이트웨이 위에서 부재→기록→신선한 적중, 소프트·하드 만료, 네거티브 만료, 스키마 라벨, 같은 어댑터 안에서의 세대 무효화, 항목 바이트 조건부 기록, Redis 장애 시 성능 저하 처리를 고정합니다. - [`CacheAsideExecutorTest`](src/application-core/src/test/java/dev/caskeleton/application/cache/CacheAsideExecutorTest.java:29)는 신선한 값과 부재 표시가 원본을 건너뛰는 것과 타입으로 구분된 원본 결과를 검사합니다. - [`LiveRedisSemanticPortsTest`](src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/LiveRedisSemanticPortsTest.java:138)는 `standalone`·`cluster` 실서버 갈래에서 애플리케이션 ACL 계정으로 기록과 읽기가 동작하는지 확인하도록 태그되어 있습니다. 다만 이번 문서 작업에서는 실서버 갈래를 돌리지 않았습니다. 위 설명은 코드와 이전에 남겨 둔 근거의 범위이지 지금 `HEAD`에서 다시 실행한 결과가 아닙니다. ## 마무리 지금까지 캐시 조회 한 번이 `CacheAsideExecutor.getOrLoad`에서 시작해 세대 확인과 `GET`을 거쳐 봉투 해석으로 갈래가 나뉘고, 신선하지 않은 값만 원본으로 내려간 뒤 `SET`으로 다시 적히는 과정을 살펴봤습니다. 세대는 인스턴스마다 한 번만 읽고, 조건부 기록은 항목 바이트만 보며, 갱신 조정은 계약과 테스트 대역까지만 있습니다. Redis 조회와 기록은 운영 빈으로 조립되어 있지만, 이 빈과 실행기를 묶는 유스케이스 조립과 분산 갱신 구현은 코드에서 확인되지 않습니다. ## 시리즈에서 이어 읽기 - 이전 글: [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)