DB

캐싱 설계 가이드: Cache-Aside, Write 전략, Cache Stampede 방어

Joonfluence 2026. 7. 11.

1. 문서 제목

캐싱 설계 가이드: Cache-Aside, Write 전략, Cache Stampede 방어


2. 기술 개요

캐시(Cache)는 자주 조회되지만 매번 다시 계산하거나 DB를 조회하기엔 비싼 데이터를, 더 빠른 저장소(메모리, Redis 등)에 사본으로 미리 준비해두는 것이다. 목적은 단순하다 — 응답 속도를 올리고 원본 저장소(DB)의 부하를 줄이는 것.

문제는 캐시를 쓰는 순간 "진짜 데이터(DB)"와 "사본(캐시)" 두 벌이 생긴다는 점이다. 원본이 바뀌었는데 사본이 안 바뀌면 사용자는 낡은 값을 보게 된다. 그래서 캐싱 설계는 사실상 아래 두 질문으로 귀결된다.

  1. 읽을 때, 캐시에 없으면 누가 채우는가? (읽기 전략)
  2. 원본이 바뀌었을 때, 캐시를 어떻게 처리하는가? (쓰기 전략)

"컴퓨터 과학에서 가장 어려운 문제는 캐시 무효화와 이름 짓기뿐이다"라는 유명한 농담이 있을 만큼, 두 번째 질문이 실무에서 훨씬 더 자주 발목을 잡는다.


3. 핵심 개념 정리

3.1 읽기 전략 (Read Strategy)

"캐시 미스가 났을 때 누가 원본을 읽어서 캐시를 채우는가"의 문제.

전략 동작 방식 장점 단점
Cache-Aside (Lazy Loading) 애플리케이션이 캐시를 먼저 확인 → 없으면 애플리케이션이 직접 DB를 읽고, 그 결과를 캐시에 넣음 실제로 조회된 데이터만 캐싱되어 공간 효율적, 캐시가 통째로 죽어도 DB로 서비스 계속 가능 캐시 미스 난 첫 요청은 느림(cold), 캐시 관리 코드를 애플리케이션이 직접 들고 있어야 함
Read-Through 캐시 레이어 자체가 미스를 감지하면 알아서 DB를 읽어 채움 — 애플리케이션은 캐시만 바라봄 애플리케이션 코드가 단순해짐(캐시 존재 자체를 몰라도 됨) 캐시 레이어가 DB 접근 방법을 알아야 해서 인프라 결합도가 높고, 보통 전용 캐시 프로바이더/라이브러리가 필요
[Cache-Aside]                     [Read-Through]

App → Cache (miss)                App → Cache (miss)
App → DB (직접 읽음)                        Cache → DB (캐시가 대신 읽음)
App → Cache (직접 채움)                     Cache → App (채운 뒤 반환)

실무에서는 Cache-Aside가 압도적으로 흔하다. Redis/Memcached + 일반 애플리케이션 코드 조합에서는 사실상 기본값이라고 봐도 된다.


3.2 쓰기 전략 (Write Strategy)

"원본 데이터가 바뀌었을 때 캐시를 어떻게 처리하는가"의 문제. 여기서 실무 선택지가 갈린다.

전략 동작 방식 장점 단점
Write-Through DB에 쓸 때 캐시에도 동시에(동기적으로) 씀 캐시가 항상 최신 상태 쓰기 지연 증가(두 곳에 다 써야 요청이 끝남), 한 번도 안 읽힐 데이터까지 캐싱하는 낭비 발생 가능
Write-Around DB에만 쓰고 캐시는 건드리지 않음. 다음 읽기 때 Cache-Aside로 자연스럽게 채워짐 쓰기 경로가 단순, 안 읽히는 데이터로 캐시가 오염되지 않음 쓴 직후 바로 읽으면 무조건 캐시 미스
Write-Behind (Write-Back) 캐시에 먼저 쓰고, DB 반영은 나중에 비동기 배치로 처리 쓰기 응답이 매우 빠름 캐시가 죽으면 아직 DB에 안 간 데이터가 통째로 유실될 위험, 구현 복잡도가 가장 높음
Invalidate-on-Write DB에 쓴 뒤, 캐시의 해당 키를 그냥 삭제(무효화)만 함. 새 값은 다음 읽기가 Cache-Aside로 다시 채움 쓰기 경로가 "계산 없이 지우기"라 가장 단순. 상태를 계산하는 로직이 읽기 경로 한 곳에만 존재해서 캐시-실제값 불일치(drift) 위험이 구조적으로 최소화됨 무효화 직후 첫 읽기는 캐시 미스 발생(읽기가 훨씬 잦은 도메인에서 유리)
[Write-Through]          [Write-Around]           [Write-Behind]           [Invalidate-on-Write]

