← posts/b.log()

blog92@web:~$ cat posts/backend-antipatterns-12-retry-storm-thundering-herd.md

BACKEND8 min read

동기 호출 사슬과 장애 전파 — Retry Storm, Thundering Herd

하위 서비스 하나가 느려지자 전체가 멈추는 동기 호출 사슬. 재시도 증폭, 캐시 동시 만료, 커넥션 풀 도미노와 각 방어책의 대가.

하나가 느려지자 전부가 멈췄다

추천 서비스의 응답이 50ms에서 2초로 늘어났다. 추천은 상품 상세 화면의 하단 영역일 뿐이고 없어도 화면은 그려진다.

호출부는 이렇게 생겼다.

ts
// services/product/detail.ts — Before: 방어 장치가 하나도 없다
export async function productDetail(id: string) {
  const [product, stock, recommend] = await Promise.all([
    fetch(`${PRODUCT_URL}/products/${id}`).then((r) => r.json()),
    fetch(`${STOCK_URL}/stock/${id}`).then((r) => r.json()),
    fetch(`${RECO_URL}/recommend/${id}`).then((r) => r.json()),  // 없어도 되는 영역
  ])
  // 타임아웃이 없으므로 추천이 2초를 끌면 이 요청도 2초를 붙잡는다.
  // 재시도 규칙도, 추천만 빼고 돌려주는 경로도, 호출별 자원 격리도 없다.
  return { ...product, stock, recommend }
}

세 호출이 같은 커넥션 풀을 쓰고, 셋 중 하나만 느려도 요청 전체가 그만큼 붙잡힌다. 여기까지는 추천 화면만의 문제로 보인다.

그런데 20분 뒤 주문도, 로그인도, 헬스체크도 실패하기 시작한다. 추천을 전혀 호출하지 않는 엔드포인트까지 타임아웃을 낸다. 대시보드는 전부 빨간색이고, 전부 동시에 아프기 때문에 무엇이 원인인지 알 수 없다. 이 편은 그 전파 경로를 따라간다.

깊이가 곱해지는 구조

동기 호출 사슬의 첫 번째 성질은 가용성이 더해지지 않고 곱해진다는 것이다.

게이트웨이 → BFF → 주문 → 재고 → 가격, 깊이 5의 사슬을 생각하자. 각 서비스의 가용성이 99.9%라면 전체는 0.999⁵ = 0.99501, 99.5%다.

연간 다운타임으로 환산하면 차이가 드러난다. 99.9%는 8,760 × 0.001 = 8.76시간, 99.5%는 8,760 × 0.00499 = 43.7시간이다. 한 달 기준으로는 730 × 0.001 = 43.8분이 730 × 0.00499 = 3.6시간이 된다. 각 서비스 팀은 모두 SLO를 지켰는데 사용자가 보는 가용성만 다섯 배 나빠졌다.

지연도 같은 방식으로 쌓인다. 7편에서 본 꼬리 지연은 동기 사슬에서 특히 나쁘게 작동한다. 각 구간의 p99가 100ms여도, 다섯 구간 중 하나라도 느리면 전체가 느려진다. 직렬 사슬의 응답 시간은 평균의 합이 아니라 최악 구간에 지배된다.

재시도가 부하를 증폭한다

여기에 재시도가 얹히면 상황이 질적으로 달라진다.

깊이 3의 사슬에서 각 계층이 실패 시 3회 시도(최초 1회 + 재시도 2회)한다고 하자. 최상위 요청 1건은 두 번째 계층에 3번, 세 번째 계층에 3 × 3 = 9번, 네 번째 계층에 3³ = 27번 도달한다. 다운스트림이 느려져서 실패율이 오르면, 그 순간 최하단이 받는 부하가 27배가 된다.

이건 양의 피드백 루프다.

다운스트림이 느려진다 → 상위 계층이 재시도한다 → 다운스트림 부하가 늘어난다 → 더 느려진다.

