지수 백오프는 왜 지수인가

지수 백오프가 분산 시스템에서 표준이 된 수학적 근거. 분산 공간 요구 조건, 등비수열의 dominance, 그리고 지터의 본질적 역할까지

sillysillyman··17분 읽기

들어가며

서버에 트래픽이 몰려서 100개의 요청이 한꺼번에 실패했다고 하자. 클라이언트는 어떻게 재시도해야 할까?
답은 지수 백오프(exponential backoff)다. 요청이 실패했을 때 대기 시간을 지수적으로 늘려가며 재시도를 반복하는 알고리즘이다. 1976년 Metcalfe & Boggs의 이더넷 논문에서 처음 등장한 이래, 50년이 지난 지금도 AWS SDK에 기본 내장되어 있고 분산 시스템 문헌마다 등장한다. 이 알고리즘 없이 재시도하면 다수 클라이언트가 동시에 몰리는 thundering herd가 발생한다. 그런데 여기서 의문이 든다. 왜 즉시 재시도도 아니고, 일정 시간 대기도 아니고, 선형 증가도 아닌, 하필 지수여야 하는가?

이 문제는 어디서 나타나는가

실제 백엔드에서 이 문제는 다음과 같은 형태로 나타난다.
  • API rate limit (HTTP 429): 서버 초당 1,000 req 제한인데 2,000개가 들어옴. 앞 1,000개는 성공, 뒤 1,000개는 429. 실패한 1,000개가 동시에 백오프하고 동시에 재시도. 다시 429.
  • DB 커넥션 풀 고갈: 풀 사이즈 100, 트래픽 스파이크로 200개 요청. 앞 100개는 커넥션 잡고 성공, 뒤 100개는 타임아웃. 풀이 회복 중에 100개가 동시 재시도하면 또 풀이 막힘.
  • 낙관적 동시성 제어 (OCC): 같은 레코드를 여러 클라이언트가 동시에 업데이트. UPDATE ... WHERE version = ? 같은 버전 체크가 있어서 하나만 성공하고 나머지는 충돌. 실패한 요청들이 동시 재시도. 또 하나만 성공.
세 시나리오 모두 같은 패턴이다. 같은 원인으로 비슷한 시점에 실패한 클라이언트들이 같은 알고리즘으로 백오프하면 비슷한 시점에 재시도한다.

naive한 방법들

즉시 재시도

가장 단순한 방법이다.
typescript
while (true) {
  try {
    await call();
    break;
  } catch {
    // 즉시 재시도
  }
}
100개의 요청이 동시에 실패했으니 동시에 재시도한다. 서버는 다시 다운된다.

고정 시간 대기 후 재시도

가장 자연스러운 다음 시도다.
typescript
while (true) {
  try {
    await call();
    break;
  } catch {
    await sleep(1000);
  }
}
100개의 요청이 정확히 1초 후에 다시 동시에 도착한다. 1초의 휴식이 있을 뿐, 결과적으로 즉시 재시도와 다르지 않다.

선형 증가 후 재시도

라운드마다 대기 시간을 늘리는 시도다.
typescript
let attempt = 0;
while (true) {
  try {
    await call();
    break;
  } catch {
    attempt += 1;
    await sleep(1000 * attempt);
  }
}
여전히 모든 요청이 같은 시점에 깨어난다. 1초 후 동시 재시도, 그다음 2초 후 동시 재시도, 그다음 3초 후... 즉, 시점이 옮겨갈 뿐, 결정론적이라는 점에서 고정 시간 대기와 다르지 않다.

무작위 대기 후 재시도

각 요청이 00WW 사이에서 무작위로 대기 시간을 고른다. WW는 윈도우 크기, 즉 분산할 시간 범위다. 이제야 각 요청이 서로 다른 시점에 도착할 수 있다.
typescript
const windowMs = 1000; // 분산할 시간 범위
while (true) {
  try {
    await call();
    break;
  } catch {
    await sleep(Math.random() * windowMs);
  }
}
여기서부터가 본론이다. WW를 얼마로 잡아야 할까?

