Redis 캐시 전략: Cache-Aside와 스탬피드 방어

“조회가 느리니 @Cacheable 하나 붙이면 되지 않나요?” 캐시를 처음 도입할 때 흔히 하는 생각입니다. 하지만 애너테이션 하나로 끝나는 건 캐시의 절반일 뿐입니다. 데이터가 바뀌면 캐시를 어떻게 무효화할지, 언제 만료시킬지, 인기 데이터가 동시에 만료되면 어떻게 대응할지까지 설계해야 비로소 캐시가 안전하게 동작합니다. 그렇지 않으면 오래된 데이터를 서빙하거나, 캐시가 메모리를 잠식하거나, 심지어 캐시 때문에 DB가 폭주하는 일이 벌어집니다.

이 글에서는 가장 널리 쓰이는 Cache-Aside 패턴부터, 캐시 무효화와 TTL 설계, 그리고 운영에서 진짜 무서운 캐시 스탬피드와 그 방어법까지 Redis 캐시 전략을 원리 중심으로 정리합니다.


가장 흔한 패턴: Cache-Aside

캐시를 읽고 쓰는 방식에는 여러 전략이 있지만, 실무에서 가장 널리 쓰이는 것은 Cache-Aside(Look-Aside)입니다. 애플리케이션이 캐시를 직접 관리하는 방식입니다.

동작은 단순합니다. 데이터를 조회할 때 먼저 캐시를 봅니다. 값이 있으면(캐시 히트) 그대로 반환하고, 없으면(캐시 미스) DB에서 읽어와 캐시에 저장한 뒤 반환합니다. 다음 요청부터는 캐시에서 바로 응답하므로 DB 부하가 줄고 응답이 빨라집니다.

public Product getProduct(Long id) {
    String key = "product:" + id;
    Product cached = redis.get(key);
    if (cached != null) return cached;        // 캐시 히트

    Product product = productRepository.findById(id); // 미스 → DB 조회
    redis.set(key, product, Duration.ofMinutes(10));  // 캐시에 적재 (TTL 포함)
    return product;
}

다른 전략과 비교하면 각자의 위치가 분명해집니다.

전략동작특징
Cache-Aside앱이 미스 시 DB 조회 후 캐시 적재가장 일반적, 앱이 캐시 관리
Read-Through캐시 계층이 미스 시 스스로 DB 조회앱 코드 단순, 캐시가 처리
Write-Through쓰기를 캐시와 DB에 동시에일관성 높음, 쓰기 지연 증가
Write-Behind캐시에 먼저 쓰고 DB는 비동기로쓰기 빠름, 유실 위험 존재

대부분의 서비스는 Cache-Aside로 충분합니다. 다만 이 방식은 캐시 무효화를 애플리케이션이 책임진다는 뜻이기도 합니다.


캐시 무효화: 가장 어려운 문제

캐시의 가장 어려운 부분은 “언제 캐시를 최신으로 만드느냐”입니다. 원본 데이터가 바뀌었는데 캐시가 옛 값을 들고 있으면, 사용자는 낡은 정보를 보게 됩니다.

Cache-Aside에서 흔한 접근은 쓰기 시 캐시를 갱신하지 않고 삭제(evict)하는 것입니다. 데이터를 수정할 때 해당 캐시 키를 지워두면, 다음 조회에서 미스가 나며 최신 값으로 다시 적재됩니다. 갱신 대신 삭제를 택하는 이유는, 쓰기 시점에 캐시를 직접 갱신하려다 동시성 문제로 오히려 잘못된 값이 남는 경우를 피하기 위해서입니다.

public void updateProduct(Product product) {
    productRepository.save(product);
    redis.delete("product:" + product.getId()); // 캐시 삭제 → 다음 조회에 재적재
}

그럼에도 무효화는 완벽하기 어렵습니다. 그래서 TTL을 안전망으로 함께 둡니다. 설령 무효화를 놓치더라도, TTL이 지나면 캐시가 스스로 비워져 최신 값으로 갱신되기 때문입니다.


TTL과 만료: 캐시는 언젠가 비워야 한다

모든 캐시 항목에는 만료 시간(TTL)을 두는 것이 원칙입니다. TTL 없는 캐시는 시간이 지날수록 항목이 쌓여 메모리를 잠식하고, 결국 캐시 서버의 메모리를 고갈시킵니다. 만료 없는 컬렉션이 힙을 채워 서비스를 죽이는 OOM으로 죽은 서비스, 힙 덤프에서 범인 찾기와 같은 문제가 캐시에서도 그대로 일어납니다.

TTL은 데이터 성격에 맞춰 정합니다. 자주 바뀌는 데이터는 짧게, 거의 고정된 데이터는 길게 둡니다. 핵심은 “이 데이터가 최대 얼마나 낡아도 괜찮은가”를 기준으로 삼는 것입니다. 다만 TTL을 잘 잡았더라도, 인기 있는 키들이 같은 시각에 한꺼번에 만료되면 다음에 설명할 위험한 상황이 벌어집니다.