App → DB                 App → DB만                App → Cache(먼저)         App → DB
App → Cache(동시에)                                  Cache → DB(나중에,비동기)  App → Cache 삭제
                                                                            (다음 읽기가 재적재)

왜 "새 값을 바로 캐싱"하지 않고 굳이 지우기만 할까? (Invalidate-on-Write가 좋은 이유)

새 값을 캐시에 바로 쓰려면, "지금 상태가 뭔지 계산하는 로직"이 쓰기 경로에도 있어야 한다. 그런데 읽기 경로에도 같은 계산 로직이 있으니, 결국 같은 계산이 코드 두 군데에 존재하게 된다. 둘 중 하나만 나중에 수정되거나 버그가 생기면 — 캐시에 저장된 값과 실제 정답이 서서히 어긋난다.

무효화만 하면 "정답을 계산하는 코드는 읽기 경로 단 한 곳"으로 유지된다. 쓰기 경로는 "지금 값이 낡았다"는 사실만 표시하고, 계산은 항상 같은 곳에서 같은 로직으로 이루어진다. 그래서 읽기가 쓰기보다 훨씬 잦은 도메인(회원 상태, 상품 정보 등)에서는 Cache-Aside(읽기) + Invalidate-on-Write(쓰기) 조합이 사실상 표준처럼 쓰인다.


3.3 캐시 미스가 몰릴 때 — Cache Stampede (Thundering Herd)

인기 있는 키의 캐시가 만료되는 바로 그 순간, 동시에 수백~수천 개의 요청이 한꺼번에 캐시 미스를 겪고 전부 DB로 몰려가는 현상이다. DB가 순간적으로 과부하에 빠질 수 있다.

방어법:

  • 분산락 + Double-Checked Locking: 캐시 미스가 나면 먼저 락을 획득한 요청 하나만 DB를 조회해서 캐시를 채우고, 락을 못 잡은 나머지 요청들은 잠깐 기다렸다가 이미 채워진 캐시를 그냥 읽는다. "락 획득 후 캐시를 한 번 더 확인"하는 이중 체크가 핵심 — 대기 중에 이미 다른 요청이 채워놨을 수 있기 때문.
  • 확률적 조기 갱신(Probabilistic Early Expiration): TTL 만료 시점이 다가오면, 아주 낮은 확률로 일부 요청만 미리 캐시를 갱신하게 만들어서 "정확히 그 순간 동시에 만료"되는 상황 자체를 분산시킨다.
  • Negative Caching: "이 데이터는 없다"는 결과도 짧은 TTL로 캐싱해서, 존재하지 않는 키를 반복 조회하는 요청이 매번 DB까지 가는 것을 막는다.
캐시 만료 순간

요청 1000개 동시 도착
   │
   ▼ (락 없이 그냥 캐시-어사이드만 쓰면)
DB로 1000개 쿼리 동시 발사 → DB 과부하

   │
   ▼ (분산락 + 이중 체크를 쓰면)
1개만 락 획득 → DB 조회 1번 → 캐시 채움
나머지 999개는 대기 후 캐시에서 읽음

3.4 TTL 설계

  • 실제 값 캐시: 상대적으로 긴 TTL(수 시간~하루 단위)로 둔다. 값이 바뀌면 Invalidate-on-Write로 즉시 지워지므로, TTL은 "무효화가 실패했을 때의 최후 안전망" 역할에 가깝다.
  • Negative(없음) 캐시: 짧은 TTL(초~분 단위)로 둔다. 방금 생성된 데이터가 "없음"이라는 낙인이 찍힌 채로 오래 남아있는 걸 방지하기 위함.

4. 실무 사례 예시

다음은 멤버십 V2 → V3 프로젝트의 실제 상태 캐싱을 기준으로 정리한 예시다.

개념 적용 사례
Cache-Aside 멤버십 상태 조회 시 Redis를 먼저 보고, 없으면 DB의 최신 트랜잭션으로 상태를 계산해 캐시에 채움
Invalidate-on-Write 상태를 바꾸는 모든 쓰기(해지/갱신/조정)는 DB 반영 후 캐시를 새 값으로 갱신하지 않고 그냥 삭제만 함 — 다음 읽기가 재적재
Double-Checked Locking 캐시 미스 시 분산락을 잡고, 락 안에서 캐시를 한 번 더 확인한 뒤에만 DB를 조회
Negative Caching 멤버십이 없는 고객은 "EMPTY"로 캐싱하되 TTL을 1분으로 짧게 둠(정상 값은 24시간)
캐시 스키마 검증 캐시된 JSON에 최신 필드가 실제로 존재하는지 확인 후, 없으면(구버전 캐시) 폐기하고 재계산

