트래픽이 급증할 때 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) 실패 전파 정책이 없으면 병합이 오히려 장애를 키운다
한 번 실행한 선행 요청이 실패했을 때 대기 중인 후행 요청에 무엇을 반환할지 미리 정해야 합니다.
권장 정책:
- 선행 요청 타임아웃(예: 300ms) 시 후행 요청은 즉시 폴백 캐시(stale data) 확인
- stale 데이터가 있으면
stale-while-revalidate로 응답 후 백그라운드 재검증 - stale도 없으면 에러를 그대로 반환하되, 동일 키 재시도는 지수 백오프로 제한
핵심은 “같이 묶였으니 같이 죽는다”를 피하는 것입니다.
실무 적용
1) 구현 순서(작게 시작)
- 핫키 식별: 7일 기준 상위 키 분포와 TTL 만료 시점 트래픽 확인
- 로컬 Singleflight 적용:
key = resourceType:id:version규칙으로 그룹화 - 캐시 채우기 정책 정리: miss 시 단일 조회 후 set + 짧은 지터(jitter) 부여
- 관측 지표 추가:
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을 줄입니다.
트레이드오프/주의점
- 메모리 압박: 키별 in-flight 상태를 들고 있어야 하므로 핫키가 너무 많으면 메모리 사용이 증가합니다. 최대 동시 키 수 상한을 두세요.
- 헤드 오브 라인 블로킹: 선행 요청이 느리면 후행 요청이 대기합니다. 키별 대기 시간 상한(예: 200~400ms)이 필요합니다.
- 에러 증폭 위험: 선행 실패가 다수 요청으로 전파될 수 있습니다. 폴백 캐시와 재시도 예산(retry budget)을 함께 설계해야 합니다.
- 관측 부재: 병합 효과를 계측하지 않으면 “복잡도만 증가”한 채 끝납니다. 최소 3개 지표(절감 호출 수, 대기 시간, 실패 전파율)는 필수입니다.
체크리스트 또는 연습
- 핫키 상위 20개에 대해 TTL 만료 시 동시 요청 수를 측정했다.
- 로컬 Singleflight 키 규칙(
type:id:version)을 문서화했다. - 병합 대기 상한과 폴백 정책(stale 허용 범위)을 수치로 정했다.
-
origin_calls_saved와 P95 지연을 같은 대시보드에서 본다. - 2주 후 유지/확장/롤백 기준(5xx, P95, 운영시간)을 미리 합의했다.
연습 과제:
- 동일 상품 ID로 100 동시 요청 부하를 만들고 병합 전/후 DB QPS 차이를 비교해보세요.
- 선행 요청을 강제로 500ms 지연시키고 대기 상한 설정에 따른 실패율 변화를 확인해보세요.
- 병합 + TTL 지터 + stale-while-revalidate를 순차 적용하며 P95/P99가 어떻게 변하는지 기록해보세요.
💬 댓글