Skip to content
Published on

캐시 무효화 완전정복

Authors

들어가며 — 왜 이것이 농담이 되었나

컴퓨터 과학에는 유명한 농담이 있습니다. 필 칼튼(Phil Karlton)이 남겼다고 전해지는 말입니다.

"컴퓨터 과학에는 어려운 것이 딱 두 가지 있다. 캐시 무효화, 그리고 이름 짓기."

이 농담이 오래도록 회자되는 이유는, 정말로 캐시 무효화가 어렵기 때문입니다. 캐시를 만드는 것 자체는 쉽습니다. 값을 어딘가에 저장해 두고 다음에 재사용하면 됩니다. 진짜 어려움은 그 저장된 값이 언제 낡았는지, 그래서 언제 버려야 하는지를 정확히 아는 데 있습니다. 원본 데이터는 바뀌었는데 캐시는 옛 값을 붙잡고 있으면, 사용자는 틀린 정보를 봅니다. 그렇다고 매번 원본을 확인하면 캐시를 둔 의미가 없습니다.

이 글은 이 어려움을 정면으로 다룹니다. 캐시가 무엇을 위한 것인지, 어떤 쓰기 전략들이 있는지, 캐시 스탬피드라는 함정과 그 완화법, 무효화를 실제로 어떻게 구현하는지, 그리고 CPU에서 브라우저까지 이어지는 캐시의 계층을 짚겠습니다.

캐시가 하는 일과 근본 문제

캐시의 목적은 단순합니다. 비싼 계산이나 느린 조회의 결과를 저장해 두었다가 재사용함으로써 시간을 아끼는 것입니다. 데이터베이스 쿼리, 원격 API 호출, 무거운 렌더링. 이런 것들의 결과를 가까운 곳에 보관하면 다음에는 순식간에 답할 수 있습니다.

캐시가 잘 작동하는 이유는 두 가지 성질 덕분입니다.

  • 시간 지역성(temporal locality): 방금 쓴 데이터는 곧 또 쓰일 가능성이 높다.
  • 공간 지역성(spatial locality): 어떤 데이터를 쓰면 그 근처 데이터도 곧 쓰일 가능성이 높다.

문제는 캐시가 원본의 복사본이라는 데서 나옵니다. 복사본은 원본이 바뀌는 순간 낡습니다. 이 낡음을 다루는 것이 캐시의 모든 어려움의 근원입니다. 여기서 두 가지 나쁜 상황을 구분해야 합니다.

  • 스테일(stale) 데이터: 캐시가 원본보다 오래된 값을 들고 있다. 사용자가 틀린 값을 본다.
  • 캐시 미스(miss): 캐시에 값이 없어 원본까지 다녀와야 한다. 느리다.

캐시 전략은 결국 이 둘 사이의 줄타기입니다. 스테일을 줄이려 자주 버리면 미스가 늘고, 미스를 줄이려 오래 보관하면 스테일이 늘어납니다. "정답"은 없고, 데이터의 성격에 맞는 균형점이 있을 뿐입니다.

TTL — 가장 단순한 무효화

가장 널리 쓰이는 무효화 방법은 **TTL(Time To Live)**입니다. 각 캐시 항목에 "이 값은 몇 초 동안만 유효하다"는 수명을 붙이는 것입니다. 그 시간이 지나면 항목은 만료되고, 다음 요청은 원본에서 새로 가져옵니다.

TTL의 매력은 단순함과 예측 가능성입니다. 무효화 로직을 따로 짤 필요가 없습니다. 그냥 시간이 지나면 알아서 버려집니다. 최악의 경우에도 스테일은 TTL 시간을 넘지 않습니다.

하지만 TTL은 본질적으로 스테일과 부하 사이의 트레이드오프입니다.

  • 긴 TTL: 캐시 적중률이 높아 빠르고 원본 부하가 적다. 하지만 원본이 바뀌어도 최대 TTL만큼 옛 값을 보여준다.
  • 짧은 TTL: 최신성이 좋다. 하지만 만료가 잦아 원본 조회가 늘고 느려진다.

