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