Redis를 캐시로 사용할 때 가장 먼저 떠올리는 설정 중 하나가 TTL이다.
처음에는 단순하게 생각하기 쉽다.
“이 데이터는 10분 정도 캐싱하면 되겠네.”
“자주 바뀌지 않으니까 1시간으로 둬도 되겠네.”
이렇게 TTL을 정하고 캐시에 데이터를 넣어두면, 일정 시간 동안은 DB를 직접 조회하지 않아도 된다.
그래서 응답 속도도 빨라지고 DB 부하도 줄어든다.
그런데 TTL을 설정했다고 해서 항상 안전한 것은 아니다.
오히려 트래픽이 많은 서비스에서는 TTL 만료 시점 때문에 예상하지 못한 부하가 생길 수 있다.
그중 하나가 Cache Stampede 문제다.
Cache Stampede란?

Cache Stampede는 캐시가 만료된 순간, 여러 요청이 동시에 원본 데이터 저장소로 몰리는 현상을 말한다.
예를 들어 메인 페이지에 보여주는 인기 게시글 목록을 Redis에 캐싱한다고 해보자.
key: popular:posts
ttl: 10분
처음 요청이 들어오면 DB에서 인기 게시글을 조회한 뒤 Redis에 저장한다.
그 이후 10분 동안은 Redis에서 바로 데이터를 가져올 수 있다.
여기까지만 보면 문제없어 보인다.
문제는 10분 뒤에 발생한다.
TTL이 끝나서 popular:posts 캐시가 사라지는 순간, 마침 많은 사용자가 동시에 메인 페이지에 접근하면 어떻게 될까?
요청 A → Redis 조회 → 캐시 없음 → DB 조회
요청 B → Redis 조회 → 캐시 없음 → DB 조회
요청 C → Redis 조회 → 캐시 없음 → DB 조회
요청 D → Redis 조회 → 캐시 없음 → DB 조회
원래는 한 번만 DB를 조회하고 다시 캐시에 저장하면 될 데이터인데, 캐시가 비어 있는 아주 짧은 순간에 여러 요청이 동시에 들어오면서 모두 DB를 조회하게 된다.
트래픽이 적은 서비스에서는 크게 문제가 안 될 수도 있다.
하지만 요청량이 많은 API나 메인 페이지, 랭킹 데이터, 설정 데이터, 권한 데이터처럼 자주 조회되는 데이터라면 이야기가 달라진다.
캐시가 만료되는 순간 DB에 요청이 몰리고, 그로 인해 응답 지연이 생기거나 DB 부하가 급격히 올라갈 수 있다.
TTL을 걸었는데 왜 문제가 될까?
TTL은 캐시를 자동으로 비워주는 편리한 기능이다.
하지만 모든 캐시 키가 비슷한 시간에 생성되고, 비슷한 TTL을 가지고 있다면 만료 시점도 비슷해진다.
예를 들어 서버가 재시작된 뒤 주요 데이터를 한 번에 캐싱했다고 해보자.
user:ranking ttl 10분
popular:posts ttl 10분
main:banners ttl 10분
product:list ttl 10분
이렇게 여러 캐시가 비슷한 시점에 생성되면, 10분 뒤에 여러 키가 거의 동시에 만료될 수 있다.
그 순간 관련 요청들이 모두 DB로 향하게 된다.
결국 캐시는 평소에는 부하를 줄여주지만, 특정 만료 시점에는 부하를 한꺼번에 넘기는 구조가 될 수 있다.
어떻게 방지할 수 있을까?
Cache Stampede를 막는 방법은 여러 가지가 있다.
서비스 규모나 데이터 특성에 따라 적절한 방식을 선택하면 된다.
1. TTL에 랜덤 값을 섞기
가장 간단한 방법은 TTL을 고정값으로만 두지 않는 것이다.
예를 들어 모든 캐시를 10분으로 설정하는 대신, 약간의 랜덤 값을 섞는다.
기본 TTL: 600초
랜덤 TTL: 0~60초
최종 TTL: 600초 + 랜덤값
그러면 캐시 키들이 한 번에 만료되지 않고 조금씩 다른 시점에 만료된다.
ttl := 600 + rand.Intn(60)
이 방식은 구현이 간단하면서도 여러 키가 동시에 만료되는 문제를 줄이는 데 도움이 된다.
다만 하나의 인기 키에 요청이 몰리는 문제까지 완전히 막아주지는 못한다.
여러 키가 동시에 만료되는 상황을 완화하는 데 더 가깝다.
2. Lock을 사용해서 한 요청만 DB를 조회하게 하기
캐시가 없을 때 모든 요청이 DB를 조회하는 것이 문제라면, DB를 조회하는 요청을 하나로 제한할 수 있다.
흐름은 이런 식이다.
1. Redis에서 캐시 조회
2. 캐시 없음
3. lock 획득 시도
4. lock을 얻은 요청만 DB 조회
5. DB 조회 후 캐시 저장
6. 나머지 요청은 잠깐 기다렸다가 캐시 재조회
이렇게 하면 캐시가 만료되더라도 동시에 들어온 요청 중 하나만 DB를 조회하게 만들 수 있다.
예를 들면 이런 구조다.
// 먼저 Redis에서 캐시 데이터를 조회한다.
data, err := redis.Get(ctx, key).Result()
// 캐시에 데이터가 있으면 DB까지 가지 않고 바로 반환한다.
if err == nil {
return data, nil
}
// 여기까지 왔다는 것은 캐시가 없거나 만료된 상태다.
// 여러 요청이 동시에 들어오면 모두 DB를 조회할 수 있으므로,
// Redis에 lock key를 만들어서 한 요청만 DB를 조회하게 제한한다.
lockKey := "lock:" + key
// SetNX는 key가 없을 때만 값을 저장한다.
// 즉, lockKey가 없으면 현재 요청이 락을 획득하고 true를 반환한다.
// 3초 TTL을 주는 이유는, 락을 잡은 요청이 중간에 죽더라도
// lock이 영원히 남지 않게 하기 위해서다.
ok, _ := redis.SetNX(ctx, lockKey, "1", 3*time.Second).Result()
if ok {
// 현재 요청이 lock을 얻은 상태다.
// DB 조회가 끝나면 lock을 삭제해서 다른 요청이 다시 갱신할 수 있게 한다.
defer redis.Del(ctx, lockKey)
// 캐시가 없으므로 DB에서 원본 데이터를 조회한다.
data, err := loadFromDB()
if err != nil {
return "", err
}
// DB에서 가져온 데이터를 Redis에 다시 저장한다.
// 이후 요청들은 DB가 아니라 Redis 캐시에서 데이터를 가져가게 된다.
redis.Set(ctx, key, data, ttl)
return data, nil
}
// 여기까지 왔다는 것은 다른 요청이 이미 lock을 잡고 DB를 조회 중이라는 뜻이다.
// 그래서 현재 요청은 바로 DB를 조회하지 않고 잠깐 기다린다.
time.Sleep(100 * time.Millisecond)
// 잠깐 기다린 뒤 Redis 캐시를 다시 조회한다.
// lock을 잡은 요청이 DB 조회 후 캐시에 저장했을 가능성이 있다.
data, err = redis.Get(ctx, key).Result()
if err == nil {
return data, nil
}
// 그래도 캐시가 없다면 fallback으로 DB를 조회한다.
// 예를 들어 lock을 잡은 요청이 실패했거나,
// DB 조회가 100ms보다 오래 걸렸을 수 있다.
return loadFromDB()
핵심은 캐시 미스가 발생했을 때 모든 요청이 DB로 가지 않게 하는 것이다.
다만 lock 방식도 주의할 점이 있다.
lock TTL을 너무 길게 잡으면 장애 시 lock이 오래 남을 수 있고, 너무 짧게 잡으면 DB 조회가 끝나기 전에 lock이 풀릴 수 있다.
그래서 lock TTL, 재시도 횟수, fallback 처리를 같이 고민해야 한다.
3. Stale While Revalidate 방식 사용하기
또 다른 방식은 만료된 데이터를 바로 버리지 않고, 잠깐 동안은 오래된 데이터를 내려주는 것이다.
즉, 캐시 데이터에 두 가지 시간을 둔다.
fresh ttl: 정상 캐시로 보는 시간
stale ttl: 조금 오래됐지만 임시로 사용할 수 있는 시간
예를 들어 인기 게시글 목록이라면 1~2분 정도 오래된 데이터가 보여도 큰 문제가 없을 수 있다.
이런 데이터는 캐시가 완전히 만료됐다고 바로 DB로 몰아보내기보다, 기존 데이터를 일단 응답하고 백그라운드에서 새 데이터를 갱신하는 방식이 더 안정적일 수 있다.
흐름은 이런 식이다.
1. 캐시 데이터가 아직 fresh 상태면 그대로 응답
2. fresh 시간은 지났지만 stale 시간 안이면 기존 데이터 응답
3. 동시에 별도 로직으로 캐시 갱신
4. stale 시간까지 지나면 DB 조회
이 방식은 사용자에게 약간 오래된 데이터를 보여줄 수 있다는 단점이 있다.
대신 트래픽이 많은 서비스에서는 응답 안정성을 높이는 데 도움이 된다.
랭킹, 통계, 추천 목록, 메인 배너, 공지 목록처럼 실시간 정확성이 아주 중요하지 않은 데이터에 잘 맞는다.
4. 미리 캐시를 갱신하기
사용자가 요청했을 때 캐시를 만드는 방식이 아니라, 스케줄러나 배치 작업으로 미리 캐시를 갱신하는 방법도 있다.
예를 들어 인기 게시글 목록을 1분마다 계산해서 Redis에 저장해두는 식이다.
매 1분마다:
DB 조회 → 인기 게시글 계산 → Redis 저장
이렇게 하면 사용자의 요청은 항상 Redis만 바라보게 만들 수 있다.
물론 이 방식은 모든 데이터에 어울리지는 않는다.
하지만 메인 페이지 데이터, 랭킹, 통계, 추천 영역처럼 조회는 많고 변경 주기가 어느 정도 정해진 데이터에는 꽤 잘 맞는다.
어떤 방법을 써야 할까?
무조건 하나의 정답이 있는 것은 아니다.
간단한 서비스라면 TTL에 랜덤 값을 섞는 것만으로도 충분할 수 있다.
트래픽이 많은 인기 API라면 lock이나 stale cache 방식을 같이 고려하는 것이 좋다.
개인적으로는 이렇게 나눠서 생각하는 편이 좋다고 본다.
여러 캐시 키가 동시에 만료되는 문제
→ TTL jitter, 즉 랜덤 TTL 적용
하나의 인기 캐시 키에 요청이 몰리는 문제
→ lock, singleflight, stale while revalidate 고려
실시간성이 낮고 조회가 많은 데이터
→ 스케줄러 기반 캐시 갱신 고려
중요한 건 Redis를 쓴다고 해서 자동으로 부하 문제가 해결되는 것은 아니라는 점이다.
캐시는 빠르지만, 캐시가 비는 순간에는 결국 원본 저장소를 다시 봐야 한다.
그래서 TTL을 어떻게 설정할지, 캐시 미스가 났을 때 요청을 어떻게 흘려보낼지까지 같이 설계해야 한다.
마무리
Redis 캐시는 API 응답 속도를 빠르게 만들고 DB 부하를 줄이는 데 큰 도움이 된다.
하지만 TTL을 단순히 “몇 분 뒤에 삭제” 정도로만 생각하면, 트래픽이 많은 순간에 예상하지 못한 문제가 생길 수 있다.
특히 여러 요청이 동시에 캐시 미스를 만나 DB로 몰리는 Cache Stampede 문제는 실무에서 충분히 발생할 수 있는 문제다.
처음부터 복잡한 구조를 만들 필요는 없지만, 최소한 다음 정도는 고려해두면 좋다.
TTL을 모두 동일하게 주고 있지는 않은가?
인기 키가 만료될 때 요청이 한 번에 DB로 가지는 않는가?
오래된 데이터를 잠깐 보여줘도 되는 데이터인가?
미리 갱신할 수 있는 데이터인가?
캐시는 단순히 Redis에 값을 넣고 TTL을 거는 것에서 끝나지 않는다.
어떤 데이터에, 어떤 TTL을 주고, 캐시가 없을 때 어떻게 처리할지까지 설계해야 안정적으로 사용할 수 있다.
그래서 Redis를 제대로 쓰려면 성능뿐 아니라 만료 시점의 부하까지 같이 생각해야 한다.
'Infra' 카테고리의 다른 글
| Redis Cluster 정리, Hash Slot과 데이터 분산 구조 이해하기 (2) (0) | 2026.05.01 |
|---|---|
| Redis Cluster 정리, 단일 Redis의 한계와 필요성 이해하기 (1) (0) | 2026.05.01 |
| n8n Webhook 테스트, curl로 요청 보내고 응답 받아보기 (0) | 2026.04.27 |
| n8n 로컬 설치해보기 (0) | 2026.04.27 |
| PostGIS란 무엇인가 (0) | 2026.04.10 |