분산에 필요한 공간은 N2N^2만큼 크다

요청 100개를 0~10초 사이에 분산시킨다고 해보자. 평균적으로 1초당 10개다. 같은 순간에 두세 개가 겹치는 일이 잦다.
100개를 0~100초로 분산시키면 1초당 평균 1개지만, 여전히 같은 순간에 여러 요청이 종종 겹칠 수 있다.
물론 무작위로 분산시킨다고 해서 어떤 W로도 충돌 확률 0%는 보장하지 못한다. 운이 좋으면 100개의 슬롯에서도 100개가 겹치지 않을 수 있고, 운이 나쁘면 100만 슬롯에서도 여러 개가 겹칠 수 있다. 따라서 핵심은 충돌 확률을 충분히 낮게 만들려면 윈도우가 얼마나 커야 하느냐다.
핵심 관찰은 다음과 같다. 충돌은 두 요청 사이의 문제다. 한 개만 있으면 충돌은 일어나지 않는다. 충돌이 일어나려면 적어도 두 개가 같은 슬롯에 떨어져야 한다. 따라서 계산해야 할 단위는 개별 요청이 아니라 요청의 쌍이다.
NN개의 요청이 있을 때 가능한 쌍의 수를 세보자.
  • 3개 → 3쌍
  • 4개 → 6쌍
  • 10개 → 45쌍
  • 100개 → 4,950쌍
규칙은 서로 다른 NN개의 항목 중 한 쌍을 선택하는 조합의 수 (n2)=N(N1)/2\binom n 2 = N(N-1)/2, 대략 N2/2N^2/2다. 요청은 100개인데 쌍은 5,000개에 가깝다. 요청 수가 두 배가 되면 쌍은 제곱에 비례하여 네 배가 된다.
이제 각 쌍이 같은 슬롯에 떨어질 확률은 1/W1/W다. 그러면 NN개 중 어떤 쌍이라도 충돌할 기댓값은 (쌍의 수) × (쌍당 충돌 확률), 즉:
N22×1W=N22W\frac{N^2}{2} \times \frac{1}{W} = \frac{N^2}{2W}
충돌이 거의 일어나지 않으려면 이 값이 1보다 충분히 작아야 한다. 즉 WW N2N^2 정도는 되어야 한다.
이것이 그 유명한 23명만 모여도 같은 생일이 있을 확률이 50%를 넘는다는 생일 문제의 본질이다. 23명이면 쌍은 253개이고, 1년 365일과 거의 비슷한 규모다. 그래서 50% 확률로 누군가 겹친다. 사람 수가 아니라 쌍의 수를 365와 비교해야 직관이 맞는다.
100개라면 윈도우는 약 10,000 슬롯이다. 1,000도 아니고 만 단위다. 직관과 상당히 다르다.
보다 엄밀하게 따지면 정확한 상수까지 얻을 수 있다. NN개 항목을 WW개 슬롯에 무작위로 넣을 때 어떤 쌍이라도 같은 슬롯에 들어갈 충돌 확률 PP를 정확히 따져보자.
NN개를 순차적으로 배치하자. 이미 kk개가 차지된 상태에서 다음 항목이 빈 슬롯을 고를 확률은 1k/W1 - k/W다 (k=0k = 0일 때 첫 배치, k=N1k = N-1일 때 마지막 배치). 모두 곱하면 충돌 사건 CC의 여사건 Cˉ\bar{C} (모두 서로 다른 슬롯에 들어감)의 확률은 다음과 같다:
P(Cˉ)=k=0N1(1kW)P(\bar{C}) = \prod_{k=0}^{N-1}\left(1 - \frac{k}{W}\right)
이 곱은 닫힌 형태로 다루기 까다로워서 근사가 필요하다. 테일러 전개 ex=1x+x22!e^{-x} = 1 - x + \frac{x^2}{2!} - \cdots에서 xx가 작으면 x2x^2 이후 항이 무시 가능하므로 1xex1 - x \approx e^{-x}가 성립한다. NWN \ll W 가정 하에 k/Wk/W가 작아서 이 근사가 잘 들어맞는다.
각 항을 지수로 바꾸면 eaeb=ea+be^a \cdot e^b = e^{a+b} 성질에 의해 곱이 지수의 합으로 압축되고, 합 안에는 등차수열 0+1++(N1)=N(N1)20 + 1 + \cdots + (N-1) = \frac{N(N-1)}{2}가 등장한다.
P(Cˉ)k=0N1ek/W=exp(1Wk=0N1k)=exp(N(N1)2W) P(\bar{C}) \approx \prod_{k=0}^{N-1} e^{-k/W} = \exp\left(-\frac{1}{W}\sum_{k=0}^{N-1}k\right) = \exp\left(-\frac{N(N-1)}{2W}\right)
지수 안에 등장한 N(N1)2\frac{N(N-1)}{2}는 본문에서 본 쌍의 수와 정확히 같다. 여사건 관계 P=1P(Cˉ)P = 1 - P(\bar{C})에 의해:
P1exp(N(N1)2W)P \approx 1 - \exp\left(-\frac{N(N-1)}{2W}\right)
이 확률을 50% 이하로 만들려면:
WN(N1)2ln20.72×N2W \geq \frac{N(N-1)}{2 \ln 2} \approx 0.72 \times N^2
본문의 직관적 논리와 같은 결론에, 정확한 상수 0.720.72까지 함께 도출된다.
이것이 첫 번째 핵심이다. 분산에 필요한 공간 WW은 요청 수 NN의 제곱에 비례한다.

