본문으로 건너뛰기
wooncloud

캐싱 전략 총정리: cache-aside부터 stampede 방어까지

cache-aside·write-through·write-behind 등 캐싱 패턴과 무효화 전략, 그리고 캐시 스탬피드(thundering herd)를 막는 락·확률적 만료·single-flight 방어까지 정리한다.

·26 min read· views·

캐싱 전략 총정리: cache-aside부터 stampede 방어까지

캐시는 성능의 지름길이다. 디스크 I/O나 네트워크 왕복, 복잡한 집계 쿼리를 메모리 조회 한 번으로 바꿔준다. 그러나 공짜는 아니다. 캐시는 데이터의 사본을 만드는 행위이고, 사본이 존재하는 순간부터 우리는 "원본과 사본이 어긋날 수 있다"는 일관성 비용을 떠안는다. Phil Karlton의 유명한 농담처럼, 컴퓨터 과학에서 어려운 건 두 가지뿐이다. 캐시 무효화와 이름 짓기.

이 글은 캐시를 도입할 때 마주치는 핵심 의사결정을 정리한다. 어떤 읽기/쓰기 패턴을 쓸지, 언제 무효화할지, cache-aside의 고질적인 레이스를 어떻게 다룰지, 그리고 인기 키가 만료되는 순간 발생하는 캐시 스탬피드를 어떻게 막을지를 다룬다. 모든 선택에는 트레이드오프가 따르므로, 정답보다는 "내 워크로드에 맞는 균형점"을 찾는 게 목표다.

읽기/쓰기 패턴

캐시 패턴은 결국 "캐시 미스를 누가 채우는가"와 "쓰기가 캐시와 DB에 어떤 순서로 반영되는가"의 문제다.

Cache-aside (lazy loading)

가장 흔한 패턴이다. 애플리케이션이 캐시를 직접 들여다보고, 미스가 나면 DB에서 읽어 캐시를 채운다. 캐시는 "옆에 있는(aside)" 보조 저장소일 뿐, 쓰기 경로에 끼어들지 않는다.

async function getUser(id: string): Promise<User> {
  const key = `user:${id}`;
 
  const cached = await redis.get(key);
  if (cached !== null) {
    return JSON.parse(cached) as User; // cache hit
  }
 
  // cache miss → DB 로드 후 캐시 채움
  const user = await db.user.findUniqueOrThrow({ where: { id } });
  await redis.set(key, JSON.stringify(user), "EX", 300); // TTL 300s
  return user;
}

장점은 단순함과 회복력이다. 캐시가 죽어도 애플리케이션은 DB로 직접 폴백한다. 실제 요청된 데이터만 캐시되므로 메모리도 효율적이다.

단점은 두 가지다. 첫째, 모든 키의 첫 접근은 반드시 미스라서 그 요청은 DB 지연을 그대로 떠안는다(cold start). 둘째, 무효화 책임이 애플리케이션 코드에 흩어진다. 데이터를 바꾸는 모든 경로가 캐시 삭제를 잊지 않아야 한다. 잊는 순간 stale 데이터가 남는다.

Read-through

cache-aside에서 "미스 시 DB를 읽어 채우는" 로직을 애플리케이션이 아니라 캐시 계층(또는 캐시 라이브러리/프록시)이 책임진다. 호출하는 쪽은 캐시에만 물어보면 된다.

// 캐시 계층이 로더를 알고 있다. 미스는 캐시가 알아서 채운다.
const userCache = createReadThroughCache<User>({
  ttl: 300,
  loader: (id) => db.user.findUniqueOrThrow({ where: { id } }),
});
 
async function getUser(id: string): Promise<User> {
  return userCache.get(id); // 미스 처리는 캐시 내부에서
}