일반적인 장애는 원인이 사라지면 회복된다. Retry Storm은 원인이 사라진 뒤에도 유지된다. 재시도 트래픽 자체가 새로운 원인이 되기 때문이다. 다운스트림을 재시작해도, 올라오는 즉시 27배 부하를 맞고 다시 쓰러진다.

여기에 지터 없는 백오프가 문제를 하나 더 만든다. 다운스트림이 30초간 죽었다가 살아나면, 그 30초 동안 실패한 클라이언트 전부가 같은 백오프 표(1초, 2초, 4초…)를 따라 같은 시각에 재시도한다. 실패가 클라이언트들의 시계를 동기화시킨 것이다. 지터는 선택 사항이 아니라 이 동기화를 깨는 유일한 수단이다.

ts
// lib/retry.ts — 지터를 넣은 지수 백오프 + 재시도 예산
let budget = 0            // 최근 성공 대비 허용 재시도 수
export function onSuccess() { budget = Math.min(budget + 0.1, 50) }
 
export async function retry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
  for (let i = 0; ; i++) {
    try { const r = await fn(); onSuccess(); return r } catch (e) {
      // 재시도할 가치가 없는 오류와 예산 초과는 즉시 포기한다
      if (i >= max - 1 || !isRetryable(e) || budget < 1) throw e
      budget -= 1
      const cap = Math.min(1000 * 2 ** i, 20_000)
      await sleep(Math.random() * cap)  // full jitter: [0, cap) 균등 분포
    }
  }
}

budget이 핵심이다. 재시도 예산은 "재시도는 전체 트래픽의 일부여야 한다"는 제약을 코드로 강제한다. 성공 10건당 1건의 재시도만 허용하면, 전면 장애 상황에서 재시도 트래픽이 원래 부하의 10%를 넘지 못한다. 예산이 없으면 장애 시 재시도가 정상 트래픽보다 커진다.

더 근본적인 규칙이 하나 더 있다. 재시도는 사슬의 한 지점에서만 한다. 계층마다 재시도하면 27배가 나오고, 최상위에서만 3회 재시도하면 3배다.

증폭이 실제로 일어나는지는 잴 수 있다. 최하단 서비스가 받은 요청 수를 최상위가 받은 요청 수로 나눈 값을 대시보드에 올려두면 된다. 평상시 1.05였던 값이 장애 중 8로 뛴다면, 그 장애의 상당 부분은 재시도가 만든 것이다.

캐시가 동시에 비는 순간

Thundering Herd는 같은 증폭이 캐시 경계에서 일어나는 형태다.

인기 상품 상세를 초당 500건 조회하고, 캐시 TTL은 60초, 원본 재계산에 200ms가 걸린다고 하자. 캐시가 만료되는 순간부터 새 값이 채워지기까지 200ms 동안, 그 키를 찾는 요청 500 × 0.2 = 100건이 전부 캐시 미스로 원본에 도달한다. 평소 이 키의 원본 부하는 60초당 1건이었다.

배포 직후에는 더 나쁘다. 캐시를 한꺼번에 채우면 TTL도 같아서 만료도 한꺼번에 일어난다. 키 1,000개가 같은 초에 만료되면 그 순간 원본은 평소의 수백 배를 받는다.

해법은 셋이고, 조합해서 쓴다.

  • 단일 비행(single-flight). 같은 키에 대한 재계산을 프로세스당 하나로 묶는다. 나머지 요청은 그 결과를 기다린다. 100건이 1건이 된다.
  • 확률적 조기 만료. 만료가 가까워질수록 확률적으로 미리 갱신한다. TTL이 동시에 끝나는 것을 흩뜨린다. TTL 자체에 무작위 폭을 주는 것(예: 60초 ± 10%)도 같은 효과를 낸다.
  • stale-while-revalidate. 만료된 값을 일단 돌려주고 갱신은 뒤에서 한다. 원본 지연이 사용자에게 노출되지 않는다. 대가는 오래된 값이 잠깐 나간다는 것이고, 이건 10편에서 말한 허용 지연 합의의 문제다.

