Skip to content
Historical revision
Cache-Aside2026-06-30 19:59 UTC
rev_0525b02061b24ffd94a6c423f4feb657

Cache-Aside

Cache-Aside는 애플리케이션이 캐시와 원본 저장소를 직접 다루는 캐싱 패턴이다. 먼저 캐시를 조회하고, 없으면 DB나 외부 API 같은 원본 저장소에서 읽은 뒤 캐시에 채운다. Microsoft는 이를 "load data on demand into a cache from a data store"로 설명한다.[1]

다른 이름으로 Lazy Loading Cache라고도 부른다. AWS도 lazy caching 또는 cache-aside를 가장 흔한 캐싱 방식으로 설명한다.[2]

Cache-Aside는 "DB를 빠르게 만드는 마법"이 아니라, 읽기 부하와 일관성 위험을 교환하는 패턴이다. 이 문서는 그 교환에서 생기는 운영 문제(무효화, stale, stampede, 장애)를 어떻게 다루는지에 초점을 둔다.

핵심 구조

Cache-Aside에서 캐시는 DB 앞에 투명하게 끼어 있는 계층이 아니다. 애플리케이션 코드가 캐시 조회, DB 조회, 캐시 저장, 캐시 무효화를 직접 책임진다.

역할

책임

애플리케이션

캐시를 먼저 보고, miss면 원본 저장소를 읽고, 결과를 캐시에 넣는다

캐시

자주 읽는 값을 빠르게 돌려준다

원본 저장소

최종 source of truth다

기본 읽기 흐름은 이렇다.

  1. 요청이 들어온다.

  2. 애플리케이션이 캐시에서 key를 찾는다.

  3. cache hit이면 캐시 값을 반환한다.

  4. cache miss이면 DB에서 읽는다.

  5. 읽은 값을 TTL과 함께 캐시에 저장한다.

  6. 값을 반환한다.

C#
public async Task<ProductDto?> GetProductAsync(long productId, CancellationToken ct)
{
    var cacheKey = $"product:{productId}:v1";

    var cached = await cache.GetAsync<ProductDto>(cacheKey, ct);
    if (cached is not null)
    {
        // 가장 빠른 경로다. DB를 치지 않는다.
        return cached;
    }

    // cache miss. 원본 저장소가 source of truth다.
    var product = await productRepository.FindDtoAsync(productId, ct);
    if (product is null)
    {
        return null;
    }

    // 너무 긴 TTL은 stale data를 늘리고, 너무 짧은 TTL은 DB 부하를 줄이지 못한다.
    await cache.SetAsync(cacheKey, product, TimeSpan.FromMinutes(5), ct);
    return product;
}

TypeScript로 쓰면 보통 Redis 클라이언트와 repository를 조합한다.

TypeScript
type ProductDto = {
  id: number;
  name: string;
  price: number;
};

async function getProduct(productId: number): Promise<ProductDto | null> {
  const cacheKey = `product:${productId}:detail:v1`;

  const cached = await redis.get(cacheKey);
  if (cached !== null) {
    // JSON 역직렬화 실패가 나면 캐시 값이 깨졌다는 뜻이다.
    // 실무에서는 로그를 남기고 캐시를 지운 뒤 DB fallback을 타게 만든다.
    try {
      return JSON.parse(cached) as ProductDto;
    } catch {
      await redis.del(cacheKey);
      // 아래 DB fallback 경로로 내려간다.
    }
  }

  // cache miss. DB가 source of truth다.
  const product = await productRepository.findDto(productId);
  if (product === null) {
    return null;
  }

  // Redis EX는 초 단위 TTL이다.
  // DTO 모양이 바뀌면 key 버전을 v2로 올린다.
  await redis.set(cacheKey, JSON.stringify(product), { EX: 300 });
  return product;
}

위 예제의 { EX: 300 }은 node-redis v4 문법이다.
클라이언트마다 TTL 지정 방식이 다르므로 아래 Redis로 구현할 때 절에서 따로 정리한다.

위 TS 예제의 JSON.parse try/catch도 설명용이다. 실제 코드에서는 cache serializer/adapter가 Corrupted, Miss, Down 같은 상태로 변환해 서비스 본문으로 넘긴다.

설명을 단순하게 하려고 앞의 예제는 null로 cache miss와 not found를 함께 표현했지만,
실제 모범 운영 코드에서는 miss(캐시 없음), not found(엔티티 없음), cache down(캐시 장애)를 Result나 union으로 분리하는 편이 안전하다. 뒤의 [에러 정책] 절에서 이를 Hit / Miss / Down으로 나눈다.

언제 쓰는가?

Cache-Aside는 읽기 빈도가 높고, 약간 오래된 값을 허용할 수 있는 데이터에 잘 맞는다.

자주 쓰는 곳:

  • 상품 상세

  • 게시글 상세

  • 카테고리 목록

  • 설정값

  • 권한 목록(짧은 TTL과 최종 검증 필요)

  • 사용자 프로필 일부

  • 대시보드 요약 데이터

  • 외부 API 응답 중 짧게 재사용 가능한 값

판단 기준은 여러 저자들의 말을 요약해서 말하면 다음과 같다.

질문

Cache-Aside 적합도

같은 key가 반복 조회되는가?

높음

DB 조회나 외부 API 호출이 비싼가?

높음

몇 초에서 몇 분 정도 stale data를 허용할 수 있는가?

높음

쓰기보다 읽기가 훨씬 많은가?

높음

항상 최신값이 필요한가?

낮음

key별 무효화 기준이 모호한가?

낮음

권한, 재고, 좌석, 결제 상태처럼 stale data의 비용이 큰 값은 조심해야 한다. Cache-Aside를 쓰더라도 TTL을 짧게 잡고, key에 tenant/user/role/scope/version 같은 경계를 넣고, 최종 판정은 DB나 권한 서버에서 다시 확인하는 경로가 필요하다. 경우에 따라 캐시하지 않는 편이 더 싸다.

왜 쓰는가?

원본 저장소 부하를 줄이고 응답 시간을 낮추기 위해 쓴다.

DB는 보통 정확성과 영속성을 담당한다. 캐시는 빠른 재사용을 담당한다. 둘을 분리하면 다음 이점이 생긴다.

  • 반복 조회가 DB까지 가지 않는다.

  • 외부 API 호출 횟수를 줄인다.

  • 피크 시간대 read traffic을 흡수한다.

  • 자주 쓰는 데이터만 캐시에 올라오므로 메모리를 비교적 효율적으로 쓴다.

  • 캐시를 잃어도 DB에서 다시 채울 수 있다.