그래서 TTL은 "잠깐 낡아도 괜찮은" 데이터에 잘 맞습니다. 뉴스 목록, 인기 게시물, 환율처럼 몇 초에서 몇 분의 지연이 허용되는 것들입니다. 반대로 "절대 낡으면 안 되는" 데이터(예: 계좌 잔액)에는 TTL만으로는 부족하고, 뒤에서 볼 명시적 무효화가 필요합니다.

쓰기 전략 — write-through, write-behind, cache-aside

캐시와 원본을 어떻게 함께 갱신하느냐에 따라 몇 가지 전형적인 패턴이 있습니다. 이것들을 구분해 두면 설계가 훨씬 명확해집니다.

Cache-aside (lazy loading, 지연 로딩). 가장 흔한 패턴입니다. 애플리케이션이 캐시를 직접 관리합니다.

  읽기:
    1. 캐시를 본다
    2. 있으면(hit) 그 값을 반환
    3. 없으면(miss) 원본에서 읽어와 캐시에 넣고 반환

  쓰기:
    1. 원본을 갱신
    2. 캐시의 해당 항목을 무효화(삭제)

핵심은 캐시가 "필요할 때만" 채워진다는 것입니다. 읽지 않는 데이터는 캐시에 들어가지 않습니다. 쓰기 시에는 보통 캐시를 갱신하지 않고 삭제만 합니다. 다음 읽기가 알아서 최신 값으로 다시 채우도록 두는 것입니다. 이 방식은 단순하고 견고하지만, 미스 때마다 원본을 다녀오는 지연이 있습니다.

Write-through (쓰기 통과). 쓰기가 캐시와 원본을 동시에 갱신합니다. 애플리케이션은 캐시에 쓰고, 캐시가 그 즉시 원본에도 씁니다.

  쓰기:
    애플리케이션 --> 캐시에 기록 --> (즉시) 원본에도 기록
    둘 다 성공해야 완료

장점은 캐시가 항상 최신이라는 것입니다. 읽기는 늘 캐시에서 바로 처리되어 빠릅니다. 단점은 쓰기가 느려진다는 것입니다. 매 쓰기가 원본 기록을 기다려야 하기 때문입니다. 또 한동안 읽히지 않을 데이터까지 캐시에 채워 공간을 낭비할 수 있습니다.

Write-behind (write-back, 쓰기 지연). 쓰기가 일단 캐시에만 기록되고, 원본 반영은 나중에 비동기로 이루어집니다.

  쓰기:
    애플리케이션 --> 캐시에 기록 (즉시 완료 응답)
                       |
                       v (나중에, 배치로)
                    원본에 반영

장점은 쓰기가 매우 빠르고, 여러 쓰기를 모아 한 번에 원본에 반영해 부하를 줄일 수 있다는 것입니다. 단점은 위험합니다. 캐시에는 썼지만 원본에는 아직 반영 안 된 그 사이에 캐시가 죽으면 데이터가 유실됩니다. 그래서 write-behind는 유실을 감당할 수 있거나, 별도의 내구성 장치를 갖춘 경우에만 씁니다.

세 전략을 한눈에 비교하면 이렇습니다.

전략쓰기 속도읽기 최신성유실 위험대표 상황
cache-aside보통미스 후 최신낮음범용, 읽기 위주
write-through느림항상 최신낮음최신성이 중요
write-behind매우 빠름항상 최신(캐시 기준)높음쓰기 폭주, 유실 허용

캐시 스탬피드 — 도그파일 문제

이제 캐시에서 가장 악명 높은 함정을 다룹니다. 캐시 스탬피드(cache stampede), 다른 이름으로 도그파일(dogpile) 또는 캐시 러시라 불리는 현상입니다.

