핵심 한 줄

캐시 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이 살짝 눌린다. 하지만 실제로는 요청 수가 늘지 않았다. 같은 요청들이 같은 값을 중복으로 계산하는 것이다.

함정은 여기다 — 지연 스파이크만 보면 두 가지 다른 원인을 구별할 수 없다:

  1. N+1: 요청 한 건이 뒤에서 1+N개 쿼리를 fan-out한다.
  2. 스탬피드: 요청 여럿같은 값을 동시에 재계산한다.

p99 그래프는 둘을 같은 모양으로 보여준다.


4. 판별 신호 — 동시 재계산 수와 두 원인의 비교

CacheStampedeAtomicInteger로 실시간 추적하는 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_inflight0 (캐시 재계산 없음)> 0 (herd 크기)
petclinic_cache_recompute_total0빠르게 증가
지연 패턴모든 요청이 일정하게 느림TTL 주기마다 스파이크 파도

핵심은 이다. N+1은 요청 한 건 에서 일어나는 fan-out이고, 스탬피드는 요청 사이에서 일어나는 충돌이다. 두 원인 모두 latency를 올리지만, petclinic_cache_recompute_inflight는 스탬피드만 올릴 수 있다.

cache-miss count는 충분하지 않다

캐시 미스 카운터가 높다는 것은 “재계산이 많다”는 뜻이다. 하지만 그 재계산들이 동시에 같은 값을 노리는지는 inflight 게이지만이 말해준다. 미스 카운터가 높아도 inflight ≈ 1이면 스탬피드가 아니다; inflight ≈ 콘커런시이면 herd다.


5. 해법 방향 — 단일비행(single-flight)과 조기 만료

벤치는 의도적으로 single-flight 가드를 뺐다. 해법을 구현하는 것은 오케스트레이터와 설계자의 몫이기 때문이다. 판별 신호가 “스탬피드”를 명명한 뒤에 나오는 설계 결정은 두 가지다:

  1. 단일비행(single-flight): 같은 키에 대한 재계산을 하나로 접고 나머지는 그 결과를 기다리게 한다. Java의 CompletableFuture 공유 패턴이 이 역할을 한다.
  2. 조기 만료(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다. 지연이나 미스 카운터만으로는 이 둘을 절대 가를 수 없다.


관련 글