트래픽이 급증할 때 DB를 죽이는 주범은 종종 느린 쿼리보다 동일 키에 대한 중복 조회 폭증입니다. 예를 들어 인기 상품 상세 API에서 캐시 TTL이 만료되는 순간, 같은 상품 ID로 수백 개 요청이 동시에 들어오면 애플리케이션 인스턴스별로 같은 DB 쿼리를 반복 실행하게 됩니다. 이 상황이 캐시 스탬피드(cache stampede)이고, 요청 병합(request coalescing)은 이 문제를 가장 비용 효율적으로 줄이는 방법 중 하나입니다.

이 글에서 얻는 것

  • 캐시 스탬피드와 일반적인 캐시 미스의 차이를 구분하고, 병합이 필요한 임계 조건을 잡을 수 있습니다.
  • Singleflight(동일 키 동시 실행 1회 보장) 패턴을 API/배치 양쪽에서 적용하는 기준을 이해합니다.
  • P95 지연, DB QPS, 에러율을 함께 보며 운영 의사결정을 내리는 실무 체크포인트를 가져갑니다.

핵심 개념/이슈

1) 요청 병합은 “캐시”가 아니라 “동시성 제어” 문제다

캐시는 평균 지연을 낮추지만, TTL 만료 순간의 동시성 폭발까지 자동으로 막아주진 않습니다. 병합은 같은 키에 대해 선행 요청 1개만 원본(DB/외부 API)을 호출하고, 나머지는 그 결과를 공유하게 만드는 방식입니다.

실무에서 병합 도입을 검토할 조건:

  • 특정 핫키(top 1~5% 키)가 전체 조회 트래픽의 20% 이상 차지
  • 캐시 미스 순간 동일 키 동시 요청 수가 10개 이상 반복
  • DB CPU가 평시 대비 2배 이상 튀는 구간이 TTL 만료 시점과 일치

이 세 조건 중 2개 이상이면 병합 우선순위를 높게 둡니다.

2) 어디까지 병합할 것인가: 프로세스 로컬 vs 분산

  • 프로세스 로컬 병합: 인스턴스 내부에서만 중복 실행 방지. 구현이 단순하고 오버헤드가 낮음.
  • 분산 병합: 여러 인스턴스 간에도 1회 실행 보장(분산 락/리더 선출 기반). 일관성은 높지만 운영 복잡도 증가.

대부분의 팀은 로컬 병합 + Redis 캐시 조합으로 먼저 70~90% 효과를 얻습니다. 처음부터 분산 병합으로 가면 락 만료, 리더 장애, 타임아웃 전파 등 디버깅 비용이 커질 수 있습니다.

3) 실패 전파 정책이 없으면 병합이 오히려 장애를 키운다

한 번 실행한 선행 요청이 실패했을 때 대기 중인 후행 요청에 무엇을 반환할지 미리 정해야 합니다.

권장 정책:

  1. 선행 요청 타임아웃(예: 300ms) 시 후행 요청은 즉시 폴백 캐시(stale data) 확인
  2. stale 데이터가 있으면 stale-while-revalidate로 응답 후 백그라운드 재검증
  3. stale도 없으면 에러를 그대로 반환하되, 동일 키 재시도는 지수 백오프로 제한

핵심은 “같이 묶였으니 같이 죽는다”를 피하는 것입니다.

실무 적용

1) 구현 순서(작게 시작)

  1. 핫키 식별: 7일 기준 상위 키 분포와 TTL 만료 시점 트래픽 확인
  2. 로컬 Singleflight 적용: key = resourceType:id:version 규칙으로 그룹화
  3. 캐시 채우기 정책 정리: miss 시 단일 조회 후 set + 짧은 지터(jitter) 부여
  4. 관측 지표 추가: coalesced_requests, singleflight_wait_ms, origin_calls_saved

지터는 TTL을 랜덤하게 ±10~20% 흔들어 만료 동시점을 분산시킵니다. 병합만 넣고 TTL 동기 만료를 방치하면 스파이크는 줄지만 남습니다.

2) 의사결정 기준(숫자/우선순위)

도입 후 2주 동안 아래 기준으로 유지/확장 판단을 권장합니다.

  • 1순위: 안정성 — 병합 적용 엔드포인트의 5xx 증가가 0.2%p 이하
  • 2순위: 비용 — 원본 호출 수(Origin calls) 30% 이상 감소
  • 3순위: 체감 성능 — P95 지연 15% 이상 개선
  • 4순위: 운영 복잡도 — 장애 분석 시간이 기존 대비 1.5배 초과 시 설계 단순화 재검토

만약 Origin calls 감소는 큰데 P95가 개선되지 않는다면, DB가 아니라 네트워크/직렬화 구간이 병목일 수 있습니다. 이 경우 커넥션 풀 튜닝이나 API 레이트 리밋/백프레셔를 함께 봐야 합니다.

3) 운영 시나리오 예시

  • 평시: 캐시 hit 92%, miss 8%
  • TTL 만료 1분 구간: miss 25%로 상승, DB QPS 2.4배 튐
  • 병합 적용 후: 같은 구간 miss 24% 유사하지만 DB QPS는 1.3배로 완화

핵심은 miss 비율 자체보다 miss가 원본 호출로 몇 번 번역되는지입니다. 병합은 miss를 없애는 게 아니라, miss의 폭발적 fan-out을 줄입니다.

트레이드오프/주의점

  1. 메모리 압박: 키별 in-flight 상태를 들고 있어야 하므로 핫키가 너무 많으면 메모리 사용이 증가합니다. 최대 동시 키 수 상한을 두세요.
  2. 헤드 오브 라인 블로킹: 선행 요청이 느리면 후행 요청이 대기합니다. 키별 대기 시간 상한(예: 200~400ms)이 필요합니다.
  3. 에러 증폭 위험: 선행 실패가 다수 요청으로 전파될 수 있습니다. 폴백 캐시와 재시도 예산(retry budget)을 함께 설계해야 합니다.
  4. 관측 부재: 병합 효과를 계측하지 않으면 “복잡도만 증가”한 채 끝납니다. 최소 3개 지표(절감 호출 수, 대기 시간, 실패 전파율)는 필수입니다.

체크리스트 또는 연습

  • 핫키 상위 20개에 대해 TTL 만료 시 동시 요청 수를 측정했다.
  • 로컬 Singleflight 키 규칙(type:id:version)을 문서화했다.
  • 병합 대기 상한과 폴백 정책(stale 허용 범위)을 수치로 정했다.
  • origin_calls_saved와 P95 지연을 같은 대시보드에서 본다.
  • 2주 후 유지/확장/롤백 기준(5xx, P95, 운영시간)을 미리 합의했다.

연습 과제:

  1. 동일 상품 ID로 100 동시 요청 부하를 만들고 병합 전/후 DB QPS 차이를 비교해보세요.
  2. 선행 요청을 강제로 500ms 지연시키고 대기 상한 설정에 따른 실패율 변화를 확인해보세요.
  3. 병합 + TTL 지터 + stale-while-revalidate를 순차 적용하며 P95/P99가 어떻게 변하는지 기록해보세요.

함께 보면 좋은 글