상황은 이렇습니다. 인기 있는 캐시 항목 하나가 만료되는 순간을 상상해 봅시다. 그 항목은 초당 수천 번 읽히고 있었습니다. 만료된 바로 그 찰나, 수천 개의 요청이 동시에 캐시 미스를 겪습니다. 그리고 이들이 모두 동시에 원본(예: 데이터베이스)으로 몰려가 같은 값을 다시 계산하려 합니다. 하나면 충분했을 원본 조회가 수천 개로 폭증하고, 원본은 그 부하에 짓눌립니다. 최악의 경우 원본이 죽고, 그러면 더 많은 미스가 나고, 연쇄적으로 시스템 전체가 무너집니다.

  정상: 요청들이 캐시에서 처리됨
    요청 요청 요청 --> [캐시 hit] --> 빠른 응답

  스탬피드: 인기 키 만료 순간
    요청 요청 요청 요청 ... (수천 개)
         모두 miss
           |
           v (동시에 몰림)
        [원본 DB] <-- 수천 개의 동일 계산 요청에 압사

핵심은 "인기 있는 항목일수록 위험하다"는 역설입니다. 자주 읽히는 값일수록 만료 순간의 동시 미스가 크기 때문입니다. 이 문제를 완화하는 방법이 몇 가지 있습니다.

1. 잠금(lock) / 뮤텍스. 미스가 났을 때, 첫 번째 요청만 원본을 조회하도록 잠금을 겁니다. 나머지 요청은 그 하나가 값을 채울 때까지 잠깐 기다리거나, 잠시 옛 값을 받습니다. 이렇게 하면 원본 조회가 하나로 줄어듭니다. 단점은 대기가 생기고 구현이 다소 복잡하다는 것입니다.

2. TTL에 지터(jitter) 넣기. 많은 항목이 정확히 같은 순간에 만료되면 스탬피드가 커집니다. 그래서 TTL에 약간의 무작위 값을 더해 만료 시점을 흩뜨립니다. 예를 들어 TTL을 정확히 300초로 두는 대신 270~330초 사이로 무작위화하면, 만료가 시간축에 퍼져 동시 미스가 줄어듭니다.

import random

def ttl_with_jitter(base=300, spread=30):
    # 만료 시점을 흩뜨려 동시 만료를 방지
    return base + random.randint(-spread, spread)

3. Stale-while-revalidate (재검증 중 옛 값 제공). 이것이 특히 우아한 방법입니다. 항목이 만료되어도 그 옛 값을 즉시 버리지 않고, "일단 옛 값을 돌려주면서 뒤에서 조용히 새 값을 가져와 갱신"합니다. 사용자는 기다리지 않고 (약간 낡았을 수 있는) 값을 즉시 받고, 갱신은 백그라운드에서 한 번만 일어납니다. HTTP 캐시 제어의 stale-while-revalidate 지시자가 바로 이 개념을 표준화한 것입니다.

  항목 만료됨
      |
      v
  요청 도착 --> 옛 값을 즉시 반환 (사용자는 안 기다림)
      |
      +--> 백그라운드에서 새 값을 가져와 캐시 갱신 (한 번만)

4. 미리 갱신(early recomputation). 만료되기 직전에, 확률적으로 미리 값을 새로 계산해 두는 기법도 있습니다. 만료가 임박할수록 갱신 확률을 높여, 실제 만료 순간에 이미 새 값이 준비되어 있게 합니다. 이를 확률적 조기 만료(probabilistic early expiration)라고 부릅니다.

실무에서는 이들을 조합합니다. 예를 들어 지터로 만료를 흩뜨리고, stale-while-revalidate로 대기를 없애고, 잠금으로 갱신을 하나로 모으는 식입니다.

무효화 — 명시적으로 옛 값을 버리기

TTL은 "시간이 지나면 버린다"는 수동적 무효화입니다. 하지만 어떤 데이터는 원본이 바뀌는 즉시 캐시를 버려야 합니다. 이것이 **명시적 무효화(invalidation)**이고, 여기가 진짜로 어려운 부분입니다. 두 가지 대표 전략이 있습니다.

