같은 시스템에 공격 모드 / 방어 모드를 토글해서 직접 비교
존재하지 않는 데이터를 계속 조회 → 캐시 항상 miss → 매번 DB 직행. 악의적 공격(없는 ID 대량 요청)에 취약.
# 방어 모드 코드 — 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
}
대량의 키가 동시에 만료되거나 캐시 서버 통째로 다운 → 트래픽이 한꺼번에 DB로 → DB 과부하/장애.
16개 캐시 키의 TTL 카운트다운 — 공격 모드
⚠️ 모든 키가 동시에 빨강(만료)이 되는 순간 = 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) 다층 → 한 층 죽어도 버티기
인기 단일 키가 만료되는 순간 → 수많은 요청이 동시 miss → 전부 같은 무거운 DB 쿼리를 동시에 던짐 (Thundering Herd).
ranking:popular⚠️ 만료 순간, 10개 요청이 동시에 miss → 모두 같은 쿼리 실행
// 방어 모드 — 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()
}
}
"DB 업데이트 → 캐시 삭제" 순서여도 동시성 때문에 stale 값이 다시 캐싱될 수 있다. 4 패턴 중 가장 미묘하고 디버깅 어려움.
// 방어 모드 — 지연 이중 삭제 (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) → 메시지 → 모든 인스턴스 캐시 무효화
// (다중 인스턴스 로컬 캐시 동기화에 특히 유용)
| 패턴 | 증상 | 핵심 방어 | 한 줄 |
|---|---|---|---|
| 🕳 Penetration | 없는 데이터가 매번 DB 직행 | null 캐싱 + Bloom Filter | "없음"도 캐시한다 |
| 🏔 Avalanche | 동시 만료 → DB 폭격 | TTL 지터 + 다층 캐시 | 같이 죽지 않게 만료를 분산 |
| 🐂 Stampede | 핫키 만료 시 N개 동시 재계산 | 분산 락 / Refresh-Ahead | 한 명만 보내고 나머지는 대기 |
| 🏁 Race | stale 값이 다시 캐시에 박힘 | 지연 이중 삭제 / CDC 무효화 | 업데이트 X, 삭제 O — 한 번 더 |