핵심 설계 포인트

이 사례에서 중요한 점은 쓰기 경로가 "계산"이 아니라 "삭제"만 한다는 것이다. 이렇게 하면 상태를 계산하는 로직이 읽기 경로 단 한 곳에만 존재하게 되어, 코드가 두 갈래로 진화하다가 캐시-실제값이 어긋나는 문제 자체가 생기지 않는다.


5. Best Practice

  • 읽기가 쓰기보다 훨씬 잦은 도메인이라면 Cache-Aside(읽기) + Invalidate-on-Write(쓰기) 조합을 기본값으로 고려한다.
  • Write-Through/Write-Behind는 "캐시가 항상 최신이어야 한다"는 강한 요구가 있을 때만 검토한다 — 구현 복잡도 대비 실제 이득을 따진다.
  • 캐시 미스가 몰릴 가능성이 있는 인기 키는 분산락 등 Cache Stampede 방어를 반드시 함께 설계한다.
  • "없음"이라는 결과도 캐싱하되, TTL은 짧게 잡아 최신 상태 반영이 늦어지지 않게 한다.
  • 캐시 스키마가 나중에 바뀔 수 있다는 걸 전제하고, 캐시된 값이 최신 스키마인지 검증하는 방어 로직을 넣는다.

6. 기술 면접 핵심 질문

Q. Cache-Aside와 Read-Through의 차이는 뭔가요?

둘 다 "캐시 미스가 나면 원본을 읽어서 채운다"는 점은 같지만, 누가 그 일을 하는지가 다릅니다. Cache-Aside는 애플리케이션 코드가 직접 캐시를 확인하고 DB를 읽고 캐시에 채워 넣습니다. Read-Through는 캐시 레이어 자체가 미스를 감지해서 알아서 DB를 읽어 채워주고, 애플리케이션은 캐시만 바라봅니다.


Q. Write-Through와 Invalidate-on-Write 중 어떤 걸 선택하겠나요?

읽기가 훨씬 잦고 쓰기 지연에 민감한 도메인이면 Invalidate-on-Write를 선호합니다. 상태를 계산하는 로직이 읽기 경로 한 곳에만 있게 되어 캐시와 실제 값이 어긋나는 위험이 구조적으로 줄어들기 때문입니다. 반대로 "캐시가 절대 미스 나면 안 되고 항상 최신이어야 한다"는 요구가 강하면 Write-Through를 검토합니다.


Q. Cache Stampede는 어떻게 막나요?

인기 키의 캐시가 만료되는 순간 동시에 수많은 요청이 DB로 몰리는 문제입니다. 분산락을 잡고, 락을 획득한 요청 하나만 DB를 조회해 캐시를 채우고, 나머지는 대기 후 캐시를 읽게 하는 double-checked locking으로 방어합니다. TTL을 확률적으로 조기에 분산시켜 갱신하는 방법도 함께 씁니다.


Q. 캐시 무효화가 왜 어려운 문제인가요?

캐시를 쓰는 순간 "진짜 데이터"와 "사본" 두 벌이 생기고, 언제 사본을 지우거나 갱신할지를 애플리케이션이 빠짐없이 챙겨야 하기 때문입니다. 쓰기 경로가 하나라도 무효화를 빠뜨리면 캐시가 낡은 값을 계속 돌려주는 문제가 조용히 발생합니다. 그래서 무효화 로직을 여러 곳에 흩어두지 않고, 상태를 바꾸는 모든 쓰기 경로가 공통된 한 지점(락 안에서 invalidate)을 반드시 거치도록 강제하는 설계가 중요합니다.


7. 참고 자료 / 공식 문서


핵심 요약

캐싱 설계는 읽기 전략(캐시 미스 시 누가 채우는가 — Cache-Aside가 사실상 표준)과 쓰기 전략(원본이 바뀌면 캐시를 어떻게 하는가 — Write-Through/Around/Behind/Invalidate 중 선택)을 분리해서 생각하면 명확해진다. 특히 읽기가 훨씬 잦은 도메인에서는 Cache-Aside + Invalidate-on-Write 조합이 구현이 가장 단순하고, 상태 계산 로직이 한 곳에만 존재해 캐시-실제값 불일치 위험도 가장 낮다. 여기에 Cache Stampede 방어(분산락+이중 체크)와 Negative Caching(짧은 TTL)을 더하면 실무에서 바로 쓸 수 있는 완성된 캐싱 설계가 된다.

반응형

댓글