커넥션 풀이 도미노를 만든다

첫머리의 "추천이 느려졌는데 로그인이 실패한다"는 현상은 여기서 나온다.

HTTP 클라이언트 풀이 20개, 전체 처리량이 초당 100건이라 하자. 추천 응답이 50ms일 때 리틀의 법칙으로 점유 커넥션은 100 × 0.05 = 5개다. 여유가 충분하다. 응답이 2초로 늘면 필요한 커넥션은 100 × 2 = 200개가 된다. 풀 20개는 즉시 고갈된다.

문제는 그 다음이다. 풀이 하나라면, 추천을 기다리는 요청들이 풀 전체를 점유한다. 결제·로그인·헬스체크 요청은 커넥션을 얻지 못해 대기하다가 타임아웃난다. 추천과 아무 관계 없는 엔드포인트가 추천 때문에 죽는다. 워커 스레드, 이벤트 루프의 대기 큐(14편), DB 커넥션 풀에서도 같은 구조가 반복된다.

탈출 경로와 각각의 대가

Michael Nygard가 Release It!(2007)에서 정리한 안정성 패턴들이 여기에 대응한다. 앞의 productDetail을 After로 고친다는 것은 이 절의 장치들을 그 호출부에 겹쳐 놓는다는 뜻이다. 추천 호출을 breaker로 감싸 실패가 쌓이면 즉시 끊고, limiter로 추천에 커넥션 5개만 배정하고, 재시도가 필요한 구간에만 앞 절의 retry를 붙이고, 추천이 실패하면 그 필드를 비운 채 응답한다. 장치는 넷이지만 규칙은 하나다 — 없어도 되는 것이 있어야 하는 것의 자원을 먹지 못하게 한다. 각각 무료가 아니다.

Circuit Breaker. 실패율이 임계치를 넘으면 회로를 열어 호출을 즉시 실패시킨다. 목적은 다운스트림에 회복할 시간을 주는 것이고, 부수 효과로 호출자의 커넥션 점유도 끊는다. 대가는 튜닝이다. 임계치가 낮으면 일시적 흔들림에 회로가 열려 멀쩡한 기능을 죽이고, half-open 상태에서 통과시키는 시험 요청 수가 많으면 회복 중인 다운스트림을 다시 넘어뜨린다.

ts
// lib/breaker.ts — closed / open / half-open
type State = 'closed' | 'open' | 'half'
export function breaker(threshold = 5, cooldownMs = 10_000) {
  let state: State = 'closed', fails = 0, openedAt = 0
 
  return async function call<T>(fn: () => Promise<T>): Promise<T> {
    if (state === 'open') {
      if (Date.now() - openedAt < cooldownMs) throw new Error('circuit open')
      state = 'half'                      // 시험 요청 1건만 통과시킨다
    }
    try {
      const r = await fn()
      state = 'closed'; fails = 0         // half에서 성공하면 닫는다
      return r
    } catch (e) {
      fails++
      if (state === 'half' || fails >= threshold) { state = 'open'; openedAt = Date.now() }
      throw e
    }
  }
}

Bulkhead. 자원을 용도별로 격리한다. 추천 호출에 커넥션 5개만 배정하면, 추천이 느려져도 나머지 15개는 남는다. 대가는 자원 효율이다. 칸을 나누면 각 칸이 남는 자원을 서로 빌려 쓰지 못해, 같은 처리량에 더 많은 자원이 필요하다.

ts
// lib/bulkhead.ts — 용도별 동시성 리미터
export function limiter(max: number) {
  let active = 0
  const waiting: Array<() => void> = []
  return async function run<T>(fn: () => Promise<T>): Promise<T> {
    if (active >= max) {
      // 큐가 길어지면 기다리게 하지 말고 즉시 거절한다 (Load Shedding)
      if (waiting.length >= max * 2) throw new Error('bulkhead full')
      await new Promise<void>((res) => waiting.push(res))
    }
    active++
    try { return await fn() } finally { active--; waiting.shift()?.() }
  }
}

