Gateway가 인증 상태와 조직 컨텍스트, 브랜드 정보와 내부 헤더까지 만들기 시작하면서 조직 slug 변환과 멤버 조회가 거의 모든 요청에 필요해졌습니다. 매번 Backend를 호출하기 어려워 같은 pod의 Local LRU와 여러 pod가 공유하는 Redis를 함께 사용했습니다.

성능은 좋아졌지만 다른 문제가 생겼습니다. 캐시를 지운 뒤에도 이전 조회나 쓰기가 늦게 끝나 오래된 값을 다시 저장할 수 있었습니다. 이 값은 내부 서비스가 신뢰하는 요청 컨텍스트에 쓰이기 때문에, 단순한 캐시 적중률보다 잘못된 조직과 멤버 정보가 전달되지 않는지가 더 중요했습니다.

Gateway에 두 단계의 캐시가 필요했던 이유

Gateway는 Local LRU, Redis, Backend source 순서로 값을 찾습니다. 가까운 계층부터 확인하고 값이 없을 때 다음 계층을 조회해 채우는 cache-aside 구조입니다.

3열 표
계층역할주의할 점
Local LRU같은 pod에서 네트워크 호출 없이 재사용pod마다 값의 시점이 다를 수 있음
Negative cache없는 조직이나 멤버의 반복 조회 방지생성이나 권한 추가 뒤에도 이전 miss가 남을 수 있음
Redis shared cache여러 Gateway pod가 값을 공유무효화와 쓰기가 겹치면 이전 값이 다시 남을 수 있음
Backend source조직, 멤버와 브랜드 정보의 원천매 요청 조회하면 Gateway의 핵심 경로가 느려짐
Redis 공식 client-side caching 구조원본 보기

Redis 공식 자료처럼 애플리케이션 가까이에 캐시를 두면 네트워크 왕복을 줄일 수 있습니다. 이 글에서는 여기에 여러 pod가 공유하는 Redis와 무효화 처리를 더했습니다.

이 구조에서는 몇 가지 캐시 개념을 구분해야 합니다.

  • Cache hit는 원하는 값을 캐시에서 찾은 경우이고, cache miss는 다음 계층이나 Backend source까지 조회해야 하는 경우입니다.
  • Stale value는 원천 데이터가 바뀌었지만 캐시에 이전 값이 남은 상태입니다.
  • Invalidation은 조직이나 멤버 정보 변경을 반영하려고 캐시를 지우거나 generation을 바꾸는 처리입니다.
  • Eviction은 용량 제한이나 TTL 만료 같은 캐시 정책에 따라 값이 제거되는 현상입니다. 업무 변경을 반영하는 invalidation과는 원인이 다릅니다.
  • Negative cache는 실제로 존재하지 않는 결과를 짧게 기억해 같은 miss가 Backend까지 반복되는 일을 줄입니다.

이번 문제는 eviction이 늦어서 생긴 것이 아니라, invalidation이 끝난 뒤 이전 read나 write가 값을 다시 채우면서 stale value가 되살아난 경우였습니다.

지운 값이 다시 살아나는 순서

같은 pod 안에서도 Redis 조회와 무효화가 겹칠 수 있습니다.

  1. 요청 A가 Local LRU에서 값을 찾지 못합니다.
  2. A가 Redis에서 이전 조직 정보를 읽기 시작합니다.
  3. 같은 시점에 조직 정보가 변경되고 캐시가 무효화됩니다.
  4. Redis와 Local LRU의 값이 삭제됩니다.
  5. 먼저 시작된 A의 Redis 조회가 이전 값을 반환합니다.
  6. A가 그 값을 Local LRU에 저장하면 무효화가 사실상 취소됩니다.

이를 막기 위해 key 또는 같은 종류의 key 묶음인 namespace에 단조 증가하는 genSeq를 둡니다. 조회 전에 generation을 저장하고 응답 뒤 다시 비교해, 중간에 값이 바뀌었다면 Local LRU에 넣지 않습니다.

kotlin
val before = genSeq.current(key)
val value = redisCache.get(key) ?: return null
val cacheable = genSeq.current(key) == before

if (cacheable) {
    localCache.put(key, value)
}

