캐시 스탬피드(Cache Stampede)의 원인과 대응: singleflight, 분산 락, XFetch
캐시는 자주 쓰는 데이터를 가까운 곳에 복사해 두고 데이터베이스(DB)의 부담을 덜어 주는 장치입니다. 그런데 인기 있는 캐시 항목 하나가 만료되는 순간, 그 데이터를 찾던 수많은 요청이 한꺼번에 DB로 몰리는 일이 생깁니다. 이 현상을 캐시 스탬피드(Cache Stampede)라고 하며, 도그파일(dog-piling)이라고 부르기도 합니다. 이 글은 트래픽이 늘기 시작한 서비스를 운영하는 분이 원인과 대표적인 대응 방법을 빠르게 이해할 수 있도록 정리했습니다.
1. 비유로 이해하기
동네 빵집이 매일 아침 진열대에 빵을 채워 둔다고 생각해 봅시다. 진열대(캐시)에 빵이 있으면 손님은 바로 사 갑니다. 그런데 진열대가 비는 순간 손님 100명이 동시에 주방(DB)에 "빵 하나 구워 주세요"라고 주문하면, 주방은 같은 빵을 100번 굽느라 마비됩니다. 사실 한 번만 구워서 진열대를 다시 채우면 충분한데 말입니다. 캐시 스탬피드 대응은 결국 "같은 빵은 한 번만 굽게 하기" 또는 "진열대가 비기 전에 미리 채워 두기"입니다.
2. 구체적인 시나리오
메인 화면의 인기 상품 목록을 만드는 데 DB 쿼리가 0.5초 걸리고, 결과를 5분 동안 캐시한다고 가정합니다. 초당 1,000건의 요청이 들어오는 상황에서 캐시가 만료되면, 첫 요청이 쿼리를 끝내고 캐시를 다시 채우기까지 0.5초 동안 들어온 약 500건이 모두 같은 쿼리를 DB에 보냅니다. 쿼리가 몰려 DB가 느려지면 쿼리 시간은 더 길어지고, 그동안 더 많은 요청이 몰리는 악순환이 생길 수 있습니다. 커넥션 풀 고갈이나 응답 지연이 연쇄적으로 번지는 원인이 되기도 합니다.
3. 대표적인 대응 전략
| 전략 | 핵심 아이디어 | 적용 범위 |
|---|---|---|
| 요청 합치기 (singleflight) | 같은 키의 동시 요청은 한 번만 DB 조회, 나머지는 결과 공유 | 프로세스(서버 1대) 안 |
| 분산 락 | 락을 잡은 서버 하나만 캐시를 다시 채움 | 여러 서버 |
| 확률적 조기 갱신 (XFetch) | 만료 직전 일부 요청이 확률적으로 미리 갱신 | 여러 서버, 락 불필요 |
| TTL 무작위화 (jitter) | 여러 키의 만료 시각을 조금씩 흩어 놓음 | 동시 만료 완화 보조책 |
서버가 여러 대라면 singleflight 같은 로컬 방식은 서버마다 따로 동작합니다. 서버 10대라면 최악의 경우 DB 조회가 10번 일어날 수 있지만, 수백 번에서 10번으로 줄어드는 것만으로도 충분한 경우가 많습니다. 이것으로 부족할 때 분산 락이나 XFetch를 더합니다.
4. Go: singleflight
golang.org/x/sync/singleflight는 Go 팀이 관리하는 보조 모듈(표준 라이브러리 아님)입니다. Do는 같은 키로 동시에 들어온 호출 중 하나만 함수를 실행하고, 나머지는 그 결과를 함께 받습니다.
type ProductService struct {
group singleflight.Group
cache Cache
repo Repository
}
func (s *ProductService) Popular(ctx context.Context) ([]Product, error) {
if items, ok := s.cache.Get("popular"); ok {
return items, nil
}
v, err, _ := s.group.Do("popular", func() (any, error) {
items, err := s.repo.FindPopular(ctx)
if err != nil {
return nil, err
}
s.cache.Set("popular", items, 5*time.Minute)
return items, nil
})
if err != nil {
return nil, err
}
return v.([]Product), nil
}
주의할 점이 있습니다. 첫 호출자의 ctx가 취소되면 기다리던 다른 호출자도 같은 오류를 받게 됩니다. 필요하면 DB 조회에 별도의 타임아웃 컨텍스트를 쓰거나 DoChan을 사용합니다.
5. NestJS: 진행 중인 Promise 공유
Node.js는 한 프로세스 안에서 같은 키의 진행 중인 Promise를 Map에 보관하는 방식으로 같은 효과를 낼 수 있습니다.
@Injectable()
export class ProductService {
private readonly inflight = new Map<string, Promise<Product[]>>();
async popular(): Promise<Product[]> {
const cached = await this.cache.get<Product[]>('popular');
if (cached) return cached;
const running = this.inflight.get('popular');
if (running) return running;
// loadAndCache(): DB 조회 후 캐시에 저장하는 메서드(생략)
const task = this.loadAndCache().finally(() => this.inflight.delete('popular'));
this.inflight.set('popular', task);
return task;
}
}
PM2 클러스터 모드처럼 프로세스가 여러 개라면 이 Map은 프로세스마다 따로 존재한다는 점을 기억해야 합니다.
6. XFetch: 만료 전에 확률적으로 미리 갱신
XFetch는 Vattani, Chierichetti, Lowenstein의 논문 "Optimal Probabilistic Cache Stampede Prevention"(VLDB 2015)에서 제안된 방법입니다. 캐시에 값과 함께 값을 다시 계산하는 데 걸린 시간(delta)과 만료 시각을 저장해 두고, 요청마다 아래 조건을 검사합니다.
// beta 기본값은 1, 크게 할수록 더 일찍 갱신
const shouldRefresh =
!entry || now - entry.delta * beta * Math.log(Math.random()) >= entry.expiry;
Math.log(Math.random())는 음수이므로 왼쪽 값은 현재 시각보다 조금 뒤의 시각이 됩니다. 만료가 가까울수록, 재계산이 오래 걸리는 항목일수록 조건이 참이 될 확률이 높아집니다. 그래서 대부분의 요청은 캐시를 그대로 쓰고, 소수의 요청만 만료 직전에 미리 값을 갱신합니다. 락 없이 각 서버가 독립적으로 판단한다는 것이 장점입니다.
분산 락으로 갱신할 때의 흐름
서버가 많고 원본 조회가 특히 무거워서 서버 간에도 조회를 한 번으로 줄이고 싶다면 분산 락을 사용할 수 있습니다. 일반적인 흐름은 다음과 같습니다.
- 캐시를 조회하고, 값이 있으면 그대로 반환합니다.
- 값이 없으면 짧은 만료 시간을 둔 락을 시도합니다(예: Redis
SET lock:popular 토큰 NX PX 3000). - 락을 얻은 서버는 캐시를 한 번 더 확인한 뒤, 여전히 비어 있으면 DB를 조회해 캐시를 채우고 락을 해제합니다.
- 락을 얻지 못한 서버는 잠시 기다렸다가 캐시를 다시 읽거나, 이전 값(만료된 값)을 임시로 보여 줍니다.
락 대기가 길어지면 오히려 응답이 느려지므로, 대기 시간에는 상한을 두고 상한을 넘기면 DB를 직접 조회하는 식의 탈출구를 마련해 두는 것이 안전합니다.
7. 언제 쓰고, 언제 안 써도 되나
- 필요한 경우: 조회 비용이 큰 키에 요청이 집중되는 경우(메인 화면, 랭킹, 설정값 등), 캐시 만료 시각마다 DB 지표가 튀는 것이 관찰되는 경우.
- 안 써도 되는 경우: 트래픽이 낮거나, 원본 조회가 충분히 가볍거나, 키가 고르게 분산되어 특정 키에 몰리지 않는 경우. 이때는 적절한 TTL만으로도 충분합니다.
8. 자주 하는 실수
- 수많은 키에 같은 TTL을 부여: 배치로 한꺼번에 채운 캐시가 같은 순간에 만료됩니다. TTL에 약간의 무작위 값을 더해 흩어 놓습니다.
- 락을 잡은 뒤 캐시를 다시 확인하지 않음: 락을 기다리는 사이 다른 요청이 이미 캐시를 채웠을 수 있으므로, 락 획득 후 한 번 더 캐시를 확인합니다.
- 분산 락에 과도하게 의존: 캐시 갱신용 락은 "중복 작업 줄이기"가 목적이므로 가끔 두 번 실행돼도 괜찮게 설계하는 편이 단순합니다. 분산 락의 한계는 Redis 분산 락 글에서 다룹니다.
- DB 오류 결과까지 공유: singleflight는 오류도 함께 공유합니다. 일시적 오류라면 대기자 전원이 실패할 수 있으므로 재시도 정책을 함께 정합니다.
9. 정리
- 캐시 스탬피드는 인기 키가 만료될 때 같은 원본 조회가 한꺼번에 몰리는 현상입니다.
- 가장 먼저 프로세스 안에서 요청을 합치는 방법(singleflight 등)을 적용합니다.
- 그래도 부족하면 분산 락이나 XFetch 같은 조기 갱신을 검토합니다.
- TTL 무작위화는 간단하면서도 동시 만료를 줄이는 좋은 보조책입니다.