Load Shedding. 처리할 수 없는 부하를 대기시키지 않고 거절한다. 대기 큐가 길어지면 어차피 타임아웃될 요청을 붙들고 자원만 쓰기 때문이다. 대가는 제품 결정이다. "지금은 처리할 수 없다"를 화면에 어떻게 보여줄지 합의하지 않으면 이 방어는 배포되지 못한다.

비동기로의 전환. 가장 근본적인 해법이다. 판단 기준은 하나다. 호출자가 결과를 지금 당장 필요로 하는가. 주문 확정에 필요한 재고 확인은 동기여야 하지만, 알림 발송·집계 갱신·추천 학습은 아니다. 11편의 outbox로 발행하고 소비자가 처리하면 사슬에서 빠진다. 대가는 최종 일관성과 운영 대상 증가다.

판단 기준 — 방어가 오히려 장애를 만들 때

깊이 1~2의 짧은 사슬. 곱셈 효과가 작다. 깊이 2에서 0.999² = 99.8%로, 단일 서비스와 연간 8.8시간 차이다. 여기에 Circuit Breaker와 Bulkhead와 재시도 예산을 다 얹으면 얻는 것보다 설정 항목이 많아진다.

내부 저지연 호출. 같은 가용 영역 안의 p99 5ms 호출에 회로를 다는 것은 대개 손해다. 임계치를 잘못 잡은 Circuit Breaker는 그 자체로 장애 원인이 된다. 다운스트림이 정상인데 일시적 지연 하나로 회로가 열리고, cooldown 동안 모든 요청이 거절된다. 회로가 열려 있는 동안의 실패는 100%이므로, 오탐 한 번의 비용이 실제 장애보다 클 수 있다.

재시도가 위험한 연산. 멱등하지 않은 쓰기에 재시도를 걸면 중복 실행이 된다(11편). 재시도는 읽기와 멱등 쓰기에만 붙이고, 그 외에는 실패를 그대로 올려보내는 편이 안전하다.

방어를 얹기 전에 먼저 할 일은 사슬을 짧게 만드는 것이다. 깊이 5를 3으로 줄이면 가용성은 99.5%에서 99.7%로 오른다. 경계를 다시 긋는 것(10편)이 방어 패턴을 추가하는 것보다 대개 싸다.

요약

항목내용
가용성 곱셈깊이 5·각 99.9% → 0.999⁵ = 99.5%. 연 43.7h, 월 3.6h 다운
재시도 증폭계층마다 3회면 깊이 3에서 3³ = 27배. 원인이 사라져도 유지되는 루프
지터동시 실패가 클라이언트를 동기화. 지터 없는 백오프는 재시도를 한 시점에 모음
Cache Stampede초당 500건·재계산 200ms면 만료 순간 100건이 원본으로
풀 고갈지연 50ms→2초면 필요 커넥션 5→200. 무관한 엔드포인트까지 전파
탈출Circuit Breaker·Bulkhead·Load Shedding(Nygard, 2007), 지터 백오프, 재시도 예산
대가임계치 오탐, 자원 효율 하락, 의도적 거절을 제품이 수용해야 함
정답인 경우깊이 1~2, 내부 저지연 호출. 방어보다 사슬 단축이 먼저

다음 편 — 13편. 상태를 잊은 함수 — 서버리스 안티패턴

3막이 여기서 끝난다. 4막은 서버를 지운 시대를 다룬다. 서버가 사라지면서 서버가 제공하던 것들 — 프로세스 수명에 묶인 상태, 유지되는 커넥션, 예열된 캐시 — 도 함께 사라졌다. 13편은 함수 인스턴스에 상태를 남기는 코드, 콜드 스타트, 커넥션 풀이 성립하지 않는 환경, 그리고 함수가 함수를 부르는 Lambda Pinball을 다룬다.

COMMENTS (…)

댓글을 불러오는 중이에요.

NEW COMMENT0 / 1000
⌘↵ 전송

blog92@web:~$ cd ..