이벤트 기반 무효화(event-based). 원본 데이터가 바뀌면 이벤트를 발생시켜, 관련된 캐시 항목을 즉시 삭제(또는 갱신)합니다. 예를 들어 "상품 42의 가격이 변경됨"이라는 이벤트가 나면, 상품 42를 담은 모든 캐시 항목을 무효화합니다.

이 방식의 강점은 최신성입니다. 원본이 바뀌면 거의 즉시 캐시가 정리됩니다. 어려움은 의존성 추적입니다. "상품 42"는 상품 상세 캐시에도 있고, 카테고리 목록 캐시에도 있고, 검색 결과 캐시에도 있을 수 있습니다. 어느 캐시 항목들이 이 데이터에 의존하는지 정확히 알아야 빠짐없이 무효화할 수 있습니다. 이 의존성 그래프를 잘못 관리하면, 일부 캐시가 조용히 스테일 상태로 남아 버그가 됩니다.

버전 키 / 키 버저닝(versioned keys). 아주 실용적이고 견고한 기법입니다. 캐시 키 자체에 버전을 심어, 무효화를 "삭제" 대신 "새 키로 이동"으로 처리합니다.

  캐시 키에 버전을 포함:
    user:42:v7:profile

  사용자 42의 데이터가 바뀌면 --> 버전을 v8로 올림
    이제 코드는 user:42:v8:profile 을 조회
    -> v7 키는 아무도 찾지 않으므로 자연히 버려짐(TTL로 정리)

이 방식의 아름다움은 삭제를 직접 하지 않아도 된다는 것입니다. 버전 번호만 올리면, 옛 버전 키들은 아무도 조회하지 않게 되어 결국 TTL로 정리됩니다. 경쟁 조건(무효화와 재조회가 엇갈리는 문제)에도 강합니다. 옛 키와 새 키가 물리적으로 다르기 때문입니다. 흔한 응용은 어떤 엔터티나 전체 데이터셋에 "버전 카운터"를 두고, 그것을 캐시 키에 붙이는 것입니다.

두 전략은 배타적이지 않습니다. 세밀한 즉시 무효화가 필요한 곳엔 이벤트 기반을, 넓은 범위를 한 번에 갈아엎어야 하는 곳엔 버전 키를 쓰는 식으로 함께 씁니다.

무효화가 어려운 진짜 이유

여기서 왜 이 모든 것이 그토록 어려운지 한 번 정리하고 넘어갑시다. 캐시 무효화가 어려운 것은 단순히 코드가 복잡해서가 아니라, 몇 가지 근본적 긴장 때문입니다.

첫째, 분산의 문제입니다. 캐시는 대개 여러 서버, 여러 계층에 흩어져 있습니다. 한 곳에서 무효화해도 다른 캐시에는 아직 옛 값이 남아 있을 수 있습니다. 모든 캐시를 동시에, 원자적으로 무효화하는 것은 분산 시스템의 어려운 문제(합의, 순서)를 그대로 물려받습니다.

둘째, **경쟁 조건(race condition)**입니다. "캐시를 무효화하는 순간"과 "다른 요청이 옛 값을 다시 읽어 캐시에 넣는 순간"이 엇갈리면, 방금 지운 옛 값이 다시 캐시에 되살아납니다. 무효화 순서와 재조회 순서가 미묘하게 얽혀 이런 버그를 만듭니다.

셋째, 의존성의 복잡성입니다. 앞서 봤듯 하나의 원본 데이터가 여러 캐시 항목에 흩어져 있으면, 무엇을 무효화해야 하는지 완전히 파악하기 어렵습니다. 놓친 의존성 하나가 스테일 버그가 됩니다.

이 세 가지가 겹치기 때문에, "이름 짓기"와 나란히 농담의 소재가 될 만큼 어렵습니다. 완벽한 무효화는 종종 불가능하고, 그래서 실무는 "얼마나 오래 스테일을 견딜 수 있는가"를 정해 TTL과 명시적 무효화를 섞는 실용적 타협으로 갑니다.