캐시 스탬피드: 동시 만료가 부르는 DB 폭주

평소엔 캐시가 DB를 막지만, 인기 키가 동시 만료되면 요청이 DB로 몰린다.

캐시 스탬피드(cache stampede)는 운영에서 가장 무서운 캐시 문제입니다. 많은 요청이 몰리는 인기 키가 만료되는 바로 그 순간, 수많은 요청이 동시에 캐시 미스를 맞고 전부 DB로 몰려가는 현상입니다. 평소 캐시가 막아주던 부하가 순간적으로 DB에 그대로 쏟아지면서, DB가 감당하지 못하고 느려지거나 멈춥니다. 갑작스러운 조회 폭주가 DB를 무너뜨리는 양상은 MySQL 쿼리 지연 해결: 3개월 클라우드 DB 분투기에서 다룬 상황과 닮아 있습니다.

방어법은 크게 세 가지입니다.

첫째, 락으로 재적재를 한 번만 하게 합니다. 미스가 났을 때 분산락을 걸어, 단 하나의 요청만 DB에서 값을 읽어 캐시에 채우고 나머지 요청은 잠시 기다렸다 캐시에서 읽게 합니다. 이 방식은 Redis 분산락 구현하기의 접근을 그대로 활용할 수 있습니다.

둘째, TTL에 무작위 지터(jitter)를 더합니다. 만료 시간을 정확히 같은 값으로 두지 않고 약간의 무작위 편차를 주면, 키들이 한꺼번에 만료되지 않고 시간에 분산돼 스탬피드 자체가 완화됩니다.

셋째, 만료 임박 시 미리 갱신합니다. TTL이 끝나기 전에 백그라운드로 값을 새로 채우거나(refresh-ahead), 만료된 값을 잠시 그대로 반환하면서 뒤에서 갱신하는(stale-while-revalidate) 방식으로, 사용자가 미스를 체감하지 않게 합니다.


캐시를 도입하기 전에 따져볼 것

캐시는 강력하지만 공짜가 아닙니다. 무효화·만료·일관성이라는 복잡성을 함께 떠안는 일이므로, 도입 전에 적합한지 판단해야 합니다.

캐시는 읽기가 많고 변경이 적은 데이터에 가장 잘 맞습니다. 상품 정보, 설정값, 자주 조회되는 통계 등입니다. 반대로 실시간 정확성이 중요한 데이터(잔액, 재고 수량 등)는 캐시로 인한 불일치가 치명적일 수 있어 신중해야 합니다. 도입 후에는 캐시 히트율을 모니터링해, 실제로 부하를 줄이고 있는지 확인해야 합니다. 히트율이 낮다면 캐시가 오히려 복잡성만 늘리고 있을 수 있습니다.


자주 묻는 질문 (FAQ)

Q1. 쓰기 시 캐시를 갱신하는 것과 삭제하는 것, 무엇이 나은가요?

Cache-Aside에서는 보통 삭제가 더 안전합니다. 갱신은 동시 쓰기 상황에서 캐시에 잘못된 값이 남을 위험이 있지만, 삭제 후 다음 조회에서 재적재하면 항상 DB의 최신 값을 반영하기 때문입니다.

Q2. 캐시 스탬피드는 TTL만 잘 잡으면 막을 수 있나요?

TTL에 지터를 주면 동시 만료를 줄일 수 있어 도움이 되지만, 완전한 해결은 아닙니다. 특정 키에 트래픽이 극단적으로 몰리면 여전히 위험하므로, 락 기반 재적재나 사전 갱신을 함께 쓰는 것이 안전합니다.

Q3. @Cacheable만 써도 되나요?

간단한 조회 캐싱에는 유용하지만, 무효화 시점·TTL·스탬피드 대응은 별도로 설계해야 합니다. 애너테이션은 캐시 적재·조회를 편하게 해줄 뿐, 캐시 전략 전체를 대신해 주지는 않습니다.


핵심만 추리면

Redis 캐시는 “붙이면 빨라지는 스위치”가 아니라, 무효화·만료·동시성까지 함께 설계해야 하는 하나의 전략입니다. 대부분의 서비스는 Cache-Aside로 시작해, 쓰기 시 캐시를 삭제하고 TTL을 안전망으로 두면 충분합니다. 여기에 인기 키가 몰리는 서비스라면 캐시 스탬피드를 반드시 염두에 두고, TTL 지터와 락 기반 재적재로 DB 폭주를 막아야 합니다. 캐시를 넣기 전에는 읽기 중심 데이터인지 따지고, 넣은 뒤에는 히트율로 효과를 확인하는 것까지가 캐시 전략의 한 묶음입니다.

댓글 남기기