윈도우를 키우는 두 가지 방식

문제는 처음에 충돌하는 요청이 몇 개인지 알 수 없다는 점이다. 그래서 충돌이 반복되면 윈도우를 키우는 적응적 방식을 사용한다.
100개가 충돌해서 윈도우를 10,000까지 키워야 한다고 했을 때, 두 가지 전략을 비교해보자.

선형 증가

매 라운드 윈도우를 1씩 늘린다. 10,000에 도달하려면 10,000 라운드가 필요하다.
그동안 클라이언트들은 계속 충돌하고, 서버는 계속 부하를 받는다. 사실상 영원히 끝나지 않는다.

지수 증가

매 라운드 윈도우를 2배로 늘릴 때 10,000에 도달하려면:
21=222=423=8213=8,192214=16,384\begin{aligned} 2^1 &= 2 \\ 2^2 &= 4 \\ 2^3 &= 8 \\ &\vdots \\ 2^{13} &= 8{,}192 \\ 2^{14} &= 16{,}384 \end{aligned}
14 라운드면 충분하다.
10,000 라운드와 14 라운드를 비교했을 때 확실히 후자가 효율적이다. 이것이 지수 백오프의 첫 번째 장점이다.

그러면 너무 오래 기다리는 거 아닌가?

14번째 재시도면 마지막 대기 시간이 16,384이다. 그 앞에 13번을 모두 누적하면 더 심각해지지 않을까?
답은 의외다. 누적이 마지막 한 번의 약 2배에 그친다.
작은 예부터 직접 더해보자.
20+21+22+23+24=3125=32\begin{aligned} 2^0 + 2^1 + 2^2 + 2^3 + 2^4 &= 31 \\ 2^5 &= 32 \end{aligned}
31 < 32. 다섯 번의 누적이 여섯 번째 한 번보다 작다. 즉 새로 추가되는 한 항이 이전 누적 전체보다 크다.
좀 더 길게 가도 같다.
20+21+22+23+24+25+26+27=25528=256\begin{aligned} 2^0 + 2^1 + 2^2 + 2^3 + 2^4 + 2^5 + 2^6 + 2^7 &= 255 \\ 2^8 &= 256 \end{aligned}
255 < 256. 매번 두 배가 되니 새 항 하나가 이전 합보다 항상 크다. 이것이 등비수열의 핵심이다. 일반화하면:
1+2+4++2k=2k+1122k1 + 2 + 4 + \cdots + 2^k = 2^{k+1} - 1 \approx 2 \cdot 2^k
따라서 14라운드 백오프의 실제 누적은 2151=32,7672^{15} - 1 = 32{,}767, 마지막 한 번 (16,38416{,}384)의 거의 정확히 2배다. "14번이나 기다린다"는 인상과 달리 마지막 한 번이 누적의 절반 이상을 차지하고, 이전 13번은 합쳐도 마지막 한 번만 못하다.
선형은 어떨까?
1+2+3++k=k(k+1)2k221 + 2 + 3 + \cdots + k = \frac{k(k+1)}{2} \approx \frac{k^2}{2}
100번 재시도면 누적은 5,0505{,}050, 마지막 한 번(100100)의 약 50배다. 1,000번이면 500배. 즉 라운드 수가 늘어날수록 누적/마지막 비율이 선형으로 함께 커진다.
지수 백오프의 두 번째 장점이 이것이다. 지수는 라운드가 늘어도 비율이 약 2로 고정되지만, 선형은 라운드 수에 비례해 비율이 커진다.