AWS의 Redis 캐싱 전략 문서도 Cache-Aside의 장점으로 요청된 데이터만 캐시에 들어가 비용 효율적이고, 구현이 단순하며 즉각적인 성능 개선을 얻기 쉽다는 점을 든다.[3]

Redis로 구현할 때

이 문서의 예제는 캐시 저장소로 Redis를 쓴다.
Redis는 인메모리 key-value 저장소로, 값은 기본적으로 문자열(바이트열)로 저장되며 list, hash, set, sorted set 같은 자료구조도 지원한다. Cache-Aside의 캐시 계층으로 가장 흔히 쓰인다.[4]

개인메모:물론 Redis말고도 다른 Valkey등도 있지만, 기본적으로 한국에서 교섭할때 협상 우위에 있는 스택은 Redis이므로, Redis 쓰는게 낫다.

명령이 원자적이다

Redis는 명령 실행을 단일 스레드 이벤트 루프 모델로 직렬화해 처리하므로, 개별 명령(GET, SET, DEL, INCR 등)은 원자적으로 보이고,
Redis 6 이후 I/O threading이 있어도 명령 실행의 원자성 모델은 유지된다. 그래서 단일 연산에는 추가 락이 필요 없다.
다만 "GET 으로 확인한 뒤 판단해서 SET" 같은 복합 동작은 여러 명령으로 쪼개지므로 원자적이지 않다. Cache-Aside의 read-modify 레이스(앞의 stale-set)가 Redis를 써도 애플리케이션 레벨 문제로 남는 이유가 이것이다.

명령 매핑

Cache-Aside의 각 단계는 Redis 명령으로 다음처럼 대응된다.

단계

Redis 명령

캐시 조회

GET key

TTL과 함께 저장

SET key value EX seconds (또는 SETEX key seconds value)

무효화(삭제)

DEL key (큰 값은 비동기 삭제 UNLINK key)

없을 때만 저장

SET key value NX (단일 채움. 분산 락으로 쓰면 토큰+안전 해제 필요)

만료 확인·연장

TTL key, EXPIRE key seconds

배치 조회

MGET k1 k2 ... 또는 파이프라이닝

클라이언트별 TTL 문법 차이

같은 SET ... EX라도 클라이언트마다 호출 형태가 다르다. 이 문서 예제는
node-redis v4 기준이다.

TypeScript
// node-redis v4
await redis.set(key, value, { EX: 300 });
await redis.set(key, value, { NX: true, PX: 3000 }); // 없을 때만, ms TTL

// ioredis
await redis.set(key, value, "EX", 300);
await redis.set(key, value, "PX", 3000, "NX");
C#
// C# StackExchange.Redis
db.StringSet(key, value, TimeSpan.FromMinutes(5));
db.StringSet(key, value, TimeSpan.FromSeconds(3), when: When.NotExists); // NX

직렬화와 key 버전

값이 문자열이므로 객체는 JSON 등으로 직렬화해 저장하고, 읽을 때 역직렬화 한다. 그래서 DTO 모양이 바뀌면 역직렬화가 깨질 수 있다. 추후에 Key 설계에서 설명하겠지만, 보통은 key에 v1, v2 버전을 넣어 옛 캐시와 새 캐시를 분리한다.

메모리 한계와 eviction

Redis는 maxmemory와 eviction 정책(allkeys-lru, allkeys-lfu,
volatile-ttl 등)으로 메모리 상한을 관리한다. 메모리가 차면 TTL이 남아 있어도 값이 밀려날 수 있다. 이는 "캐시는 언제든 사라질 수 있다"는 Cache-Aside의 전제와 정확히 맞는다. 그래서 아래 [캐시 장애 처리]처럼 캐시가 없어도 DB로 다시 채울 수 있어야 한다.

메모리 파편화

DEL 후 재조회 시 SET하는 방식은 레이스를 줄이는 안전한 선택이지만, 크기가 제각각인 값(JSON, MessagePack 등)이 끊임없이 지워지고 다시 쓰이면 Redis의 메모리 할당자(jemalloc) 안에 빈 공간이 흩어지는 파편화가 쌓인다. 그러면 논리적 데이터 크기는 작아도 물리 메모리(RSS)가 부풀어, OS의 OOM 킬러가 Redis를 죽일 수 있다.

그래서 운영에서는 파편화 비율(mem_fragmentation_ratio, RSS 대비 사용 메모리)을 모니터링하고, 이 값이 지속적으로 1.5 안팎 이상으로 높아지면 activedefrag(능동적 파편화 제거)를 검토한다. 무효화가 잦은 캐시일수록 이 지표가 중요하다.
용량을 maxmemory만으로 잡고 RSS를 보지 않으면, 논리 사용량이 한참 아래인데도 물리 메모리가 터지는 일이 생긴다.

영속성에 기대지 않는다

Redis는 RDB 스냅샷이나 AOF로 디스크에 남길 수 있지만, Cache-Aside의 캐시 용도라면 이 영속성에 의존하지 않는다. source of truth는 어디까지나 DB이고, 캐시는 비어도 복구 가능한 보조 계층으로 다룬다.

분산 환경 주의

