Redis Look-aside Cache 설계와 Cache Stampede 대응

· 약 8분 읽기 · 조회 1 #Redis#캐시#성능#Spring Boot#아키텍처

Look-aside(Cache-aside) 캐시는 구현이 쉬워서 가장 많이 쓰이지만, 인기 있는 키 하나가 만료되는 순간 수백 개의 요청이 동시에 DB로 몰리는 Cache Stampede를 만든다. 평소에는 DB 부하를 크게 덜어 주던 캐시가 만료 한 번에 DB를 쓰러뜨리는 것이다. 이 글은 Look-aside 캐시의 기본 설계와 무효화 규칙, 그리고 Stampede를 막는 방법들을 상황별로 정리한 것이다.

진열대가 비자 손님들이 한꺼번에 오븐실로 몰려들고, 문 앞의 제빵사가 손을 들어 한 사람만 들여보내는 빵집

Look-aside의 기본 흐름

읽기

  1. 캐시에서 키를 찾는다.
  2. 있으면(hit) 그대로 돌려준다.
  3. 없으면(miss) DB에서 읽고, 캐시에 TTL과 함께 넣은 뒤 돌려준다.

쓰기

  1. DB를 먼저 갱신한다.
  2. 캐시는 새 값으로 덮어쓰지 말고 지운다. 다음 읽기가 최신 값으로 다시 채운다.

쓰기에서 캐시를 갱신하지 않고 지우는 이유는 경쟁 때문이다. 두 요청이 거의 동시에 값을 바꾸면 DB에는 B가 남았는데 캐시에는 늦게 도착한 A가 남을 수 있다. 지우면 이런 어긋남이 다음 읽기에서 저절로 사라진다.

public Product getProduct(long id) {
    String key = "product:" + id;
    Product cached = cache.get(key);
    if (cached != null) return cached;

    Product product = productRepository.findById(id).orElseThrow();
    cache.set(key, product, ttlWithJitter(Duration.ofMinutes(10)));
    return product;
}

@Transactional
public void updatePrice(long id, BigDecimal price) {
    productRepository.updatePrice(id, price);
    // 커밋 뒤에 지워야 지운 직후 옛 값이 다시 채워지는 일을 줄인다
    afterCommit(() -> cache.delete("product:" + id));
}

키와 TTL 설계

항목 기준
키 이름 도메인:식별자[:버전] 예: product:42, product:list:cat=3:page=1
값 크기 수십 KB 이하. 큰 목록은 페이지 단위로 쪼갠다
TTL 데이터가 바뀌는 주기와 허용되는 지연으로 정한다. 모든 키를 같은 값으로 두지 않는다
직렬화 JSON 등 버전에 덜 민감한 형식. 클래스가 바뀌어도 읽을 수 있게
장애 시 Redis 오류는 miss로 취급하고 DB로 간다. 캐시 장애가 서비스 장애가 되면 안 된다

Cache Stampede는 왜 생기나

인기 키 하나(예: 메인 화면 상품 목록)가 초당 1,000번 읽힌다고 하자. 이 키가 만료되는 순간부터 DB 조회가 끝나 캐시가 다시 채워질 때까지, 그 사이 들어온 요청은 모두 miss다. DB 조회가 200ms 걸리면 그동안 약 200개의 요청이 똑같은 무거운 쿼리를 DB에 동시에 보낸다. DB가 느려지면 조회 시간이 더 길어지고, miss도 더 쌓인다.

인기 키가 만료된 순간 많은 요청이 동시에 miss가 나지만, 락을 얻은 요청 하나만 DB에서 다시 읽어 캐시를 채우고 나머지는 잠깐 기다렸다가 캐시에서 읽는 순서

비슷한 이름의 다른 문제와 구분해 둔다.

문제 원인 대응
Stampede(Dog-piling) 인기 키 하나의 만료 락, 미리 갱신, 논리 만료
Avalanche 많은 키가 동시에 만료, 또는 Redis 장애 TTL 지터, 로컬 캐시, 서킷 브레이커
Penetration 없는 키를 반복 조회(항상 miss) 빈 결과도 짧게 캐시, 블룸 필터

대응 1: 락으로 한 요청만 다시 채운다

miss가 나면 Redis에 짧은 락을 잡은 요청 하나만 DB를 읽고, 나머지는 잠깐 기다렸다가 캐시를 다시 본다.

Product loadWithLock(String key, long id) {
    String lockKey = key + ":lock";
    String token = UUID.randomUUID().toString();
    Boolean locked = redis.opsForValue().setIfAbsent(lockKey, token, Duration.ofSeconds(3));
    if (Boolean.TRUE.equals(locked)) {
        try {
            Product product = productRepository.findById(id).orElseThrow();
            cache.set(key, product, ttlWithJitter(Duration.ofMinutes(10)));
            return product;
        } finally {
            releaseIfOwner(lockKey, token);   // Lua로 "내 토큰일 때만 삭제"
        }
    }
    for (int i = 0; i < 10; i++) {            // 최대 약 500ms 대기
        sleep(50);
        Product cached = cache.get(key);
        if (cached != null) return cached;
    }
    return productRepository.findById(id).orElseThrow();   // 마지막 수단
}
  • 락에는 반드시 만료 시간을 준다. 락을 잡은 인스턴스가 죽어도 풀린다.
  • 해제는 "내가 잡은 락일 때만" 지운다. 만료 뒤 다른 요청이 잡은 락을 지우는 사고를 막는다.
  • 기다리는 쪽에는 상한을 둔다. 무한 대기는 스레드 고갈로 번진다.