두 장점이 함께 작동한다

정리하면 지수 증가는 두 가지를 동시에 달성한다.
성질선형지수
윈도우를 N2N^2까지 키우는 데 걸리는 라운드N2N^2 라운드logN\log N 라운드
누적 대기 시간라운드 수의 제곱마지막 대기의 약 2배
이 두 가지가 결합되어야 좋은 백오프 알고리즘이 된다. 어느 하나만으로는 부족하다.
  • 빠르게 분산되어야 서버가 회복할 시간을 얻음
  • 누적 시간이 유한해야 클라이언트가 포기하지 않을 만큼만 기다림
지수 증가는 이 둘을 모두 만족하는 거의 유일한 단순 함수다.

base는 꼭 2여야 할까?

수학적으로는 그렇지 않다. 1보다 큰 어떤 수든 지수다. base를 1.5로 하든 3으로 하든 위 두 성질은 그대로 유지된다.
base = 2가 흔한 이유는 다음과 같다.
  • 비트 시프트(<< 1)로 단순한 구현
  • 너무 빠르지도 너무 느리지도 않은 속도
  • 1976년 이더넷 논문(Metcalfe & Boggs)에서 시작된 binary exponential backoff의 관성
AWS SDK는 보통 1.5 ~ 3 사이를 사용한다. 사용자 응답성을 중시한다면 base를 작게, 백엔드 간 통신처럼 보수적이어도 되는 곳이라면 크게 잡으면 된다.

지수 백오프의 또 다른 핵심: 지터(jitter)

지금까지 지수 증가가 왜 좋은지 살펴봤다. 그런데 지수 증가만으로는 어떤 효과도 내지 못한다.
100개가 동시에 충돌했고, 모두 같은 알고리즘에 따라 base×2n\text{base} \times 2^n만큼 대기한 뒤 재시도한다고 하자.
  • 1차 재시도: 모두 base×2\text{base} \times 2초 후 깨어남 → 다시 동시 충돌
  • 2차 재시도: 모두 base×4\text{base} \times 4초 후 깨어남 → 다시 동시 충돌
  • 3차 재시도: 모두 base×8\text{base} \times 8초 후 깨어남 → 다시 동시 충돌
윈도우는 점점 커지지만 클라이언트들은 그 안에 분산되지 않고 한 점에 모여 함께 움직인다. 결정론적 알고리즘은 모두 그렇다. 같은 입력에 같은 출력이다. 지수든 선형이든 일정 시간이든 결과는 동일하다.
실제로 분산을 만드는 것은 무작위성이다.
typescript
// 결정론적: 윈도우만 커지고 분산은 0
const delay = base * Math.pow(2, n);

