캐시를 먼저 확인하고, 없으면 DB에서 읽어 캐시에 채우는 패턴(look-aside). 가장 널리 쓰인다.
흐름
읽기
- ① 캐시 조회
- ② 있으면(hit) 그대로 반환
- ③ 없으면(miss) DB 조회 → 캐시에 저장 → 반환
쓰기
- DB 갱신 후 캐시를 '무효화(evict)' 한다
@Cacheable(value = "member", key = "#id")
Member find(Long id) { return repo.findById(id).orElseThrow(); }
@CacheEvict(value = "member", key = "#member.id")
void update(Member member) { repo.save(member); }
왜 갱신이 아니라 무효화인가
@CachePut 으로 갱신하면
-
동시에 두 요청이 갱신할 때 순서가 뒤집혀 낡은 값이 남을 수 있다 무효화하면
-
다음 조회가 DB 에서 최신을 읽어 채운다. 경합 창이 훨씬 작다
쓰기 전략의 갈래
- Write-Around — DB 만 쓰고 캐시는 무효화 (기본. 위 방식)
- Write-Through — 캐시와 DB 를 함께 쓴다 → 일관성 좋음, 쓰기 느림
- Write-Back — 캐시에만 쓰고 나중에 DB 로 → 매우 빠름, 유실 위험
DB를 죽이는 세 가지
-
① 캐시 스탬피드 (Thundering Herd)
- 인기 키가 만료되는 순간 수천 요청이 동시에 DB 로 몰린다
- TTL 에 지터를 준다 · 논리적 만료 후 한 스레드만 갱신 · 분산 락
-
② 캐시 페네트레이션 (Penetration)
- 존재하지 않는 키를 계속 조회한다 → 캐시가 늘 miss → DB 직행
- '없음' 자체를 짧은 TTL 로 캐싱한다 · 블룸 필터
-
③ 캐시 어밸런치 (Avalanche)
- 많은 키가 동시에 만료된다 (같은 시각에 일괄 적재한 경우)
- TTL 을 무작위로 흩뿌린다
// 스탬피드 방어 — sync 로 같은 키에 대해 한 스레드만 로딩하게 한다
@Cacheable(value = "member", key = "#id", sync = true)
Member find(Long id) { ... }
sync = true는 로컬 캐시에서만 유효하다는 점을 알아야 한다. 분산 환경에서는 인스턴스마다 한 스레드씩 들어가므로 분산 락이 필요하다.
@Cacheable도 프록시다
// 자기 호출이면 캐시가 동작하지 않는다 — @Transactional 과 같은 함정
public Member outer(Long id) { return this.find(id); } // 캐시 안 탐
면접 함정
- ❌ "캐시를 쓰면 무조건 빨라진다" → 적중률이 낮으면 DB 부하에 캐시 왕복만 더한다.
- ❌ "TTL을 길게 하면 좋다" → 낡은 데이터 노출과 어밸런치 위험이 커진다.
스탬피드를 분산 락으로 막기
// 인스턴스가 여러 대면 sync=true 로는 부족하다 — 각 인스턴스마다 한 스레드씩 들어간다
public Member find(Long id) {
Member cached = cache.get(id);
if (cached != null) return cached;
String lockKey = "lock:member:" + id;
if (redis.setIfAbsent(lockKey, "1", Duration.ofSeconds(3))) { // 락 획득
try {
Member m = repo.findById(id).orElseThrow();
cache.put(id, m);
return m;
} finally { redis.delete(lockKey); }
}
Thread.sleep(50); // 남이 채우는 중 → 잠시 후 캐시를 다시 본다
return find(id);
}
TTL에 지터를 준다
// 같은 시각에 일괄 적재하면 같은 시각에 일괄 만료된다 (어밸런치)
long ttl = 600 + ThreadLocalRandom.current().nextLong(0, 120); // 10분 ± 2분
redis.opsForValue().set(key, value, Duration.ofSeconds(ttl));
캐시 적중률을 본다
cache.gets{result=hit} / cache.gets{result=miss} (Micrometer)
적중률이 낮으면 캐시가 오히려 손해다 — 캐시 왕복 + DB 왕복을 둘 다 한다 키 설계나 TTL 을 재검토할 신호다