캐시의 계층 — CPU에서 브라우저까지

캐시는 한 곳에만 있지 않습니다. 컴퓨팅 스택 전체가 캐시의 층으로 이루어져 있습니다. 하나의 데이터 요청이 브라우저에서 출발해 서버 깊숙이 들어가는 동안, 여러 캐시 계층을 지납니다. 이 전체 그림을 보는 것이 중요합니다.

  가장 가깝고 빠름 (작고 짧은 수명)
  ┌─────────────────────────────┐
  │ CPU 캐시 (L1/L2/L3)          │  나노초, 하드웨어가 관리
  ├─────────────────────────────┤
  │ OS 페이지 캐시 / 메모리       │  디스크 읽기를 메모리에 보관
  ├─────────────────────────────┤
  │ 애플리케이션 인메모리 캐시    │  프로세스 안의 로컬 캐시
  ├─────────────────────────────┤
  │ 분산 캐시 (Redis/Memcached)  │  여러 서버가 공유
  ├─────────────────────────────┤
  │ 데이터베이스 캐시            │  쿼리/버퍼 풀 캐시
  ├─────────────────────────────┤
  │ CDN 엣지 캐시                │  사용자 가까운 곳에 콘텐츠
  ├─────────────────────────────┤
  │ 브라우저 캐시                │  사용자 기기에 저장
  └─────────────────────────────┘
  가장 멀지만 사용자에겐 가장 가까움

각 계층의 성격을 짚으면 이렇습니다.

  • CPU 캐시(L1/L2/L3): 하드웨어가 자동으로 관리하는 가장 빠른 캐시입니다. 무효화도 하드웨어의 캐시 일관성 프로토콜이 처리합니다. 우리가 직접 손대지 않지만, 데이터 지역성을 고려한 코드가 왜 빠른지의 근거입니다.
  • OS 페이지 캐시: 운영체제가 디스크에서 읽은 블록을 메모리에 보관합니다. 그래서 같은 파일을 두 번째 읽을 때 훨씬 빠릅니다.
  • 애플리케이션 인메모리 캐시: 프로세스 내부의 로컬 캐시(예: 로컬 해시맵, LRU 캐시)입니다. 가장 빠르지만 서버마다 따로여서 일관성 유지가 어렵습니다.
  • 분산 캐시(Redis, Memcached): 여러 서버가 공유하는 캐시 계층입니다. 앞서 논한 전략들이 주로 여기서 벌어집니다.
  • 데이터베이스 캐시: DB 자체의 버퍼 풀과 쿼리 캐시입니다.
  • CDN 엣지 캐시: 정적 자원(그리고 점점 동적 콘텐츠까지)을 사용자 가까운 엣지에 보관합니다. 여기서의 무효화(퍼지, purge)는 전 세계 엣지에 전파되어야 해서 그 자체로 어려운 문제입니다.
  • 브라우저 캐시: 사용자 기기에 저장됩니다. Cache-Control, ETag 같은 HTTP 헤더로 제어합니다.

이 계층 구조가 캐시 무효화를 더 어렵게 만듭니다. 원본 하나를 바꿔도, 그 값의 복사본이 이 모든 층에 흩어져 있을 수 있습니다. "왜 아직도 옛날 데이터가 보이지?"라는 질문의 답은 대개 "어느 층의 캐시가 아직 갱신 안 됐다"입니다. 그래서 진단은 항상 "어느 층에서 스테일이 발생하는가"를 좁혀 들어가야 합니다.

HTTP 캐싱 — 웹의 표준 무효화

