들어가며
서버에 트래픽이 몰려서 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초 후... 즉, 시점이 옮겨갈 뿐, 결정론적이라는 점에서 고정 시간 대기와 다르지 않다.
무작위 대기 후 재시도
각 요청이 과 사이에서 무작위로 대기 시간을 고른다. 는 윈도우 크기, 즉 분산할 시간 범위다. 이제야 각 요청이 서로 다른 시점에 도착할 수 있다.
typescript
const windowMs = 1000; // 분산할 시간 범위
while (true) {
try {
await call();
break;
} catch {
await sleep(Math.random() * windowMs);
}
}
여기서부터가 본론이다. 를 얼마로 잡아야 할까?
분산에 필요한 공간은 만큼 크다
요청 100개를 0~10초 사이에 분산시킨다고 해보자. 평균적으로 1초당 10개다. 같은 순간에 두세 개가 겹치는 일이 잦다.
100개를 0~100초로 분산시키면 1초당 평균 1개지만, 여전히 같은 순간에 여러 요청이 종종 겹칠 수 있다.
물론 무작위로 분산시킨다고 해서 어떤 W로도 충돌 확률 0%는 보장하지 못한다. 운이 좋으면 100개의 슬롯에서도 100개가 겹치지 않을 수 있고, 운이 나쁘면 100만 슬롯에서도 여러 개가 겹칠 수 있다. 따라서 핵심은 충돌 확률을 충분히 낮게 만들려면 윈도우가 얼마나 커야 하느냐다.
핵심 관찰은 다음과 같다. 충돌은 두 요청 사이의 문제다. 한 개만 있으면 충돌은 일어나지 않는다. 충돌이 일어나려면 적어도 두 개가 같은 슬롯에 떨어져야 한다. 따라서 계산해야 할 단위는 개별 요청이 아니라 요청의 쌍이다.
개의 요청이 있을 때 가능한 쌍의 수를 세보자.
- 3개 → 3쌍
- 4개 → 6쌍
- 10개 → 45쌍
- 100개 → 4,950쌍
규칙은 서로 다른 개의 항목 중 한 쌍을 선택하는 조합의 수 , 대략 다. 요청은 100개인데 쌍은 5,000개에 가깝다. 요청 수가 두 배가 되면 쌍은 제곱에 비례하여 네 배가 된다.
이제 각 쌍이 같은 슬롯에 떨어질 확률은 다. 그러면 개 중 어떤 쌍이라도 충돌할 기댓값은 (쌍의 수) × (쌍당 충돌 확률), 즉:
충돌이 거의 일어나지 않으려면 이 값이 1보다 충분히 작아야 한다. 즉 가 정도는 되어야 한다.
이것이 그 유명한 23명만 모여도 같은 생일이 있을 확률이 50%를 넘는다는 생일 문제의 본질이다. 23명이면 쌍은 253개이고, 1년 365일과 거의 비슷한 규모다. 그래서 50% 확률로 누군가 겹친다. 사람 수가 아니라 쌍의 수를 365와 비교해야 직관이 맞는다.
100개라면 윈도우는 약 10,000 슬롯이다. 1,000도 아니고 만 단위다. 직관과 상당히 다르다.
보다 엄밀하게 따지면 정확한 상수까지 얻을 수 있다. 개 항목을 개 슬롯에 무작위로 넣을 때 어떤 쌍이라도 같은 슬롯에 들어갈 충돌 확률 를 정확히 따져보자.
개를 순차적으로 배치하자. 이미 개가 차지된 상태에서 다음 항목이 빈 슬롯을 고를 확률은 다 (일 때 첫 배치, 일 때 마지막 배치). 모두 곱하면 충돌 사건 의 여사건 (모두 서로 다른 슬롯에 들어감)의 확률은 다음과 같다:
이 곱은 닫힌 형태로 다루기 까다로워서 근사가 필요하다. 테일러 전개 에서 가 작으면 이후 항이 무시 가능하므로 가 성립한다. 가정 하에 가 작아서 이 근사가 잘 들어맞는다.
각 항을 지수로 바꾸면 성질에 의해 곱이 지수의 합으로 압축되고, 합 안에는 등차수열 가 등장한다.
지수 안에 등장한 는 본문에서 본 쌍의 수와 정확히 같다. 여사건 관계 에 의해:
이 확률을 50% 이하로 만들려면:
본문의 직관적 논리와 같은 결론에, 정확한 상수 까지 함께 도출된다.
이것이 첫 번째 핵심이다. 분산에 필요한 공간 은 요청 수 의 제곱에 비례한다.
윈도우를 키우는 두 가지 방식
문제는 처음에 충돌하는 요청이 몇 개인지 알 수 없다는 점이다. 그래서 충돌이 반복되면 윈도우를 키우는 적응적 방식을 사용한다.
100개가 충돌해서 윈도우를 10,000까지 키워야 한다고 했을 때, 두 가지 전략을 비교해보자.
선형 증가
매 라운드 윈도우를 1씩 늘린다. 10,000에 도달하려면 10,000 라운드가 필요하다.
그동안 클라이언트들은 계속 충돌하고, 서버는 계속 부하를 받는다. 사실상 영원히 끝나지 않는다.
지수 증가
매 라운드 윈도우를 2배로 늘릴 때 10,000에 도달하려면:
14 라운드면 충분하다.
10,000 라운드와 14 라운드를 비교했을 때 확실히 후자가 효율적이다. 이것이 지수 백오프의 첫 번째 장점이다.
그러면 너무 오래 기다리는 거 아닌가?
14번째 재시도면 마지막 대기 시간이 16,384이다. 그 앞에 13번을 모두 누적하면 더 심각해지지 않을까?
답은 의외다. 누적이 마지막 한 번의 약 2배에 그친다.
작은 예부터 직접 더해보자.
31 < 32. 다섯 번의 누적이 여섯 번째 한 번보다 작다. 즉 새로 추가되는 한 항이 이전 누적 전체보다 크다.
좀 더 길게 가도 같다.
255 < 256. 매번 두 배가 되니 새 항 하나가 이전 합보다 항상 크다. 이것이 등비수열의 핵심이다. 일반화하면:
따라서 14라운드 백오프의 실제 누적은 , 마지막 한 번 ()의 거의 정확히 2배다. "14번이나 기다린다"는 인상과 달리 마지막 한 번이 누적의 절반 이상을 차지하고, 이전 13번은 합쳐도 마지막 한 번만 못하다.
선형은 어떨까?
100번 재시도면 누적은 , 마지막 한 번()의 약 50배다. 1,000번이면 500배. 즉 라운드 수가 늘어날수록 누적/마지막 비율이 선형으로 함께 커진다.
지수 백오프의 두 번째 장점이 이것이다. 지수는 라운드가 늘어도 비율이 약 2로 고정되지만, 선형은 라운드 수에 비례해 비율이 커진다.
두 장점이 함께 작동한다
정리하면 지수 증가는 두 가지를 동시에 달성한다.
| 성질 | 선형 | 지수 |
|---|---|---|
| 윈도우를 까지 키우는 데 걸리는 라운드 | 라운드 | 라운드 |
| 누적 대기 시간 | 라운드 수의 제곱 | 마지막 대기의 약 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개가 동시에 충돌했고, 모두 같은 알고리즘에 따라 만큼 대기한 뒤 재시도한다고 하자.
- 1차 재시도: 모두 초 후 깨어남 → 다시 동시 충돌
- 2차 재시도: 모두 초 후 깨어남 → 다시 동시 충돌
- 3차 재시도: 모두 초 후 깨어남 → 다시 동시 충돌
윈도우는 점점 커지지만 클라이언트들은 그 안에 분산되지 않고 한 점에 모여 함께 움직인다. 결정론적 알고리즘은 모두 그렇다. 같은 입력에 같은 출력이다. 지수든 선형이든 일정 시간이든 결과는 동일하다.
실제로 분산을 만드는 것은 무작위성이다.
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의 논문은 충돌하면 범위에서 무작위로 슬롯을 고른다라고 명시했다. 초 후에 재시도한다가 아니라.
즉 무작위 선택은 알고리즘에 나중에 붙인 패치가 아니라 처음부터 본질의 일부였다. 지수 백오프 + 지터라는 흔한 표현이 다소 오해를 낳는 이유다. 둘은 분리될 수 없는 하나의 알고리즘이며, 각자 다른 역할을 한다.
- 무작위 선택: 클라이언트를 실제로 서로 다른 시점으로 분산시킴
- 윈도우의 지수 증가: 분산할 공간이 충돌 횟수에 맞춰 적절한 속도로 자라게 함
둘 중 하나만 있으면 어떻게 되는지 정리해보자.
| 무작위 선택 | 지수 증가 | 결과 |
|---|---|---|
| 없음 | 있음 | 동기화가 깨지지 않음. 윈도우만 의미 없이 커짐 |
| 있음 | 없음 (고정 ) | 이 크면 영원히 충돌 ( 공간이 안 만들어짐) |
| 있음 | 있음 | 우리가 원하는 것 |
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년간 살아남을 만한 분명한 수학적 이유가 있다.
참고
- Marc Brooker, Exponential Backoff And Jitter, AWS Architecture Blog, 2015 (2023 업데이트).
- Marc Brooker, Timeouts, retries, and backoff with jitter, Amazon Builders' Library.
- Marc Brooker, What is Backoff For?, 2022.
- Exponential backoff - Wikipedia.
- R. M. Metcalfe and D. R. Boggs, Ethernet: Distributed packet switching for local computer networks, Communications of the ACM, Vol. 19, No. 7, 1976. (지수 백오프의 기원)