cache-aside와 데이터 흐름은 같지만, 미스 처리 로직이 한 곳에 모인다는 게 핵심 차이다. 여러 호출 지점에서 같은 캐시-DB 로직을 복붙하는 걸 막아준다. 단점은 캐시 계층이 데이터 소스에 강하게 묶인다는 점, 그리고 첫 접근 미스 지연은 여전하다는 점이다.

Write-through

쓰기가 들어오면 캐시와 DB를 동시에(동기적으로) 갱신한다. 보통 캐시를 먼저 쓰고 그 캐시 계층이 DB까지 기록하거나, 애플리케이션이 둘 다 한 트랜잭션 흐름으로 처리한다.

async function updateUser(id: string, patch: Partial<User>): Promise<User> {
  const key = `user:${id}`;
 
  const user = await db.user.update({ where: { id }, data: patch });
  await redis.set(key, JSON.stringify(user), "EX", 300); // 쓰기와 동시에 캐시 갱신
  return user;
}

장점은 캐시가 항상 최신이라는 것이다(읽기 일관성↑). 쓰기 직후 읽기는 무조건 히트한다. 단점은 쓰기 지연이 늘어난다는 것(DB + 캐시 양쪽을 기다림), 그리고 한 번도 읽히지 않을 데이터까지 캐시에 올려 메모리를 낭비할 수 있다는 점이다. 그래서 write-through는 종종 read-through와 짝지어 "쓰면서 캐시도 데우는" 전략으로 쓴다.

주의할 함정: 두 저장소에 순차적으로 쓰는 한 둘 사이엔 원자성이 없다. DB는 성공했는데 캐시 set이 실패하면 캐시가 오히려 stale해진다. 그래서 실무에선 "쓰기 시 캐시를 갱신"보다 "쓰기 시 캐시를 삭제"가 더 안전한 경우가 많다(뒤에서 다룸).

Write-behind (write-back)

캐시에 먼저 쓰고 즉시 응답한 뒤, DB 반영은 비동기로(배치/큐로 모아서) 처리한다.

async function recordView(postId: string): Promise<void> {
  // 캐시(카운터)만 즉시 증가시키고 반환 — DB는 나중에
  await redis.incr(`views:${postId}`);
  // 별도 워커가 주기적으로 redis 카운터를 모아 DB에 flush
}

장점은 쓰기 지연이 매우 낮고, 같은 키에 대한 다수 쓰기를 하나로 합쳐(write coalescing) DB 부하를 크게 줄인다는 것이다. 조회수, 좋아요, 메트릭처럼 쓰기가 폭발적이고 약간의 손실/지연이 허용되는 데이터에 적합하다.

단점은 명확하다. 캐시가 죽으면 아직 flush되지 않은 쓰기가 유실된다. 또 DB가 일시적으로 stale하므로, DB를 직접 읽는 다른 시스템(분석, 백업)은 최신값을 못 본다. 내구성이 중요한 데이터(결제, 주문)엔 쓰지 않는다.

Refresh-ahead

만료를 기다리지 않고, 만료가 임박한 인기 키를 백그라운드에서 미리 갱신한다. 사용자 요청은 항상 신선한 캐시를 만나고, 갱신 비용은 백그라운드로 숨긴다.

async function getReport(key: string): Promise<Report> {
  const entry = await redis.get(`report:${key}`);
  if (entry === null) return loadAndCache(key); // 완전 미스
 
  const { value, expiresAt } = JSON.parse(entry) as Cached<Report>;
  const remaining = expiresAt - Date.now();
 
  // TTL의 마지막 20% 구간에 진입했고, 이미 요청이 들어왔다면 백그라운드 갱신
  if (remaining < REFRESH_WINDOW_MS) {
    void loadAndCache(key); // await 하지 않음 — 현재 요청은 캐시값 즉시 반환
  }
  return value;
}

장점은 핫키에 대해 미스 지연을 거의 없앤다는 것이다. 단점은 아무도 안 읽을 키까지 갱신하면 낭비가 되고, "임박" 판단 로직이 복잡해진다는 점이다. 그래서 보통 "최근에 접근된 인기 키"로 대상을 좁힌다. 뒤에 나올 확률적 조기 만료가 이 아이디어의 세련된 버전이다.

