Cache Failure Patterns — 장애 패턴 시각 비교

같은 시스템에 공격 모드 / 방어 모드를 토글해서 직접 비교

① Cache Penetration 🕳

존재하지 않는 데이터를 계속 조회 → 캐시 항상 miss → 매번 DB 직행. 악의적 공격(없는 ID 대량 요청)에 취약.

악의적 클라이언트들
존재하지 않는 ID 999999, 999998...
초당 1만 req
Cache
❌ 항상 miss
전부 통과
DB
🔥 부하 폭증
DB 부하98%
# 방어 모드 코드 — null도 짧은 TTL로 캐싱 + Bloom Filter
fun getProduct(id: Long): Product? {
    if (!bloomFilter.mightContain(id)) return null   // ① 확실히 없으면 즉시 거부

    cache.get(id)?.let { return if (it == NULL_MARKER) null else it }  // ② null 마커도 캐시

    val p = db.findById(id)
    cache.put(id, p ?: NULL_MARKER, ttl = if (p == null) 30.seconds else 5.minutes)  // ③ "없음"도 30초 캐시
    return p
}
💡 핵심 — Bloom Filter는 false negative(놓치는 것) 0이라 "있는 데이터를 막을" 일은 없고, "없는 데이터를 막을" 일만 한다. 그래서 캐시 관통 방어에 딱이다.

② Cache Avalanche 🏔

대량의 키가 동시에 만료되거나 캐시 서버 통째로 다운 → 트래픽이 한꺼번에 DB로 → DB 과부하/장애.

16개 캐시 키의 TTL 카운트다운 — 공격 모드

⚠️ 모든 키가 동시에 빨강(만료)이 되는 순간 = DB 폭격

DB 부하 (만료 순간)스파이크 발생
// 방어 모드 — TTL ± 랜덤 지터 + 다층 캐시
fun ttlWithJitter(base: Duration = 5.minutes, jitter: Duration = 1.minutes): Duration =
    base + Duration.ofSeconds(Random.nextLong(-jitter.seconds, jitter.seconds))

cache.put(key, value, ttlWithJitter())   // 300초 ± 60초

// 추가 방어: L1(로컬) + L2(Redis) 다층 → 한 층 죽어도 버티기
💡 핵심 — "TTL 같으면 같이 죽는다." 키마다 ±10~20% 랜덤 지터만 추가해도 만료가 시간축에 분산되어 DB 부하가 평탄해짐. 단순한 한 줄로 가장 큰 위험 하나가 사라진다.

③ Cache Stampede 🐂

인기 단일 키가 만료되는 순간 → 수많은 요청이 동시 miss → 전부 같은 무거운 DB 쿼리를 동시에 던짐 (Thundering Herd).

🔥 hot key: ranking:popular
TTL 만료가 가까워질수록 빨강. 0초 도달 시 새로 계산해야 함 (무거운 DB 쿼리)

⚠️ 만료 순간, 10개 요청이 동시에 miss → 모두 같은 쿼리 실행

A
B
C
D
E
F
G
H
I
J
10× 동시 DB 쿼리
DB
💥 같은 쿼리 10번 동시 실행
// 방어 모드 — Redis SET NX 분산 락
fun getPopularRanking(): Ranking {
    redisTemplate.opsForValue().get(KEY)?.let { return it }   // ① 캐시 조회

    val locked = redisTemplate.opsForValue()
        .setIfAbsent(LOCK_KEY, "1", Duration.ofSeconds(5)) == true   // ② 한 명만 락 획득

    return if (locked) {
        try {
            val ranking = rankingRepo.calculateExpensiveRanking()    // ③ 한 번만 무거운 쿼리
            redisTemplate.opsForValue().set(KEY, ranking, ttlWithJitter())
            ranking
        } finally { redisTemplate.delete(LOCK_KEY) }
    } else {
        Thread.sleep(50)                                              // ④ 락 못 잡은 요청은 잠깐 대기
        redisTemplate.opsForValue().get(KEY) ?: rankingRepo.calculateExpensiveRanking()
    }
}
💡 핵심 — 핵심은 "동시 요청을 한 명으로 줄이는" 것. Refresh-Ahead(만료 전 미리 갱신)나 확률적 조기 만료도 같은 목적. 스탬피드 = 캐시 만료 순간을 통과하는 트래픽을 묽게 만들기.

④ Consistency Race 🏁

"DB 업데이트 → 캐시 삭제" 순서여도 동시성 때문에 stale 값이 다시 캐싱될 수 있다. 4 패턴 중 가장 미묘하고 디버깅 어려움.

🔵 A (Reader)
🟣 B (Writer)
💡 진행 — 다음 버튼으로 race condition이 어떻게 발생하는지 따라가보세요.
// 방어 모드 — 지연 이중 삭제 (Delayed Double Delete)
@Transactional
fun update(id: Long, req: UpdateReq) {
    db.update(id, req)
    cache.delete(id)                                          // ① 즉시 삭제

    asyncExecutor.schedule({ cache.delete(id) }, 500.ms)      // ② 짧은 지연 후 한 번 더
}

// 추가 방어: CDC(Debezium) → 메시지 → 모든 인스턴스 캐시 무효화
//          (다중 인스턴스 로컬 캐시 동기화에 특히 유용)
💡 핵심 — race가 일어나는 창이 짧은 만큼, "삭제를 두 번 한다"는 단순한 대처가 효과적. 업데이트보다 삭제가 안전한 이유도 같음 — 다음 읽기에서 자연스럽게 채워지므로 race 창이 더 짧다.

📋 4개 패턴 요약

패턴증상핵심 방어한 줄
🕳 Penetration없는 데이터가 매번 DB 직행null 캐싱 + Bloom Filter"없음"도 캐시한다
🏔 Avalanche동시 만료 → DB 폭격TTL 지터 + 다층 캐시같이 죽지 않게 만료를 분산
🐂 Stampede핫키 만료 시 N개 동시 재계산분산 락 / Refresh-Ahead한 명만 보내고 나머지는 대기
🏁 Racestale 값이 다시 캐시에 박힘지연 이중 삭제 / CDC 무효화업데이트 X, 삭제 O — 한 번 더