Java 아키텍처·Spring 용어 사전
캐시Cache-Aside · @Cacheable · 쓰기 전략

캐시 어사이드

캐시를 먼저 보고 없으면 DB에서 읽어 채우는 패턴. 가장 흔하며 Spring의 @Cacheable이 이것이다.

캐시를 먼저 확인하고, 없으면 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 을 재검토할 신호다

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — 캐시·Redis — 어사이드·쓰기 전략