웹 계층의 캐시는 HTTP 헤더로 정교하게 제어됩니다. 이것은 브라우저 캐시와 CDN 캐시를 다스리는 표준 언어입니다.

  • Cache-Control: 캐시 동작의 핵심 지시자입니다. max-age(몇 초 캐시), no-cache(캐시하되 쓸 때 재검증), no-store(아예 캐시 금지), private/public(누가 캐시할 수 있는지) 등을 지정합니다.
  • ETag와 조건부 요청: 응답에 콘텐츠의 지문 같은 ETag를 붙입니다. 다음 요청 때 브라우저가 이 값을 If-None-Match로 보내면, 서버는 콘텐츠가 안 바뀌었을 때 본문 없이 304 Not Modified만 돌려줍니다. 전송량을 크게 아낍니다.
  • stale-while-revalidate: 앞서 본 그 개념입니다. 만료된 응답을 즉시 주면서 뒤에서 재검증하도록 허용합니다.

HTTP 캐싱의 강력함은 이 지시자들의 조합에 있습니다. 예를 들어 자주 안 바뀌는 정적 자산(JS, CSS)은 매우 긴 max-age와 콘텐츠 해시가 담긴 파일명을 조합합니다. 그러면 파일 내용이 바뀌면 파일명(즉 URL)이 바뀌므로, 사실상 앞서 본 버전 키 전략을 웹에 적용하는 셈입니다. 옛 URL은 아무도 요청하지 않게 되어 자연히 무효화됩니다.

실무 지침 정리

지금까지의 내용을 실무 관점에서 압축합니다.

먼저 이 데이터가 얼마나 스테일해도 되는지를 정하세요. 이것이 모든 결정의 출발점입니다. 몇 분 낡아도 되면 TTL로 충분합니다. 절대 낡으면 안 되면 명시적 무효화가 필요합니다. 이 질문에 답하지 않고 캐시를 설계하면 반드시 나중에 스테일 버그나 과부하로 되돌아옵니다.

다음으로 인기 있는 항목의 스탬피드에 대비하세요. 자주 읽히는 키일수록 만료 순간이 위험합니다. TTL에 지터를 넣고, stale-while-revalidate로 대기를 없애고, 필요하면 잠금으로 갱신을 하나로 모으세요.

무효화가 필요하다면 버전 키를 우선 고려하세요. 직접 삭제보다 견고하고 경쟁 조건에 강합니다. 세밀한 즉시 무효화가 꼭 필요한 곳에만 이벤트 기반을 더하세요.

그리고 캐시가 여러 층에 있다는 것을 잊지 마세요. 브라우저, CDN, 애플리케이션, 분산 캐시, DB. 무효화를 설계할 때 어느 층까지 전파되어야 하는지 명확히 하고, 진단할 때 어느 층에서 스테일이 나는지 좁혀 들어가세요.

마지막으로 관측 가능성을 갖추세요. 캐시 적중률, 미스율, 스테일 비율을 측정하지 않으면 캐시가 잘 돌고 있는지, 아니면 조용히 문제를 쌓고 있는지 알 수 없습니다.

마치며

캐시 무효화가 이름 짓기와 나란히 농담의 소재가 된 데는 이유가 있습니다. 캐시를 만드는 것은 쉽지만, 그 복사본이 언제 낡았는지를 정확히 아는 것은 분산, 경쟁 조건, 의존성이라는 세 가지 근본적 어려움을 동시에 건드리기 때문입니다. 완벽한 무효화는 종종 손에 잡히지 않습니다.

그래서 현실의 해법은 마법이 아니라 실용적 타협입니다. "이 데이터가 얼마나 스테일해도 되는가"를 정하고, 거기에 맞춰 TTL과 명시적 무효화를 섞습니다. 인기 항목에는 지터와 stale-while-revalidate로 스탬피드를 막고, 넓은 무효화에는 버전 키를, 세밀한 무효화에는 이벤트를 씁니다. 그리고 이 모든 것이 CPU에서 브라우저까지 이어지는 캐시의 층 위에서 벌어진다는 것을 늘 의식합니다.

캐시는 성능의 가장 강력한 지렛대이면서 가장 미묘한 버그의 원천입니다. 그 양면을 함께 이해할 때, 캐시는 골칫거리가 아니라 신뢰할 수 있는 도구가 됩니다.

참고 자료