블로그 목록

캐시 스탬피드(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())는 음수이므로 왼쪽 값은 현재 시각보다 조금 뒤의 시각이 됩니다. 만료가 가까울수록, 재계산이 오래 걸리는 항목일수록 조건이 참이 될 확률이 높아집니다. 그래서 대부분의 요청은 캐시를 그대로 쓰고, 소수의 요청만 만료 직전에 미리 값을 갱신합니다. 락 없이 각 서버가 독립적으로 판단한다는 것이 장점입니다.

분산 락으로 갱신할 때의 흐름

서버가 많고 원본 조회가 특히 무거워서 서버 간에도 조회를 한 번으로 줄이고 싶다면 분산 락을 사용할 수 있습니다. 일반적인 흐름은 다음과 같습니다.

  1. 캐시를 조회하고, 값이 있으면 그대로 반환합니다.
  2. 값이 없으면 짧은 만료 시간을 둔 락을 시도합니다(예: Redis SET lock:popular 토큰 NX PX 3000).
  3. 락을 얻은 서버는 캐시를 한 번 더 확인한 뒤, 여전히 비어 있으면 DB를 조회해 캐시를 채우고 락을 해제합니다.
  4. 락을 얻지 못한 서버는 잠시 기다렸다가 캐시를 다시 읽거나, 이전 값(만료된 값)을 임시로 보여 줍니다.

락 대기가 길어지면 오히려 응답이 느려지므로, 대기 시간에는 상한을 두고 상한을 넘기면 DB를 직접 조회하는 식의 탈출구를 마련해 두는 것이 안전합니다.

7. 언제 쓰고, 언제 안 써도 되나

  • 필요한 경우: 조회 비용이 큰 키에 요청이 집중되는 경우(메인 화면, 랭킹, 설정값 등), 캐시 만료 시각마다 DB 지표가 튀는 것이 관찰되는 경우.
  • 안 써도 되는 경우: 트래픽이 낮거나, 원본 조회가 충분히 가볍거나, 키가 고르게 분산되어 특정 키에 몰리지 않는 경우. 이때는 적절한 TTL만으로도 충분합니다.

8. 자주 하는 실수

  • 수많은 키에 같은 TTL을 부여: 배치로 한꺼번에 채운 캐시가 같은 순간에 만료됩니다. TTL에 약간의 무작위 값을 더해 흩어 놓습니다.
  • 락을 잡은 뒤 캐시를 다시 확인하지 않음: 락을 기다리는 사이 다른 요청이 이미 캐시를 채웠을 수 있으므로, 락 획득 후 한 번 더 캐시를 확인합니다.
  • 분산 락에 과도하게 의존: 캐시 갱신용 락은 "중복 작업 줄이기"가 목적이므로 가끔 두 번 실행돼도 괜찮게 설계하는 편이 단순합니다. 분산 락의 한계는 Redis 분산 락 글에서 다룹니다.
  • DB 오류 결과까지 공유: singleflight는 오류도 함께 공유합니다. 일시적 오류라면 대기자 전원이 실패할 수 있으므로 재시도 정책을 함께 정합니다.

9. 정리

  • 캐시 스탬피드는 인기 키가 만료될 때 같은 원본 조회가 한꺼번에 몰리는 현상입니다.
  • 가장 먼저 프로세스 안에서 요청을 합치는 방법(singleflight 등)을 적용합니다.
  • 그래도 부족하면 분산 락이나 XFetch 같은 조기 갱신을 검토합니다.
  • TTL 무작위화는 간단하면서도 동시 만료를 줄이는 좋은 보조책입니다.

참고 자료