핵심 한 줄
캐시 TTL 만료 직후 같은 키로 떼(herd)가 몰린다 —
petclinic_cache_recompute_inflight가 동시 콘커런시 수준으로 치솟고petclinic_cache_recompute_total이 폭발한다. N+1은 요청 하나 안에서 1+N개 쿼리가 fan-out되는 것이고, 스탬피드는 요청 여럿이 같은 값을 동시에 재계산하는 것 — 축이 다르다. ‘동시 재계산 수’가 오진을 막는 판별 신호다.
시리즈 — 장애를 '구별 가능'하게 만든 카오스 벤치
1. 배경 — 지연의 수많은 얼굴
운영에서 p99 latency↑ 알림이 뜨면 후보가 많다. 락 경합·GC·N+1·커넥션풀 고갈… 전부 같은 증상으로 도착한다. 이 시리즈의 목표는 각 원인을 다른 원인은 못 내는 단 하나의 신호로 가르는 것이다(0.서문).
W12의 주인공은 캐시 스탬피드(cache stampede) 다. 캐시가 만료된 순간, 보호막을 잃은 백엔드로 요청들이 같은 값을 동시에 재계산하러 달려든다 — 이 떼를 herd라 부른다. 겉으로는 트래픽 급증처럼 보이지만, 실은 새 요청이 늘어난 게 아니라 기존 요청들이 캐시 없이 백엔드로 동시에 쏟아진 것이다. 오진하면 스케일아웃으로 대응하다 더 큰 stampede를 일으킨다.
2. 무엇을 망가뜨렸나 — single-flight 없는 TTL 캐시
chaos 프로파일에서 cacheStampede를 arm하면, owner 검색(GET /owners)이 single-flight 가드가 없는 단일 항목 TTL 캐시를 경유한다. TTL은 1초(chaos.cache.ttl-ms=1000), 재계산 지연은 300ms(chaos.cache.recompute-ms=300). 이 두 값이 겹치는 창이 herd의 온상이다.
// OwnerController.java (chaos seam — profile-gated)
this.lockContender.ifAvailable(LockContender::contendIfArmed);
this.cacheStampede.ifAvailable(CacheStampede::getOrLoadIfArmed);seam은 ObjectProvider<CacheStampede>로 주입되어 chaos 프로파일에서만 살아난다. 평소 빌드에서 .ifAvailable(…)는 no-op이다.
getOrLoadIfArmed() 내부는 단순하다 — 락도, single-flight 채널도 없다:
// CacheStampede — concurrent-recompute probe (inflight gauge + total counter)
this.inflight.incrementAndGet(); // herd 크기 실시간 추적
try {
this.recomputeTotal.increment();
sleepQuietly(this.recomputeMs); // 300 ms 재계산 시뮬레이션
this.value = new Object();
this.expiresAtMillis = System.currentTimeMillis() + this.ttlMs;
} finally {
this.inflight.decrementAndGet();
}
// → petclinic.cache.recompute.inflight (gauge), petclinic.cache.recompute.total (counter)entry가 만료된 창(300ms) 안에 들어온 모든 요청이 inflight를 동시에 올린다. single-flight 가드가 있었다면 단 한 번만 올랐을 값이다.
3. 증상 — 만료 주기마다 부하 파도
smoke(scripts/smoke/cache-stampede-to-slack.sh)는 12개 동시 요청을 반복해 부하를 건다. TTL 1초마다 entry가 만료되고, 그 직후 12개 스레드 모두가 재계산에 진입한다.
겉에서 보이는 것은 주기적인 지연 스파이크 — 마치 트래픽이 갑자기 몰린 것처럼 보인다. p99가 치솟고 throughput이 살짝 눌린다. 하지만 실제로는 요청 수가 늘지 않았다. 같은 요청들이 같은 값을 중복으로 계산하는 것이다.
함정은 여기다 — 지연 스파이크만 보면 두 가지 다른 원인을 구별할 수 없다:
- N+1: 요청 한 건이 뒤에서 1+N개 쿼리를 fan-out한다.
- 스탬피드: 요청 여럿이 같은 값을 동시에 재계산한다.
p99 그래프는 둘을 같은 모양으로 보여준다.
4. 판별 신호 — 동시 재계산 수와 두 원인의 비교
CacheStampede가 AtomicInteger로 실시간 추적하는 petclinic_cache_recompute_inflight가 결정적 신호다. Grafana 알림(CacheStampede)은 max_over_time(petclinic_cache_recompute_inflight{service="petclinic"}[2m]) > 3에서 발화한다.
두 원인을 표로 놓으면 축이 갈린다:
| 신호/축 | N+1 (W11) | 캐시 스탬피드 (W12) |
|---|---|---|
| 중복 단위 | 요청 내 — 요청 하나가 1+N 쿼리 fan-out | 요청 간 — N 요청이 같은 값 동시 재계산 |
petclinic_cache_recompute_inflight | 0 (캐시 재계산 없음) | > 0 (herd 크기) |
petclinic_cache_recompute_total | 0 | 빠르게 증가 |
| 지연 패턴 | 모든 요청이 일정하게 느림 | TTL 주기마다 스파이크 파도 |
핵심은 축이다. N+1은 요청 한 건 안에서 일어나는 fan-out이고, 스탬피드는 요청 사이에서 일어나는 충돌이다. 두 원인 모두 latency를 올리지만, petclinic_cache_recompute_inflight는 스탬피드만 올릴 수 있다.
cache-miss count는 충분하지 않다
캐시 미스 카운터가 높다는 것은 “재계산이 많다”는 뜻이다. 하지만 그 재계산들이 동시에 같은 값을 노리는지는 inflight 게이지만이 말해준다. 미스 카운터가 높아도 inflight ≈ 1이면 스탬피드가 아니다; inflight ≈ 콘커런시이면 herd다.
5. 해법 방향 — 단일비행(single-flight)과 조기 만료
벤치는 의도적으로 single-flight 가드를 뺐다. 해법을 구현하는 것은 오케스트레이터와 설계자의 몫이기 때문이다. 판별 신호가 “스탬피드”를 명명한 뒤에 나오는 설계 결정은 두 가지다:
- 단일비행(single-flight): 같은 키에 대한 재계산을 하나로 접고 나머지는 그 결과를 기다리게 한다. Java의
CompletableFuture공유 패턴이 이 역할을 한다. - 조기 만료(probabilistic early expiry): TTL이 다 차기 전에 일부 요청이 먼저 갱신을 시작해 만료 순간의 herd 규모를 줄인다.
둘은 트레이드오프가 다르다. single-flight는 구현이 명확하지만 가드 로직이 hot-path에 추가된다. 조기 만료는 타이밍 파라미터 하나만 추가하지만 부분적 herd는 남는다. 어느 쪽을 쓸지는 캐시의 일관성 요구와 재계산 비용에 따라 결정한다.
6. 교훈
요청 간 중복을 single-flight로 접는다
지연이 오르면 N+1을 먼저 의심하기 쉽다 — 둘 다 백엔드를 두드리기 때문이다. 하지만 N+1은 요청 내 fan-out이고 스탬피드는 요청 간 충돌이다 — 축이 다르다. 벤치의 판별 신호는
petclinic_cache_recompute_inflight— 동시 재계산 수. 이 게이지가 콘커런시 수준으로 치솟으면 원인은 스탬피드이고, 해법의 방향은 single-flight다. 지연이나 미스 카운터만으로는 이 둘을 절대 가를 수 없다.
관련 글
- 0.서문 — 이 ‘판별 신호’ 논제의 전체 그림
- 11.요청 하나에 쿼리 1+N — 요청 내 fan-out vs 요청 간 중복의 정면 대조
- 10.다 멈췄는데 데드락은 아니다 — 같은 지연 패밀리, BLOCKED 스레드 신호
- 2.장애 도구가 장애에 죽지 않게 — 이 신호를 소비하는 자동 진단
- 근거: spring-petclinic
CacheStampede,OwnerControllercache-stampede seam,scripts/smoke/cache-stampede-to-slack.sh