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