// Full Jitter: 0과 base × 2^n 사이에서 무작위 선택
const delay = Math.random() * base * Math.pow(2, n);
여기서 한 가지 흥미로운 역사적 사실이 있다. 원조 이더넷의 binary exponential backoff(BEB)에는 처음부터 무작위가 포함되어 있었다. 1976년 Metcalfe & Boggs의 논문은 충돌하면 [0,2n1][0, 2^n - 1] 범위에서 무작위로 슬롯을 고른다라고 명시했다. 2n2^n초 후에 재시도한다가 아니라.
즉 무작위 선택은 알고리즘에 나중에 붙인 패치가 아니라 처음부터 본질의 일부였다. 지수 백오프 + 지터라는 흔한 표현이 다소 오해를 낳는 이유다. 둘은 분리될 수 없는 하나의 알고리즘이며, 각자 다른 역할을 한다.
  • 무작위 선택: 클라이언트를 실제로 서로 다른 시점으로 분산시킴
  • 윈도우의 지수 증가: 분산할 공간이 충돌 횟수에 맞춰 적절한 속도로 자라게 함
둘 중 하나만 있으면 어떻게 되는지 정리해보자.
무작위 선택지수 증가결과
없음있음동기화가 깨지지 않음. 윈도우만 의미 없이 커짐
있음없음 (고정 WW)NN이 크면 영원히 충돌 (N2N^2 공간이 안 만들어짐)
있음있음우리가 원하는 것
AWS의 실험 결과 Full Jitter 방식이 가장 효과적이었다. 클라이언트 작업량은 줄어들면서 완료 시간은 거의 차이가 없다. 실전에서 지수 백오프라고 부르는 알고리즘은 사실상 항상 이 두 부분이 합쳐진 것이다.

실전 코드

타입스크립트로 정리하면 이렇다.
typescript
async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  options: {
    maxRetries: number;
    baseMs: number;
    capMs: number;
  },
): Promise<T> {
  const { maxRetries, baseMs, capMs } = options;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries) throw error;

      // 지수 증가에 cap 적용
      const expDelay = Math.min(baseMs * 2 ** attempt, capMs);

      // Full Jitter
      const delay = Math.random() * expDelay;

      await new Promise((res) => setTimeout(res, delay));
    }
  }
  throw new Error("unreachable");
}
유의할 점은 다음과 같다.
  • cap (capMs): 지수는 빠르게 커지므로 상한이 없으면 곧 비현실적인 대기 시간이 됨
  • maxRetries: 무한 재시도는 의존 시스템이 영구 장애일 때 클라이언트도 함께 다운시킴
  • 재시도 가능한 에러만 재시도: 4xx 같은 클라이언트 에러는 재시도해도 동일하게 실패함. 5xx, 429, 네트워크 타임아웃처럼 다시 시도하면 성공할 가능성이 있는 에러만 재시도해야 함

백오프가 만능은 아니다

마지막으로 짚을 것이 있다. AWS의 시니어 엔지니어 Marc Brooker는 클라이언트 수가 사실상 무한대인 경우(예: 매일 한 번씩 방문하는 백만 명의 사용자) 백오프가 별 효과가 없다고 지적한다. 새로 들어오는 클라이언트는 다른 사람이 백오프하고 있다는 사실을 모르기 때문이다. 그의 핵심 주장은 다음과 같다.
The only way to deal with long-term overload is to reduce load.
(번역) 장기 과부하를 해결하는 유일한 방법은 부하를 줄이는 것이다.
또 재시도 자체가 부하를 증폭시킬 수 있다. 그래서 thundering herd를 다각도로 막기 위해 실전에서는 다음을 함께 사용한다.
  • 지수 백오프: 재시도 간격 분산
  • 지터: 동기화 깨기
  • cap과 max retries: 무한 대기/재시도 방지
  • circuit breaker: 의존 시스템에 장애가 발생했을 때 재시도 자체를 멈춤
  • 토큰 버킷: 재시도 폭발 제한
지수 백오프는 이 큰 그림 속의 작은 부분이다. 다만 그 작은 부분이 왜 하필 지수여야 하는지에는, 50년간 살아남을 만한 분명한 수학적 이유가 있다.

참고