여러 앱 서버가 같은 Redis를 공유하므로, 앞의 [Cache Stampede] 절에서 본인프로세스 락(C#의 SemaphoreSlim, Node의 in-flight Map)만으로는 서버 전체 기준 stampede를 막지 못한다. 이때 SET key value NX PX ttl 기반의 단순 분산 락이나 Redlock류, request coalescing 계층을 함께 본다. 분산 락으로 쓸 때는 value에 매번 다른 random token을 넣고, 해제할 때도 Lua로 "GET 값이 내 token과 같을 때만 DEL"해야 한다. 단순 DEL은 내 락이 만료된 뒤 다른 요청이 새로 잡은 락을 지워버릴 수 있다.
그리고 분산 락 자체가 만료 타이밍과 네트워크 분단에서 정확성 보장이 까다로우므로, 보호 대상이 정합성인지 단순 부하 완화 인지 먼저 구분하고 적용한다.

Write 흐름

Cache-Aside에서 어려운 쪽은 읽기가 아니라 쓰기다. 원본 저장소와 캐시가 동시에 존재하므로, 쓰기 후 캐시를 어떻게 처리할지 정해야 한다.

가장 흔한 방식은 DB를 먼저 갱신하고 캐시를 삭제하는 것이다.

이 순서가 "캐시 삭제 후 DB 갱신"보다 나은 이유가 있는데,
삭제를 먼저 하면, DB를 갱신하기 직전의 짧은 틈에 다른 read가 cache miss를 보고 아직 바뀌지 않은 old value를 다시 캐시에 채워 넣는다. 그러면 갱신이 끝난 뒤에도 캐시에 old value가 남는다.
DB를 먼저 갱신하면 commit 이후 발생한 cache miss는 DB에서
new value를 읽는다. 다만 cache delete 전까지 기존 cache hit는 여전히 old value를 반환할 수 있고, 아래의 stale-set 레이스도 완전히 사라지지 않는다.

C#
public async Task UpdateProductNameAsync(long productId, string name, CancellationToken ct)
{
    var cacheKey = $"product:{productId}:v1";

    // source of truth를 먼저 바꾼다.
    await productRepository.UpdateNameAsync(productId, name, ct);

    // 캐시는 다음 읽기에서 다시 채우게 한다.
    // update보다 delete가 단순한 경우가 많다.
    await cache.RemoveAsync(cacheKey, ct);
}

TypeScript 예:

TypeScript
async function updateProductName(productId: number, name: string): Promise<void> {
  const cacheKey = `product:${productId}:detail:v1`;

  // 먼저 원본 저장소를 바꾼다.
  await productRepository.updateName(productId, name);

  // Cache-Aside에서는 보통 값을 다시 쓰기보다 삭제한다.
  // 다음 read가 DB에서 새 값을 읽고 캐시를 다시 채운다.
  await redis.del(cacheKey);
}

보통은 캐시 값을 직접 갱신하기보다 삭제하는 쪽이 상대적으로 안전하다. 다만 race-free는 아니다.

방식

장점

위험

DB 갱신 후 캐시 삭제

가장 흔하고 상대적으로 안전하다

다음 요청은 cache miss가 난다. 동시 read/write에서는 stale value가 다시 캐시에 들어갈 수 있다

DB 갱신 후 캐시 갱신

다음 읽기가 빠르다

캐시 값 구성 로직이 DB 조회 로직과 중복될 수 있다

캐시 갱신 후 DB 갱신

거의 피한다

DB 실패 시 캐시가 거짓말을 한다

대표 race:

  1. 요청 A가 cache miss를 본다.

  2. A가 DB에서 old value를 읽는다.

  3. 요청 B가 DB를 new value로 갱신한다.

  4. B가 cache delete를 수행한다.

  5. A가 조금 늦게 old value를 cache set한다.

  6. 이후 캐시에 old value가 살아남는다.

요점만 말하자면 A가 old value를 읽는 시점(2)이 B의 캐시 삭제(4)보다 빨라야 한다는 것이다. A의 DB 읽기가 B의 갱신보다 늦었다면 A는 이미 new value를 읽으므로 부활이 일어나지 않는다.

그래서 이 레이스는 "A가 먼저 낡은 값을 읽고, B의 삭제 뒤에 늦게 써넣는" 짧은 틈에서만 성립해서 실제로는 거의 볼 일이 없다.

이 문제를 stale value resurrection이라고 부를 수 있다. 이름이 거창하지만 현상은 단순하다. 이미 지운 낡은 값이 다시 보이는 것이다.
정합성이 더 중요한 경우에는 짧은 TTL, delayed double delete, versioned key, write-through, outbox 기반 invalidation, 도메인 이벤트 기반 무효화 같은 보조 전략과 함께 한다.

Lua 스크립트로 stale 부활 막기

stale-set 레이스를 더 단단히 막으려면, 캐시에 쓸 때 "내가 읽은 버전이 아직 최신일 때만 쓴다"는 조건을 원자적으로 검사해야 한다. 그런데 위 레이스에서는 B가 이미 키를 지웠으므로 SET NX(없을 때만 쓰기)는 그냥 통과해 버린다. 없음과 "낡았음"이 구분되지 않기때문이다.

대신 키마다 논리적 버전(또는 타임스탬프)을 두고, Redis Lua 스크립트로 "내가 읽은 버전이 아직 최신일 때만 쓴다"를 원자적으로 검사한다. Lua는 Redis에서 원자적으로 돌기 때문에 GET과 SET 사이에 끼어드는 명령이 없다.

여기서 예제들 중에서 꽤 많이 실수하는 것들이 있는데, 버전을 cache value key 안에 함께 저장하는 것만으로는 부활을 막지 못한다. update 시 그 키를 삭제하면 버전도 같이 사라지기 때문이다. 늦게 도착한 old writer는 버전이 없는(nil) 상태를 보고 그대로 써버린다.

Text
A가 DB에서 version 1을 읽음
B가 DB를 version 2로 갱신
B가 cache key를 DEL  (안에 있던 ver도 함께 사라짐)
A가 늦게 Lua 실행 -> HGET ver = nil -> 통과 -> old value 부활

그래서 old writer를 막으려면 캐시 무효화로 지워지지 않는 별도의 version fence key(또는 tombstone marker)가 필요하다. fence는 그 키의 최신 버전을 들고 있다.

LUA
-- KEYS[1] = cache value key
-- KEYS[2] = version fence key (삭제되지 않음)
-- ARGV[1] = value, ARGV[2] = my_version, ARGV[3] = ttl_seconds
local fence = redis.call('GET', KEYS[2])
if fence and tonumber(ARGV[2]) < tonumber(fence) then
  return 0  -- 내가 읽은 뒤 더 새 DB write가 있었다. 쓰지 않는다.
end
redis.call('HSET', KEYS[1], 'ver', ARGV[2], 'val', ARGV[1])
redis.call('EXPIRE', KEYS[1], ARGV[3])
return 1

쓰기(write) 흐름은 이렇게 맞춘다.

Text
DB commit
-> version fence key를 new_version으로 SET
-> cache value key를 DEL

이러면 fence가 항상 최신 버전을 알고 있으므로, 낡은 버전으로 늦게 도착한 캐시 쓰기는 fence 비교에서 거부된다. 정합성이 중요한 키에 쓰는 표준적인 보강이며, 버전 소스(DB 시퀀스, updated_at, 논리 시계)를 명확히 정하고 fence key가 너무 일찍 만료되지 않도록(캐시보다 길게, 또는 영속) 수명을 관리해야 한다.

Redis Cluster 주의: CROSSSLOT와 hash tag

위 Lua는 KEYS[1](캐시 값)과 KEYS[2](fence) 두 키를 함께 만진다. 단일 노드에서는 문제없지만, 샤딩된 Redis Cluster에서는 두 키가 서로 다른 노드에 배치될 수 있다. 그러면 멀티키 명령·스크립트가 원자성을 보장할 수 없어 CROSSSLOT 에러로 즉시 실패한다.

해결책은 두 키를 같은 슬롯에 강제로 묶는 hash tag다. 키 이름에 중괄호 {}를 넣으면 Redis는 중괄호 안 문자열만 해싱에 사용한다.
같은 태그를 공유하는 키는 항상 같은 슬롯에 들어간다.

Text
나쁜 예 : product:123:val      / product:123:fence    (다른 슬롯에 갈 수 있음)
좋은 예 : {product:123}:val    / {product:123}:fence  (같은 슬롯 보장)

즉 클러스터에서 멀티키 Lua를 쓰려면, 함께 다루는 키들이 동일한 hash tag를 공유하도록 key 네이밍 규칙을 표준화해야 한다. Key 설계에 hash tag를 포함시켜 두는 편이 깔끔하다.

DB와 Redis는 한 트랜잭션이 아니다

fence 흐름(DB commit -> fence SET -> cache DEL)도 DB와 Redis를 묶는 원자적 트랜잭션은 아니다. DB commit은 성공했는데 fence SET이나 cache DEL이 실패할 수 있고, 그러면 캐시가 낡은 채로 남는다. 정합성이 중요한 키는 이 실패를 로그만 남기지 말고, outbox/event 기반 재시도나 background invalidation worker로 무효화 를 보장해야 한다.
"Lua를 쓰면 완전히 해결된다"는 오해를 가지면 안되는 것이다.

실무 기준:

  • DB commit이 끝난 뒤 캐시를 지운다.

  • 캐시 삭제 실패는 로그와 재시도 대상으로 본다.

  • 캐시 값이 여러 key로 퍼져 있으면 무효화 목록을 명확히 둔다.

  • 캐시가 source of truth가 되지 않게 한다.

TTL 설계

TTL(Time To Live)은 캐시 값이 살아 있는 시간이다. Cache-Aside에서 TTL은 성능과 일관성 사이의 조절 장치다.

TTL

결과

너무 짧음

cache miss가 많아져 DB 부하가 줄지 않는다

너무 김

오래된 값이 오래 보인다

없음

무효화 누락 시 데이터가 계속 썩는다

대략의 감각:

데이터

TTL 예시

상품 상세

1분에서 10분

카테고리 목록

10분에서 1시간

권한 목록

수십 초에서 수분

대시보드 요약

10초에서 5분

환율, 재고, 좌석

도메인에 따라 매우 짧게

TTL은 "얼마나 최신이어야 하는가"보다 "오래된 값이 보여도 업무적으로 괜찮은가"로 잡는 편이 낫다.

Key 설계

캐시 key는 나중에 운영자가 보고도 의미를 알 수 있어야 한다.

좋은 key 예시:

C#
var key = $"product:{productId}:detail:v1";

TypeScript에서는 key builder 함수로 모아두는 편이 낫다.

TypeScript
function productDetailKey(productId: number): string {
  return `product:${productId}:detail:v1`;
}

key에 넣을 것:

  • 도메인 이름: product, user, permission

  • 식별자: 123

  • 조회 모양: detail, summary, permissions

  • 버전: v1, v2

버전은 중요하다. DTO 모양이 바뀌었는데 기존 캐시 값을 그대로 읽으면 역직렬화 오류나 이상한 응답이 날 수 있다. 이때 key 버전을 올리면 오래된 캐시와 새 캐시를 자연스럽게 분리할 수 있다.

나쁜 key 예:

C#
var key = id.ToString();

TypeScript에서도 마찬가지다.

TypeScript
const key = String(id);

이런 key는 다른 도메인과 충돌하기 쉽고, 운영 중에 어떤 값인지 알아보기 어렵다.

Cache Stampede

Cache Stampede는 인기 key가 동시에 만료되면서 많은 요청이 한꺼번에 DB로 몰리는 현상이다.

상황:

  1. product:1 캐시가 만료된다.

  2. 동시에 요청 1,000개가 들어온다.

  3. 모두 cache miss를 본다.

  4. 모두 DB를 조회한다.

  5. DB가 캐시 대신 트래픽을 그대로 맞는다.

대응 방법:

  • TTL에 작은 jitter를 섞어 동시에 만료되지 않게 한다.

  • 같은 key에 대해 한 요청만 DB를 조회하게 한다.

  • 오래된 값을 잠깐 반환하는 stale-while-revalidate 방식을 쓴다.

  • 인기 key는 미리 warming한다.

간단한 single-flight 형태:

C#
private static readonly ConcurrentDictionary<string, SemaphoreSlim> Locks = new();

public async Task<ProductDto?> GetProductAsync(long productId, CancellationToken ct)
{
    var key = $"product:{productId}:detail:v1";

    var cached = await cache.GetAsync<ProductDto>(key, ct);
    if (cached is not null)
    {
        return cached;
    }

    var gate = Locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
    await gate.WaitAsync(ct);

    try
    {
        // lock을 잡기 전에 다른 요청이 이미 캐시를 채웠을 수 있으므로 다시 확인한다.
        cached = await cache.GetAsync<ProductDto>(key, ct);
        if (cached is not null)
        {
            return cached;
        }

        var product = await productRepository.FindDtoAsync(productId, ct);
        if (product is null)
        {
            return null;
        }

        await cache.SetAsync(key, product, TimeSpan.FromMinutes(5), ct);
        return product;
    }
    finally
    {
        gate.Release();
    }
}

이 C# 예제도 단일 애플리케이션 프로세스 안에서만 중복 조회를 줄인다. 서버가 여러 대면 각 서버가 자기 프로세스 안에서만 lock을 잡기 때문에 Redis 전체 기준 stampede는 막지 못한다. 그 경우 Redis lock, distributed lock, request coalescing 계층, background refresh 같은 별도 전략이 필요하다.

또한 위 예제는 단순화를 위해 Locks 정리 로직을 생략했다. key 종류가 많은 서비스에서 ConcurrentDictionary<string, SemaphoreSlim>을 계속 키우면 lock 객체가 남을 수 있다. 실제 서비스에서는 ref-count, 만료형 lock map, Lazy<Task> 기반 coalescing 같은 정리 전략을 둔다. 대충 CurrentCount만 보고 TryRemove를 넣으면, 기다리는 요청과 새 요청이 서로 다른 lock을 잡는 더 나쁜 race를 만들 수 있다.

TypeScript에서 단일 Node.js 프로세스 안의 중복 조회를 줄이는 간단한 형태는 Map에 진행 중인 Promise를 저장하는 방식이다.

TypeScript
const inFlight = new Map<string, Promise<ProductDto | null>>();

async function getProductWithSingleFlight(productId: number): Promise<ProductDto | null> {
  const key = productDetailKey(productId);

  const cached = await redis.get(key);
  if (cached !== null) {
    return JSON.parse(cached) as ProductDto;
  }

  const existing = inFlight.get(key);
  if (existing !== undefined) {
    // 같은 key를 이미 다른 요청이 DB에서 읽고 있다.
    // 새 DB 쿼리를 만들지 않고 같은 Promise를 기다린다.
    return existing;
  }

  const loading = (async () => {
    try {
      // 기다리는 동안 다른 요청이 캐시를 채웠을 수 있으므로 다시 확인한다.
      const secondCached = await redis.get(key);
      if (secondCached !== null) {
        return JSON.parse(secondCached) as ProductDto;
      }

      const product = await productRepository.findDto(productId);
      if (product === null) {
        return null;
      }

      await redis.set(key, JSON.stringify(product), { EX: 300 });
      return product;
    } finally {
      inFlight.delete(key);
    }
  })();

  inFlight.set(key, loading);
  return loading;
}

이 예제는 단일 프로세스 안에서만 통한다. 서버가 여러 대면 Redis lock, request coalescing, background refresh 같은 별도 전략이 필요하다.

분산 환경: 확률적 조기 만료 (PER / XFetch)

서버가 수십 대인 분산 환경에서 읽기마다 분산 락을 잡는 것은 오버헤드가 너무 크다. 락 없이 수학적으로 stampede를 막는 대안이 확률적 조기 재계산(PER,Probabilistic Early Recomputation), 흔히 XFetch라 불리는 기법이다.[6]

스탬피드 방어 전략을 한눈에 비교하면 이렇다.

전략

보장

비용

Single-flight

프로세스 내부 중복 조회 감소

서버 여러 대면 한계

Distributed lock

key 단위 재계산 수를 강하게 제한

락 비용, 만료·분단 이슈

PER / XFetch

확률적으로 stampede 완화

정확히 1개만 재계산 보장 안 함

Stale-while-revalidate

사용자 지연 최소화

백그라운드 작업 생명주기 필요

핵심은, 캐시가 실제로 만료되기 전에 각 요청이 일정 확률로 "내가 미리
갱신하겠다"고 독립적으로 판정하는 것이다. 만료가 가까울수록 그 확률이 지수적으로 커진다. 판정식은 다음과 같다.

Text
now - delta * beta * ln(rand()) >= expiry

now    : 현재 시각
delta  : 값을 재계산(DB 조회)하는 데 걸린 시간
beta   : 확률 가중치 (보통 1, 키울수록 더 일찍 갱신)
rand() : 0과 1 사이 난수
expiry : 캐시의 논리적 만료 시각

ln(rand())이 음수이므로 좌변은 now보다 큰 값이 되고, nowexpiry에 가까워질수록 조건이 참이 될 확률이 커진다. 만료 전에 당첨된 요청이 미리 DB를 조회해 캐시를 갱신하고, 나머지 요청은 아직 살아 있는 기존 값을 그대로 받는다. PER은 정확히 한 요청만 재계산한다고 보장하지는 않는다. 운이 나쁘거나 트래픽이 크면 여러 요청이 동시에 재계산할 수 있다. 다만 분산 락 없이 재계산 요청 수를 확률적으로 줄여, 만료 시점에 모든 요청이 한꺼번에 DB로 몰리는 현상을 크게
완화한다.

구현하려면 값과 함께 delta(재계산 비용)와 논리적 만료 시각을 저장하고, Redis의 실제 TTL은 그 만료보다 약간 길게 둬서 조기 갱신 여유를 남긴다.

TypeScript
type Wrapped<T> = { value: T; deltaMs: number; expiresAt: number };

function shouldEarlyRecompute(deltaMs: number, expiresAt: number, beta = 1): boolean {
  // Math.random()이 0이면 Math.log(0) = -Infinity가 되므로 EPSILON으로 막는다.
  const random = Math.max(Number.EPSILON, Math.random());
  // ln(random)은 음수이므로 (now - delta*beta*ln(random))은 now보다 커진다.
  const gap = deltaMs * beta * Math.log(random);
  return Date.now() - gap >= expiresAt;
}

async function getProductXFetch(productId: number): Promise<ProductDto | null> {
  const key = productDetailKey(productId);
  const raw = await redis.get(key);

  if (raw !== null) {
    const wrapped = JSON.parse(raw) as Wrapped<ProductDto>;
    if (!shouldEarlyRecompute(wrapped.deltaMs, wrapped.expiresAt)) {
      return wrapped.value; // 대부분의 요청이 여기서 끝난다.
    }
    // 당첨된 요청만 아래로 내려가 미리 갱신한다. 기존 값은 아직 유효하다.
  }

  const start = Date.now();
  const product = await productRepository.findDto(productId);
  if (product === null) {
    return null;
  }
  const deltaMs = Date.now() - start;

  const ttlSeconds = 300;
  const wrapped: Wrapped<ProductDto> = {
    value: product,
    deltaMs,
    expiresAt: Date.now() + ttlSeconds * 1000,
  };
  // 실제 TTL은 논리 만료보다 약간 길게 둔다.
  await redis.set(key, JSON.stringify(wrapped), { EX: ttlSeconds + 10 });
  return product;
}

이 예제는 단순화를 위해 당첨된 요청이 직접 DB를 await해 새 값을 채운다. 즉 stale-while-revalidate가 아니라 lock-free early refresh에 가깝다. stale 값을 즉시 반환하고 갱신은 뒤로 미루려면 refresh를 백그라운드로 분리해야 한다.

다만 C# ASP.NET Core에서 _ = Task.Run(...) 같은 단순 fire-and-forget은 안티패턴이다. HTTP 요청 컨텍스트가 끝나면 그 백그라운드 작업의 완료가 보장 되지 않고, 앱 풀 재시작·배포 시 유실되며, 예외가 메인으로 전달되지 않아 조용히 실패한다(silent failure). 그래서 갱신 작업은 BackgroundServiceIHostedService로 돌리거나, Channel<T> 기반 인메모리 큐에 일을 넣고 전용 컨슈머가 처리하도록 제어 흐름과 생명주기를 분리한다.

C#도 판정 구조는 같아서, 값과 함께 deltaMs, expiresAt을 저장하고 위 판정식을 적용하면 된다. PER은 분산 락이 부담스러운 읽기 위주 hot key에 특히 잘 맞는다.

인프로세스 코얼레싱의 메모리: Lazy 패턴

앞의 ConcurrentDictionary<string, SemaphoreSlim>은 동기화 객체를 직접 들고 있어 정리 전략이 필요했고, 어설픈 TryRemove는 더 나쁜 race를 만든다. 대안은 lock이 아니라 진행 중인 작업 자체를 값으로 들고, 작업이 끝나면 스스로 빠지게 하는 것이다.

ConcurrentDictionary<string, Lazy<Task<Result<ProductDto>>>>를 쓰면,GetOrAdd로 같은 key의 동시 요청이 단 하나의 Lazy를 공유한다. LazyTask를 한 번만 시작하므로 DB 조회도 한 번만 일어나고, 기다리는 모든 요청은 같은 Task를 await해 같은 Result를 받는다. 그리고 그 Task의 완료 시점 (continuation)에 해당 key를 맵에서 제거하면, 맵은 "전체 key 수"가 아니라 "현재 진행 중인 key 수"만큼만 커진다. 즉 자기정리되므로 별도 eviction 스레드도, semaphore 정리도 필요 없다.

요점은 동기화 프리미티브(SemaphoreSlim)를 공유하는 대신, 결과를 담은 불변Task를 공유한다는 것이다. 메모리는 동시성 수준에 비례해 묶이고, 완료 즉시 제거로 GC 부담도 최소화된다. (continuation에서 제거할 때, 추가가 제거보다 늦어 새 Lazy가 끼는 경계만 주의하면 된다.)

아래 코드는 설명을 단순화하기 위해 Result<T> 대신 ProductDto?를 쓴다.

실제 코드에서는 miss와 failure를 구분하려고 Result<T>나 전용 union을 쓴다.

C#
private readonly ConcurrentDictionary<string, Lazy<Task<ProductDto?>>> inFlight = new();

public Task<ProductDto?> CoalesceAsync(string key, Func<Task<ProductDto?>> factory)
{
    var lazy = inFlight.GetOrAdd(
        key,
        _ => new Lazy<Task<ProductDto?>>(
            factory,
            LazyThreadSafetyMode.ExecutionAndPublication));
    return AwaitAndRemoveAsync(key, lazy);
}

private async Task<ProductDto?> AwaitAndRemoveAsync(
    string key,
    Lazy<Task<ProductDto?>> lazy)
{
    try
    {
        return await lazy.Value.ConfigureAwait(false);
    }
    finally
    {
        // key와 lazy가 모두 일치할 때만 제거해, 그 사이 끼어든 새 Lazy를 지우는 race를 피한다.
        inFlight.TryRemove(new KeyValuePair<string, Lazy<Task<ProductDto?>>>(key, lazy));
    }
}

GetOrAdd의 factory가 Lazy를 만들고 Lazy.ValueTask를 단 한 번 시작한다. TryRemove에 key와 lazy를 함께 넘기는 것이 핵심이다. 그래야 그 사이 다른 요청이 새로 넣은 Lazy를 실수로 지우지 않는다.

Negative Caching

없는 값도 반복 조회될 수 있다. 예를 들어 존재하지 않는 상품 ID나 삭제된 사용자 ID를 계속 조회하면 매번 DB를 치게 된다.

이때 짧은 TTL로 "없음"을 캐싱할 수 있다.

C#
var missingKey = $"product:{productId}:missing:v1";

var isMissing = await cache.GetAsync<bool?>(missingKey, ct);
if (isMissing is true)
{
    return null;
}

var product = await productRepository.FindDtoAsync(productId, ct);
if (product is null)
{
    // 없는 값은 짧게만 캐싱한다.
    // 나중에 같은 ID가 생성될 가능성이 있거나 복구될 수 있기 때문이다.
    await cache.SetAsync(missingKey, true, TimeSpan.FromSeconds(30), ct);
    return null;
}

TypeScript 예:

TypeScript
async function getProductWithNegativeCache(productId: number): Promise<ProductDto | null> {
  const missingKey = `product:${productId}:missing:v1`;
  const missing = await redis.get(missingKey);
  if (missing !== null) {
    return null;
  }

  const product = await productRepository.findDto(productId);
  if (product === null) {
    // 없는 값은 짧게만 캐싱한다.
    await redis.set(missingKey, "1", { EX: 30 });
    return null;
  }

  return product;
}

주의할 점:

  • negative cache TTL은 짧게 둔다.

  • negative cache를 별도 key로 둘 경우, 해당 엔티티가 생성되거나 복구될 때 missing key도 함께 삭제한다.

  • 권한, 결제, 재고처럼 상태 변화가 민감한 값에는 조심한다.

  • "없음"과 "캐시 장애"를 구분해야 한다.

캐시 장애 처리

캐시는 성능 장치이지 source of truth가 아니다. 따라서 캐시 장애가 전체 장애로 번지면 설계가 잘못된 것이다.

읽기에서 캐시가 죽었을 때:

아래 코드는 설명용 단순 예제다. 실제 코드에서는 이 try/catch를 서비스 본문이 아니라 cache adapter 경계로 밀어낸다(뒤의 [에러 정책] 절 참고).

C#
try
{
    var cached = await cache.GetAsync<ProductDto>(key, ct);
    if (cached is not null)
    {
        return cached;
    }
}
catch (Exception ex)
{
    // 캐시 장애는 기록하되, 가능하면 DB로 fallback한다.
    logger.LogWarning(ex, "Cache read failed. key={CacheKey}", key);
}

return await productRepository.FindDtoAsync(productId, ct);

실무 기준:

  • 캐시 read 실패는 가능하면 DB fallback한다.

  • 캐시 write 실패는 응답 실패로 만들지 않는 경우가 많다.

  • 캐시 timeout은 짧게 둔다.

  • 캐시 장애 시 무조건 DB fallback하면 DB까지 연쇄 장애가 날 수 있다. timeout, circuit breaker, rate limit, degraded response, stale cache 반환 같은 보호 전략도 같이 본다.

  • 캐시 장애율, hit ratio, miss ratio를 모니터링한다.

  • 캐시가 죽었을 때 DB가 버틸 수 있는지도 따져야 한다.

마지막 항목이 중요한 지점은 캐시가 죽으면 모든 읽기가 DB로 돌아간다. 평소에 캐시 hit ratio가 95%였던 서비스는 캐시 장애 순간 DB 트래픽이 20배 가까이 늘 수 있다.

이 연쇄를 막는 것은 재시도가 아니라 격리다. 캐시 호출과 DB fallback을 Circuit Breaker로 감싸면, 캐시(또는 DB)가 임계치 이상 실패할 때 회로를 열어 빠르게 degraded response로 떨어뜨리고, 일정 시간 뒤 일부 요청만 흘려 보내 회복을 탐색한다. bulkhead(연결 풀 격리)와 함께 쓰면 한 의존성의 장애가 전체 스레드 풀을 잠식하는 것도 막는다. 캐시 장애 처리는 코드 레벨try/fallback이 아니라 이런 아키텍처 레벨 격리로 완성된다. (Circuit Breaker 참고)

에러 정책: Result 타입과 가드 클로즈

위 예제들은 cache miss를 null로, 캐시 장애를 try/catch로 다룬다. 빠른 설명에는 충분하지만, 정합성과 아키텍처 경계가 중요한 서비스에서는 두 가지가 걸린다. 첫째, miss(값이 없음)와 failure(캐시가 죽음)가 둘 다 null로 뭉개져 호출자가 구분할 수 없다. 둘째, try/catch가 조회 비즈니스 로직 안으로 침투해 제어 흐름이 된다.

cache miss와 조회 실패는 예외적 상황이 아니라 시스템이 예측하고 복구해야 하는 일반적 상태다. 그래서 예측 가능한 실패는 예외가 아니라 Result로 모델링하고, try/catch는 캐시 어댑터 같은 경계에만 둔다. 그러면 본문은 가드 클로즈로 상태를 좁힌 뒤 정상 흐름만 남는다.

C#
public enum CacheStatus { Hit, Miss, Down }
public readonly record struct CacheRead<T>(CacheStatus Status, T? Value);

// 어댑터 경계: 여기서만 try/catch로 인프라 실패를 Result로 바꾼다.
public async Task<CacheRead<ProductDto>> TryReadAsync(string key, CancellationToken ct)
{
    try
    {
        var cached = await cache.GetAsync<ProductDto>(key, ct);
        return cached is null
            ? new CacheRead<ProductDto>(CacheStatus.Miss, null)
            : new CacheRead<ProductDto>(CacheStatus.Hit, cached);
    }
    catch (Exception ex)
    {
        logger.LogWarning(ex, "Cache read failed. key={CacheKey}", key);
        return new CacheRead<ProductDto>(CacheStatus.Down, null);
    }
}

public async Task<ProductDto?> GetProductAsync(long productId, CancellationToken ct)
{
    var key = $"product:{productId}:detail:v1";

    var read = await TryReadAsync(key, ct);

    // 가드 클로즈: hit이면 즉시 반환한다.
    if (read.Status == CacheStatus.Hit)
    {
        return read.Value;
    }

    // miss든 down이든 DB가 source of truth다. 비즈니스 로직엔 try/catch가 없다.
    var product = await productRepository.FindDtoAsync(productId, ct);
    if (product is null)
    {
        return null;
    }

    // 캐시가 down이면 굳이 쓰기를 시도하지 않는다. miss일 때만 채운다.
    if (read.Status == CacheStatus.Miss)
    {
        await TryWriteAsync(key, product, TimeSpan.FromMinutes(5), ct);
    }

    return product;
}

이렇게 하면 에러 정책이 한곳(어댑터 경계)으로 모인다. 비즈니스 로직은 Hit / Miss / Down 세 상태를 명시적으로 분기할 뿐, 인프라 예외를 직접 처리하지 않는다. miss와 down을 구분하므로 "캐시가 죽었을 때는 쓰기를 건너뛴다" 같은 정책도 자연스럽게 표현된다.

다만 위 CacheRead<T>T? Value라서, C# 컴파일러가 Status == Hit일 때 Value가 non-null임을 자동으로 추론하지 못한다. 실제 코드에서는 nullable 경고가 날 수 있으므로 read.Value!로 단언하거나, Hit/Miss/Down을 전용 union type(또는 helper 메서드)으로 감싸 상태와 값의 관계를 타입으로 강제하는 편이 낫다.

Read-Through, Write-Through와 차이

Cache-Aside는 애플리케이션이 캐시를 직접 관리한다. 반면 Read-Through나 Write-Through는 캐시 계층이 원본 저장소 접근을 더 많이 감춘다.

패턴

원본 저장소 접근 책임

특징

Cache-Aside

애플리케이션

가장 흔하고 단순하다. 앱 코드가 책임을 많이 진다

Read-Through

캐시 계층

앱은 캐시만 본다. 캐시 구현이 복잡해진다

Write-Through

캐시/저장 계층

쓰기 시 캐시와 저장소를 함께 갱신한다

Write-Behind

캐시/큐/백그라운드

빠르지만 데이터 손실과 일관성 설계가 어렵다

Cache-Aside는 단순하지만, 그 단순함의 대가는 애플리케이션 코드가 무효화와 장애 처리를 직접 해야 한다는 점이다.

성능 최적화: 직렬화와 할당

지금까지 예제는 값을 JSON으로 직렬화·역직렬화한다. 디버깅이 쉽고 어디서나 통하는 기본값이지만, 읽기 빈도가 극단적으로 높은 hot key에서는 매 조회마다 문자열 할당과 JSON 파싱이 일어나 C#의 GC(특히 Gen0)에 부담을 준다. 캐시가 빨라도 GC 일시정지가 p99 지연을 끌어올릴 수 있다.

언제 바꿀지는 정해진 상수가 아니라 측정으로 정한다.
주로 측정하는 지표는 다음과 같다.

  • 할당률(allocation rate)과 Gen0 수집 빈도가 CPU의 유의미한 비율을 차지하는가

  • p99·p999 지연 스파이크가 GC 일시정지와 상관되는가

  • 그 hot key의 QPS와 payload 크기(수 KB 이상이면 파싱 비용이 커진다)

  • 직렬화 비용이 지연 예산(latency budget) 안에 들어오는가

이 신호가 임계에 닿기 전까지는 JSON으로 두는 편이 낫다. 섣부른 바이너리 전환은 디버깅성과 스키마 진화 편의를 잃는 대가만 먼저 치른다. 측정된 hot 3%에만 적용한다. 임계를 넘으면 다음 순서로 내려간다.

  1. JSON을 유지하되 비용 절감: System.Text.Json source generator로 리플렉션을 없애고, Utf8JsonReader로 Redis가 준 바이트(RedisValue)를 중간 string 생성 없이 바로 파싱한다.

  2. 바이너리 프로토콜: 크기와 속도가 더 필요하면 MessagePack이나 Protobuf로 바꾼다. payload가 작아지고 파싱이 빨라지지만 사람이 읽기 어려워지고, key 버전 관리가 더 중요해진다.

  3. Zero-allocation 읽기: 레이아웃이 고정된 값이라면Span<T>·Memory<T>ArrayPool<byte> 풀링으로 역직렬화 과정의 힙 할당을 거의 0으로 만든다.

정리하면 기준점은 "데이터 크기 곱하기 조회 빈도가 GC·지연 예산을 위협하기시작하는 측정 지점"이다.

그 전에는 JSON, 그 후에는 바이트 파싱 → 바이너리 → zero-alloc 순으로 필요한 만큼만 내려간다.

언제 쓰면 안 되는가?

  • 항상 최신 데이터가 필요할 때

  • 캐시 무효화 기준을 정할 수 없을 때

  • 쓰기가 매우 잦고 읽기 재사용이 적을 때

  • 데이터마다 TTL과 일관성 요구가 다른데 하나의 정책으로 밀어붙일 때

  • 캐시 장애가 전체 장애로 번질 때

  • 캐시 key가 사용자 권한이나 tenant 경계를 제대로 반영하지 못할 때

특히 권한과 개인정보는 조심해야 한다.
user:{id} 같은 key만 쓰면 tenant, role, locale, permission scope가 빠져서 다른 사용자에게 잘못된 데이터가 보일 수 있다.

보안과 권한 경계

Cache-Aside는 애플리케이션이 key를 직접 만들기 때문에, 권한 경계가 key에서 새면 곧바로 데이터 유출이 된다. 다음을 규칙으로 둔다.

  • tenant ID 없는 key 금지. 멀티테넌시면 key에 tenant를 반드시 포함한다.

  • user ID만으로 권한을 캐싱하지 않는다. role, scope, locale, 버전을 key에 넣는다.

  • 권한 캐시는 최종 판정용이 아니라 힌트로만 쓰고, 민감한 결정은 권한 서버나 DB에서 다시 확인한다.

  • 개인정보(PII)가 든 payload는 암호화 여부를 검토하고, 캐시 로그·트레이스에 PII를 남기지 않는다.

  • Redis 접근 자체를 보호한다. ACL로 권한을 최소화하고, 전송 구간은 TLS로 감싼다.

특히 권한 축소, 조직 이동, 구독 해지, 결제 취소처럼 권리가 줄어드는 이벤트는 TTL 만료를 기다리지 말고 즉시 invalidation해야 한다. 권한 캐시에서 진짜 위험한 것은 권한 증가가 아니라 권한 회수 지연이다.

핵심은 캐시가 빠른 만큼 잘못된 경계도 빠르게 유출된다는 것이다. key가 곧 접근 통제 경계임을 잊지 않는다.

체크리스트

Cache-Aside를 넣기 전에 확인할 것:

  • 이 데이터는 반복 조회되는가?

  • stale data 허용 시간이 명확한가?

  • key에 도메인, 식별자, 조회 모양, 버전이 들어가는가?

  • 쓰기 후 어떤 key를 지울지 정해져 있는가?

  • cache stampede를 막을 방법이 있는가?

  • 캐시 장애 시 DB fallback이 가능한가?

  • 캐시 hit ratio, miss ratio, latency를 볼 수 있는가?

  • 캐시 값에 개인정보나 권한 경계가 섞이지 않는가?

체크리스트의 "hit ratio, miss ratio, latency를 볼 수 있는가"는 그것을 어떻게 볼지가 관건이다. Cache-Aside는 애플리케이션이 제어권을 쥐므로, Redis 서버 메트릭만 봐서는 어떤 엔드포인트나 비즈니스 로직이 캐시를 뚫고 DB 부하를 만드는지 알기 어렵다.
OpenTelemetry 같은 분산 추적을 도입해 캐시 조회 구간에 명시적 Span을 만들고, hit/miss를 태그로 기록한 뒤 그 아래 DB 쿼리 트레이스와 연결(correlation)하면, "어느 경로의 miss가 DB를 때리는가"를 추적 단위로 짚을 수 있다. 인프라 메트릭(서버 hit ratio)과 애플리케이션 추적(엔드포인트별 hit/miss)을 함께 봐야 Cache-Aside의 제어 책임에 맞는 관측성이 완성된다.

멋있게 요약하기:

Cache-Aside는 "DB를 빠르게 만드는 마법"이 아니라, 읽기 부하와 일관성 위험을 교환하는 패턴이다. 캐시는 빨라지는 대신 반드시 무효화, TTL, 장애, 권한 key 문제를 가져온다.

같이 보기

  • 프로그래밍 패턴

  • Retry

  • Timeout

  • Circuit Breaker

  • 데코레이터 패턴

  • 컬럼형 데이터베이스

  • RDBMS

참고문헌

[1] Microsoft Azure Architecture Center. Cache-Aside pattern

[2] AWS. Caching Best Practices: Lazy caching

[3] AWS Whitepaper. Database Caching Strategies Using Redis: Cache-Aside. AWS 문서 자체는 일부 whitepaper가 historical reference로 표시될 수 있으므로, 개념 참고용으로 본다.

[4] Redis. Caching solutions: cache aside

[5] Microsoft Azure Architecture Center. Caching guidance

[6] Vattani, A., Chierichetti, F., Lowenstein, K. Optimal Probabilistic Cache Stampede Prevention. PVLDB 8(8), 886-897, 2015. (PER / XFetch)


분류: 프로그래밍 패턴 | 운영 패턴 | 캐싱 | WIKI

편집 역사 — Cache-Aside