boolean은 false → true → false처럼 원래 값으로 돌아갈 수 있어 중간 변경을 놓칠 수 있습니다. genSeq10 → 11 → 12처럼 증가하므로 조회와 저장 사이에 무효화가 있었는지 확인하기 쉽습니다.

다만 genSeq가 막는 범위는 오래된 값이 캐시에 다시 정착하는 경로입니다. 이미 진행 중인 요청이 이전 값을 읽는 순간까지 없애는 강한 일관성은 아닙니다. 권한 회수처럼 오래된 값을 허용하기 어려운 요청은 generation 변경 시 원천 데이터를 다시 읽거나 뒤쪽 서비스가 version을 확인해야 합니다.

Redis write 뒤에도 같은 레이스가 생긴다

Redis miss 뒤 Backend를 조회하는 loader도 같은 문제를 만날 수 있습니다.

  1. 요청 A가 generation 10에서 Backend 조회를 시작합니다.
  2. 데이터가 변경되고 generation이 11로 증가합니다.
  3. 기존 캐시는 삭제됩니다.
  4. A가 변경 전 결과를 늦게 받아 Redis에 씁니다.
  5. 무효화 이후 Redis에 이전 값이 다시 생깁니다.

따라서 Redis에 쓴 뒤에도 generation을 확인합니다. 중간에 값이 바뀌었다면 방금 쓴 Redis 값을 다시 삭제하고 Local LRU에도 넣지 않습니다.

kotlin
val before = genSeq.current(key)
val value = backendSource.load(key)

redisCache.put(key, value)

if (genSeq.current(key) != before) {
    redisCache.delete(key)
    return CacheRead(value, cacheable = false)
}

localCache.put(key, value)
return CacheRead(value, cacheable = true)

여기에는 중요한 전제가 있습니다. 데이터 변경 이벤트는 원천 트랜잭션이 commit된 뒤에 발생해야 합니다. commit 전에 캐시부터 지우면 다음 조회가 아직 변경되지 않은 원천 데이터를 읽어 다시 채울 수 있습니다.

key별 generation은 정밀하지만 key 수가 많으면 Local map이 커집니다. 조직이나 namespace 단위 generation은 관리하기 쉽지만 관련 없는 key까지 cache miss로 만들 수 있습니다. 무효화 범위와 key 수를 기준으로 단위를 정해야 합니다.

다른 pod에는 Pub/Sub으로 알렸다

한 pod의 Local LRU를 지워도 다른 pod의 캐시는 남아 있습니다. 그래서 무효화 메시지를 Redis Pub/Sub으로 전달하고, 메시지를 받은 pod는 genSeq를 증가시킨 뒤 Local LRU와 Negative cache를 지웁니다.

Redis 공식 client-side cache 무효화 흐름원본 보기

Redis 공식 자료처럼 원천 값이 바뀌면 이를 읽었던 local cache에 invalidation을 전달해야 합니다. 이 글은 Redis CLIENT TRACKING을 사용한 구현은 아니지만, 다른 pod에 변경을 알리고 local 값을 제거한다는 원칙은 같습니다.

Redis Pub/Sub은 현재 연결된 subscriber에게 빠르게 방송하지만 전달 보장은 at-most-once입니다. 구독이 끊긴 동안의 메시지는 재연결 뒤 복구되지 않습니다.

그래서 subscription이 다시 연결되면 다음처럼 처리했습니다.

  • Local LRU와 Negative cache를 비움
  • 재연결 시각을 기록
  • 그보다 오래된 Redis 값은 원천 데이터에서 다시 확인

Pub/Sub publish 자체가 실패하거나 끊긴 구간의 변경까지 복구해야 한다면 이 방식만으로는 부족합니다. 그 단계에서는 Redis Streams 같은 영속 이벤트, outbox 또는 version 기반 대조가 필요합니다.

무효화 경로에서 함께 확인한 문제

Negative cache에는 실제 not found만 저장했습니다. Backend timeout이나 5xx를 부재로 저장하면 일시적인 장애가 정상적인 miss처럼 남기 때문입니다. 조직 생성이나 권한 추가가 발생하면 일반 캐시와 Negative cache를 함께 지웠습니다.

Redis 값의 직렬화가 깨졌다면 같은 parse 오류를 반복하지 않도록 해당 key를 삭제하고 Backend source를 다시 조회했습니다. parse 실패와 schema version도 함께 남겨 데이터 손상과 배포 호환성 문제를 구분했습니다.