대응 2: 만료 전에 미리 갱신한다

논리 만료(stale-while-revalidate): 값과 함께 "논리 만료 시각"을 저장하고, Redis TTL은 그보다 길게 준다. 논리 만료가 지났으면 옛 값을 바로 돌려주고, 락을 잡은 요청 하나가 백그라운드에서 새 값을 채운다. 사용자는 기다리지 않고, DB에는 한 번만 간다. 몇 초간 옛 값을 보여 줘도 되는 데이터에 맞다.

확률적 조기 갱신: 만료가 가까워질수록 요청마다 일정 확률로 미리 다시 계산하게 한다. 조회 비용이 클수록, 만료가 가까울수록 확률을 높인다(Vattani 등의 논문 "Optimal Probabilistic Cache Stampede Prevention"에서 제안한 XFetch 방식). 락 없이도 만료 직전에 누군가 한 번 갱신해 둘 가능성이 높아진다.

예열: 매일 아침 열리는 메인 화면처럼 인기 키가 미리 정해져 있으면 배치나 스케줄러로 만료 전에 갱신한다.

대응 3: 만료 시각을 흩뜨린다

같은 시각에 대량으로 채운 키들이 같은 TTL을 가지면 같은 시각에 함께 만료된다(Avalanche). TTL에 무작위 지터를 더한다.

Duration ttlWithJitter(Duration base) {
    long jitterSec = ThreadLocalRandom.current().nextLong(0, base.toSeconds() / 10 + 1);
    return base.plusSeconds(jitterSec);   // 기본 TTL의 최대 10%를 랜덤으로 더함
}

대응 4: 한 인스턴스 안에서 요청을 합친다

같은 서버 안에서 같은 키를 동시에 읽는 요청은 하나의 DB 조회 결과를 공유하게 할 수 있다(single-flight, request coalescing). Caffeine 같은 로컬 캐시의 비동기 로딩을 앞단에 두면, 인스턴스마다 DB 조회가 최대 한 번으로 줄어든다. Redis 락과 함께 쓰면 전체 DB 조회가 더 줄어든다.

어떤 대응을 고를까

상황 고를 것
옛 값을 몇 초 보여 줘도 된다 논리 만료 + 백그라운드 갱신
반드시 최신 값이어야 하고 조회가 무겁다 락 + 짧은 대기
인기 키가 미리 정해져 있다 예열 + TTL 지터
키가 아주 많고 대량으로 채워진다 TTL 지터
인스턴스 수가 많다 로컬 캐시 요청 합치기 + Redis 락

검증하기

캐시 설계는 부하 시험으로 확인한다. 인기 키를 일부러 지운 순간의 DB 쿼리 수와 응답 시간 95퍼센타일을 재고, 대응 전후를 비교한다. 운영에서는 캐시 hit 비율, 키별 miss 급증, Redis 지연을 모니터링한다.

자주 하는 실수

  • 쓰기에서 캐시를 갱신한다. 동시 쓰기에서 캐시와 DB가 어긋난다. 지운다.
  • 모든 키의 TTL이 같다. 대량 만료가 한 시각에 몰린다.
  • 락에 만료가 없다. 락을 잡은 인스턴스가 죽으면 그 키는 영원히 다시 채워지지 않는다.
  • Redis 장애를 예외로 올린다. 캐시가 죽으면 서비스도 죽는다. miss로 처리하고 DB로 간다.
  • 없는 키를 캐시하지 않는다. 존재하지 않는 ID 조회가 모두 DB로 간다.

점검표

  • 읽기는 Look-aside, 쓰기는 DB 갱신 뒤 캐시 삭제(커밋 뒤)다
  • 키 이름 규칙과 값 크기 기준이 있다
  • TTL은 데이터 성격별로 다르고 지터가 들어간다
  • 인기 키에는 락, 논리 만료, 예열 중 하나 이상이 있다
  • 락은 만료 시간과 소유자 확인 해제를 쓴다
  • 대기에는 상한이 있다
  • Redis 오류는 miss로 처리한다
  • 빈 결과도 짧은 TTL로 캐시한다
  • 인기 키 삭제 시나리오로 부하 시험을 했다

Look-aside 캐시는 평소가 아니라 만료되는 순간을 기준으로 설계해야 한다. 한 키를 다시 채우는 일은 한 요청에게만 맡기고, 만료 시각은 흩뜨리고, 캐시가 죽어도 서비스는 살아 있게 만들면 캐시는 DB의 방패로 남는다.

댓글 0개

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

댓글 쓰기

← 목록으로 돌아가기