무효화 전략

캐시를 채우는 것보다 "언제 비울 것인가"가 훨씬 어렵다. 세 가지 축이 있다.

  • TTL (시간 기반): 일정 시간이 지나면 자동 만료. 가장 단순하고, 무효화를 잊어도 결국 자가 치유된다. 대신 TTL 동안은 stale을 감수한다. TTL을 짧게 잡으면 신선도↑·DB부하↑, 길게 잡으면 그 반대. "이 데이터가 얼마나 오래되어도 괜찮은가"라는 비즈니스 질문으로 TTL을 정한다.
  • 쓰기 시 삭제/갱신 (명시적 무효화): 데이터가 바뀌는 순간 해당 키를 지우거나(다음 읽기가 다시 채움) 새 값으로 덮어쓴다. 신선도가 가장 높지만, 데이터를 바꾸는 모든 경로를 빠짐없이 커버해야 한다. 우회 경로(배치 잡, 직접 SQL)가 있으면 누락된다.
  • 이벤트 기반: DB 변경 로그(CDC), 메시지 큐, pub/sub로 무효화 이벤트를 흘려보내 여러 서비스/노드의 로컬 캐시까지 일괄 무효화한다. 분산 환경에서 강력하지만 인프라 복잡도가 올라간다.

핵심 원칙: 갱신보다 삭제(invalidate, don't update). 쓰기 시 캐시에 "새 값"을 계산해 넣으려 하면 동시 쓰기 간 순서가 꼬여 오히려 stale을 굳힐 수 있다. 그냥 키를 지우고 다음 읽기가 권위 있는 소스(DB)에서 다시 채우게 하는 게 추론하기 쉽다.

일관성 문제: cache-aside의 read-miss/write 레이스

cache-aside에는 잘 알려진 race condition이 있다. 읽기의 "DB 로드"와 쓰기의 "캐시 삭제"가 교차하면 stale 값이 캐시에 영구히(TTL 만료 전까지) 박힌다.

타임라인은 다음과 같다.

  1. 요청 A: 캐시 미스 → DB에서 값 v1을 읽음 (아직 캐시에 쓰지 않음)
  2. 요청 B(쓰기): DB를 v2로 갱신 → 캐시 키 삭제 (지울 캐시가 아직 없으니 무효)
  3. 요청 A: 방금 읽은 낡은 값 v1을 캐시에 set

이제 캐시엔 v1이 들어 있고 DB엔 v2가 있다. TTL이 끝날 때까지 모두가 v1을 본다. A의 DB 읽기가 B의 갱신보다 먼저 시작됐지만, A의 캐시 쓰기가 B의 캐시 삭제보다 나중에 일어났기 때문이다.

완벽한 해결은 비싸지만, 실무에서 통하는 완화책은 다음과 같다.

  • 쓰기 시 삭제 + 짧은 TTL: 위 레이스가 발생해도 stale은 TTL만큼만 산다. TTL을 충분히 짧게 잡으면 노출 창이 좁아진다. 가장 흔하고 실용적인 절충이다.
  • Delayed double delete: 쓰기가 DB를 갱신하고 캐시를 지운 뒤, 짧은 지연 후 한 번 더 지운다. 레이스로 다시 박힌 stale을 두 번째 삭제가 청소한다.
  • 읽기 채우기를 원자적으로: SET ... NX나 버전/타임스탬프를 비교해, 더 낡은 값이 더 새 값을 덮어쓰지 못하게 한다.

요점은 "cache-aside는 강한 일관성을 보장하지 않는다"는 사실을 인정하고, 비즈니스가 허용하는 stale 창을 TTL로 정량화하는 것이다.

캐시 스탬피드 (thundering herd)

인기 있는 키 하나의 TTL이 만료되는 바로 그 순간, 그 키를 동시에 요청하던 수백~수천 개의 요청이 일제히 캐시 미스를 맞는다. 이들이 전부 같은 비싼 쿼리를 DB에 던지면 DB가 순간적으로 무너진다. 이게 캐시 스탬피드(cache stampede, dog-piling, thundering herd)다.

방어 기법은 네 가지가 자주 쓰인다.

(1) 뮤텍스/락 — 한 요청만 재계산

미스가 났을 때 분산 락을 잡은 한 요청만 DB를 읽어 캐시를 채우고, 나머지는 잠깐 기다렸다가 새로 채워진 캐시를 읽는다.

async function getWithLock(key: string): Promise<Data> {
  const cached = await redis.get(key);
  if (cached !== null) return JSON.parse(cached) as Data;
 
  const lockKey = `lock:${key}`;
  // NX + 짧은 만료로 락 획득 시도 (한 요청만 성공)
  const gotLock = await redis.set(lockKey, "1", "NX", "PX", 5000);
 
  if (gotLock === null) {
    // 락 못 잡음 → 다른 요청이 채우는 중. 잠깐 기다렸다 재시도.
    await sleep(50);
    return getWithLock(key);
  }
 
  try {
    const data = await loadFromDb(key); // 이 요청만 DB 접근
    await redis.set(key, JSON.stringify(data), "EX", 300);
    return data;
  } finally {
    await redis.del(lockKey);
  }
}

확실하지만 비용이 있다. 락 대기 요청들은 지연을 겪고, 락 보유자가 죽으면(그래서 만료 PX가 필수다) 짧은 정체가 생긴다. 재시도 폭주를 막기 위한 backoff도 필요하다.

(2) 확률적 조기 만료 (probabilistic early expiration)

모든 요청이 똑같은 만료 시각에 몰리는 게 문제의 근원이다. 그렇다면 각 요청이 만료 전부터 확률적으로, 시간이 갈수록 점점 높은 확률로 "내가 미리 갱신하겠다"고 결정하게 하면 갱신 시점이 자연스럽게 분산된다. XFetch로 알려진 기법이다.

// 캐시에 값과 함께 "재계산에 걸린 시간(delta)"과 만료 시각을 저장해 둔다.
function shouldRecompute(delta: number, expiresAt: number, beta = 1.0): boolean {
  const now = Date.now();
  // delta * beta * ln(rand) 만큼 만료를 앞당겨 본다.
  const earlyBy = delta * beta * -Math.log(Math.random());
  return now + earlyBy >= expiresAt;
}
 
async function getXFetch(key: string): Promise<Data> {
  const entry = await redis.get(key);
  if (entry !== null) {
    const { value, delta, expiresAt } = JSON.parse(entry) as Cached<Data>;
    if (!shouldRecompute(delta, expiresAt, 1.0)) return value;
    // 만료 전이지만 확률적으로 당첨 → 이 요청이 갱신을 맡는다
  }
 
  const start = Date.now();
  const value = await loadFromDb(key);
  const delta = Date.now() - start; // 재계산 비용을 기록
  await redis.set(
    key,
    JSON.stringify({ value, delta, expiresAt: Date.now() + 300_000 }),
    "EX",
    300,
  );
  return value;
}

재계산이 비쌀수록(delta가 클수록) 더 일찍 갱신을 시도하므로, 만료 직전의 절벽이 부드러운 경사로 바뀐다. 락 없이 동작하고 대기가 없다는 게 장점이다.

(3) Request coalescing / single-flight

같은 프로세스 안에서 동시에 같은 키를 요청하면, 실제 로드는 한 번만 수행하고 나머지 호출자는 그 하나의 in-flight Promise를 공유한다. 프로세스 단위 스탬피드를 락 없이 없앤다.

const inFlight = new Map<string, Promise<Data>>();
 
function singleFlight(key: string, loader: () => Promise<Data>): Promise<Data> {
  const existing = inFlight.get(key);
  if (existing !== undefined) return existing; // 진행 중인 로드에 합류
 
  const p = loader().finally(() => inFlight.delete(key));
  inFlight.set(key, p);
  return p;
}
 
async function getCoalesced(key: string): Promise<Data> {
  const cached = await redis.get(key);
  if (cached !== null) return JSON.parse(cached) as Data;
 
  // 같은 키에 대한 동시 미스는 단 한 번만 DB로 간다
  return singleFlight(key, async () => {
    const data = await loadFromDb(key);
    await redis.set(key, JSON.stringify(data), "EX", 300);
    return data;
  });
}

매우 가볍고 효과적이지만 범위가 단일 프로세스/노드라는 한계가 있다. 노드가 N개면 최대 N개의 동시 로드는 여전히 발생한다. 노드 수가 적당하면 이것만으로 충분하고, 더 줄이려면 (1) 분산 락과 결합한다.

(4) Stale-while-revalidate

만료된 값을 즉시 버리지 않는다. "약간 stale해도 응답으로는 내보내되, 백그라운드에서 새로 갱신"한다. 사용자는 절대 미스 지연을 겪지 않는다.

async function getSWR(key: string): Promise<Data> {
  const entry = await redis.get(key);
  if (entry === null) return loadAndCache(key); // 완전 미스만 동기 로드
 
  const { value, freshUntil } = JSON.parse(entry) as Swr<Data>;
  if (Date.now() > freshUntil) {
    // stale 구간: 일단 옛 값을 반환하고 백그라운드로 갱신 (single-flight와 결합 권장)
    void singleFlight(key, () => loadAndCache(key));
  }
  return value;
}

핵심은 "fresh TTL"과 "물리적 만료"를 분리하는 것이다. fresh 구간이 지나도 hard expire 전까지는 stale 값을 서빙하며 백그라운드 갱신을 트리거한다. HTTP Cache-Control: stale-while-revalidate와 같은 개념이다. 신선도를 약간 양보하고 지연·DB부하를 크게 얻는다. (3)과 결합하면 백그라운드 갱신도 한 번만 일어난다.

실무에선 이 기법들을 섞는다. 예를 들어 "SWR로 미스 지연을 없애고, 갱신은 single-flight로 한 번만, 그래도 부족하면 분산 락으로 노드 간 중복까지 제거"하는 식이다.

캐시 계층

캐시는 한 곳이 아니라 요청 경로 곳곳에 층층이 쌓인다. 위로 갈수록 빠르고 원본에서 멀며, 아래로 갈수록 권위 있고 느리다.

  • 브라우저 캐시: 클라이언트 로컬. HTTP Cache-Control/ETag로 제어한다. 가장 빠르지만(네트워크 0회) 무효화가 가장 어렵다. 한번 배포된 잘못된 캐시 헤더는 회수가 사실상 불가능하므로, 정적 자산은 내용 해시를 파일명에 박는 캐시 버스팅을 쓴다.
  • CDN / 엣지 캐시: 지리적으로 사용자에 가까운 곳에서 공용 응답을 캐시한다. 오리진 부하를 크게 줄인다. 퍼지(purge) API로 무효화하며, stale-while-revalidate를 엣지에서 적극 활용한다.
  • 애플리케이션 캐시 (Redis / 인메모리): 우리가 코드로 직접 다루는 층. 공유 캐시는 Redis/Memcached, 초저지연이 필요하면 프로세스 로컬 인메모리(LRU)를 둔다. 위에서 다룬 패턴·스탬피드 방어가 대부분 이 층의 이야기다. 로컬+원격을 겹치는 다층(near cache) 구성도 흔하다.
  • DB 버퍼/쿼리 캐시: DB 자체의 버퍼 풀이 자주 읽는 페이지를 메모리에 유지한다. 우리가 직접 제어하진 않지만, 인덱스·쿼리 설계로 적중률에 영향을 준다. 가장 권위 있는 데이터의 마지막 가속층이다.

층마다 무효화 난이도와 적중 효과가 다르다. 위층은 적중하면 가장 싸지만 무효화가 어렵고, 아래층은 무효화가 쉽지만 비용이 크다. 설계는 "되도록 위층에서 적중시키되, 위층일수록 짧은 TTL과 명확한 버스팅 전략을 둔다"가 기본이다.

무엇을, 어떻게 캐시할 것인가

캐시의 효용은 재사용성 × 비용으로 결정된다. 좋은 후보는 다음을 모두 만족한다.

  • 읽기가 쓰기보다 압도적으로 많다 (read:write 비율이 높다). 자주 바뀌는 데이터를 캐시하면 무효화 비용이 적중 이득을 잡아먹는다.
  • 계산/조회가 비싸다. 복잡한 조인·집계, 외부 API 결과, 렌더링된 HTML 조각처럼 만드는 데 비용이 큰 것.
  • 약간의 stale을 허용한다. 실시간 정확성이 생명인 잔액·재고 같은 값은 캐시 전략을 더 보수적으로(또는 write-through로) 가져가야 한다.

키 설계도 못지않게 중요하다.

  • 명확하고 충돌 없는 네임스페이스: user:42:profile처럼 콜론 구분 계층 키. 무엇을 담는지 키만 봐도 알아야 한다.
  • 버저닝으로 일괄 무효화: 키나 네임스페이스에 버전을 끼워 넣으면(v3:user:42), 스키마/로직이 바뀌어 전체를 무효화해야 할 때 버전 숫자 하나만 올려서 구 캐시를 한 번에 죽일 수 있다. 개별 키를 일일이 지울 필요가 없다.
const CACHE_VERSION = "v3"; // 응답 포맷이 바뀌면 이 숫자만 올린다 → 구 캐시 전부 자연 만료
 
function cacheKey(namespace: string, id: string): string {
  return `${CACHE_VERSION}:${namespace}:${id}`;
}
// 예) cacheKey("user", "42") → "v3:user:42"

버전 키 기법은 "삭제 없는 무효화"라는 점에서 특히 강력하다. 구 버전 키는 아무도 더 이상 참조하지 않으므로 TTL이 끝나면 알아서 사라진다.

정리

  • 기본기는 **cache-aside + TTL + 쓰기 시 무효화(삭제)**다. 단순하고 회복력 있으며, 대부분의 워크로드를 커버한다.
  • 무효화는 본질적으로 어렵다. "갱신보다 삭제", 그리고 "허용 가능한 stale 창을 TTL로 정량화"가 실무의 핵심 원칙이다. cache-aside의 read-miss/write 레이스를 인지하고 짧은 TTL로 노출을 제한하라.
  • 쓰기 일관성이 더 중요하면 write-through, 쓰기 폭주를 흡수해야 하고 약간의 유실이 허용되면 write-behind, 미스 지연 자체를 없애려면 read-through/refresh-ahead를 고려한다. 각각 일관성·지연·내구성을 다르게 거래한다.
  • 인기 키는 만료 순간 스탬피드를 부른다. single-flight로 프로세스 내 중복을 없애고, stale-while-revalidate로 미스 지연을 숨기고, 분산 락이나 확률적 조기 만료로 노드 간 중복까지 제거하는 조합이 견고하다.
  • 캐시는 층층이다. 위층에서 적중시키되 위층일수록 무효화가 어렵다는 걸 잊지 말고, 정적 자산은 콘텐츠 해시 버스팅, 동적 데이터는 버전 키로 일괄 무효화를 설계해 두라.

캐시는 "더 빠르게"가 아니라 "얼마나 오래된 데이터까지 허용할 것인가"라는 질문에서 시작해야 한다. 그 답이 TTL과 패턴 선택을 모두 결정한다.