대량 무효화는 긴 대상 목록을 요청 헤더에 계속 싣는 방식으로 처리하지 않았습니다. 프록시와 서버의 헤더 크기 제한을 넘길 수 있고, 일부 대상이 누락돼도 호출 성공만으로는 알아채기 어렵기 때문입니다. 대상 수가 많을 때는 namespace generation을 올리거나 body를 가진 전용 invalidation event를 사용하고, 요청 대상 수와 실제 처리 수를 대조해야 합니다.

이번 레이스와 구분해야 할 캐시 현상

  • Cache penetration은 존재하지 않는 key가 반복돼 매번 Backend까지 조회되는 현상입니다. Negative cache로 줄일 수 있지만 실제 부재와 조회 실패를 구분해야 합니다.
  • Cache stampede는 하나의 인기 key가 만료되거나 무효화된 직후 여러 요청이 동시에 loader를 실행하는 현상입니다. genSeq는 오래된 write를 막을 뿐 중복 load는 막지 않으므로 single-flight나 key별 요청 합치기가 별도로 필요합니다.
  • Cache avalanche는 많은 key가 비슷한 시점에 만료되거나 대량 무효화로 함께 비워져 Backend에 요청이 몰리는 현상입니다. TTL 분산, 단계적 warm-up과 요청 제한이 필요합니다.
  • Hot key는 특정 조직이나 브랜드 key에 요청이 집중되는 상태입니다. Local LRU가 Redis 부하를 줄여 주지만 pod별 hit rate와 eviction 횟수를 함께 봐야 합니다.
  • Stale-while-revalidate는 이전 값을 먼저 제공한 뒤 뒤에서 갱신하는 전략입니다. 일반 콘텐츠에는 유용할 수 있지만 조직과 권한 컨텍스트에는 stale 허용 범위를 매우 좁게 잡아야 합니다.

이 현상들은 모두 캐시와 관련돼 있지만 이번 변경의 직접 대상은 아니었습니다. 이번 작업은 invalidation 전후의 read와 write가 오래된 값을 다시 저장하는 경로를 줄이는 데 집중했습니다.

Gateway 인증 경계와 연결되는 이유

조직, 멤버와 브랜드 캐시는 내부 헤더와 request context를 만드는 데 사용됩니다. 이전 멤버 정보가 남으면 회수된 접근 상태가 뒤쪽 서비스에 전달될 수 있습니다.

다만 최종 멤버십과 리소스 권한은 해당 Backend가 판단합니다. genSeq와 Pub/Sub도 진행 중인 요청이나 메시지 유실까지 막지는 못하므로, 즉시 권한 회수가 필요한 경로는 version 확인이나 캐시 우회가 필요합니다.

검증은 hit rate보다 재등장을 봤다

다음 시간 순서를 고정해 검증했습니다.

  1. Redis read 중 무효화가 발생해도 이전 값이 Local LRU에 저장되지 않는지
  2. Backend 조회 뒤 generation이 바뀌면 Redis write를 다시 삭제하는지
  3. 다른 pod가 Pub/Sub 메시지를 받고 Local LRU와 Negative cache를 비우는지
  4. reconnect 뒤 기존 local cache를 버리고 Redis 값을 다시 검증하는지
  5. 조직 생성과 권한 추가 뒤 Negative cache가 제거되는지
  6. 대량 무효화의 요청 대상 수와 실제 처리 수가 일치하는지

운영에서는 hit 비율만 보지 않았습니다. generation mismatch 수, 무효화 뒤 Redis 값을 다시 삭제한 횟수, subscription 중단 시간, Negative cache invalidation 수와 key별 동시 loader 수를 함께 봤습니다.

TTL을 줄이는 것만으로는 부족했다

TTL은 오래된 값의 수명을 줄일 뿐, 무효화 전에 시작된 작업이 값을 되살리는 레이스는 막지 못합니다. 그래서 generation이 바뀐 저장은 버리고, reconnect 뒤에는 local cache를 비워 놓친 구간을 다시 확인했습니다.

목표는 적중률을 최대한 높이는 것이 아니라 오래된 조직과 멤버 컨텍스트가 내부 서비스로 넘어가지 않게 하는 데 있었습니다.

참고