분산 시스템 환경을 위한 Exponential Backoff에 Jitter를 더한 Retry 전략

분산 시스템 환경을 위한 Exponential Backoff에 Jitter를 더한 Retry 전략

2025, Aug 10    

개요

분산 시스템에서는 서버, 네트워크, 소프트웨어, 운영자 실수 등 다양한 요인으로 인해 장애가 발생할 수 있으며, 완전 무결한 시스템 구축은 불가능합니다.

  • 시간 제한: 요청 대기 시간을 제한해 리소스 고갈을 방지하고, 적절한 시간 값을 설정하는 것이 핵심입니다. 지나치게 높거나 낮은 값은 각각 리소스 낭비와 불필요한 재시도 폭증을 초래할 수 있습니다.
  • 재시도: 부분적·일시적 장애에 효과적이지만, 무분별한 재시도는 부하를 악화시키고 복구를 지연시킬 수 있습니다. 멱등성을 보장하는 API 설계, 재시도 횟수 제한, 단일 레이어 재시도 전략 등이 필요합니다.
  • 백오프: 재시도 간 간격을 점진적으로 늘려(지수 백오프) 부하를 완화합니다. 최대 대기 시간 제한을 두고, 토큰 버킷 같은 스로틀링 기법을 활용해 재시도 폭주를 방지합니다.
  • 지터: 재시도 시점을 무작위로 분산시켜 대량의 요청이 한꺼번에 몰리는 현상을 방지합니다. 예약 작업이나 주기적인 워크로드에도 지터를 적용하면 서버 자원 사용을 고르게 분산시킬 수 있습니다.

결론적으로, 분산 시스템의 고가용성을 위해서는 시간 제한으로 장애의 확산을 차단하고, 재시도/백오프/지터를 적절히 조합해 부하를 제어하는 전략이 필요합니다.
특히 재시도는 신중하게 설계·운영해야 하며, 잘못된 재시도는 문제를 개선하기보다 악화시킬 수 있다는 점을 인지해야 합니다.

N명의 클라이언트 경쟁

첫 번째 시뮬레이션에서는 여러 클라이언트가 동시에 같은 자원을 요청하는 상황을 가정합니다. 경쟁이 일어나면 한 번에 오직 한 명의 클라이언트만 요청에 성공하고, 나머지는 실패합니다. 이 경우, 모든 클라이언트가 순서대로 성공하려면 라운드를 여러 번 반복해야 합니다.

예를 들어,

  • 클라이언트가 5명이라면, 첫 라운드에 1명이 성공하고 4명은 대기합니다.
  • 다음 라운드에 또 1명이 성공하고 3명이 남습니다.
  • 이런 식으로 매 라운드마다 한 명씩만 성공하므로, 5명이 모두 성공하려면 총 5라운드가 필요합니다.

라운드 수가 곧 완료 시간과 비례하므로, 클라이언트 수(N)가 증가할수록 완료 시간도 N에 비례해 선형적으로 증가하게 됩니다.

단순 재시도 방법 적용(N번 요청, 대기 없음)

async function retry(send: () => Promise<any>, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await send();
    } catch (err) {
      if (attempt === maxRetries) throw err;
    }
  }
}

1

문제는 실패한 클라이언트들도 매 라운드마다 계속 요청을 시도한다는 점입니다.

  • 첫 라운드: N명이 요청 → 1명 성공, N-1명 실패
  • 두 번째 라운드: 남은 N-1명이 요청 → 1명 성공, N-2명 실패
  • 세 번째 라운드: 남은 N-2명이 요청 → 1명 성공, N-3명 실패

…이런 식으로 마지막 1명이 성공할 때까지 계속됩니다.

2

결론은 위와 같이 총 작업량이 N²에 비례하게 증가합니다.

즉, 클라이언트 수가 늘어날수 록 시스템이 처리해야할 요청이 기하급수적으로 증가하게됩니다.
이 때문에 경쟁이 심한 환경에서는 단순히 시간이 오래 걸리는 것뿐만 아니라, 시스템 리소스 소비량도 훨씬 더 가파르게 증가합니다.

Exponential backoff 적용

const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

function backoff(attempt: number, initialDelayMs = 1000, maxDelayMs = 10000) {
  return Math.min(initialDelayMs * 2 ** attempt, maxDelayMs);
}

async function retryBackoff(send: () => Promise<any>, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await send();
    } catch (err) {
      if (attempt === maxRetries) throw err;
      await sleep(backoff(attempt));
    }
  }
}

처음에는 N명의 클라이언트가 동시에 달려듭니다. 한 번 시도에서 한 명만 성공하니, 다음 라운드에는 N-1명이 다시 달려듭니다. 이런 식으로 계속 경쟁하면 매번 많은 요청이 한꺼번에 몰려서 리소스 낭비가 심해집니다.

그래서 재시도 속도를 늦추는 방법이 필요합니다. 가장 흔한 방법이 제한 지수 백오프인데,

  • 실패할 때마다 대기 시간을 2배, 4배, 8배, … 이렇게 늘려가고
  • 너무 길어지지 않도록 최대 대기 시간(maxDelayMs)을 설정합니다.

결과적으로, 실패 직후에는 빨리 재시도하고, 여러 번 실패하면 점점 느리게 재시도해서 서버 부담을 줄입니다.

3

백오프를 적용하였지만 약간 도움이 되지만 문제를 해결하지는 못하는 것으로 나타났습니다. 클라이언트 작업량은 약간만 줄었습니다.

문제를 파악하는 가장 좋은 방법은 기하급수적으로 줄어드는 호출이 발생하는 시간을 살펴보는 것입니다.

4

Exponential backoff가 작동하고 있다는 것은 분명합니다. 호출 빈도가 점점 줄어들고 있습니다. 문제는 여전히 호출이 뭉쳐 있다는 것입니다.
매 라운드마다 경쟁하는 클라이언트 수를 줄이는 대신, “클라이언트가 경쟁하지 않는 시간대를 만들었습니다.”
네트워크 지연의 자연스러운 변동성으로 인해 약간의 분산이 발생했지만, 경합은 크게 줄어들지 않았습니다.

백오프 + 지터 적용

const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

function fullJitter(attempt: number, initialDelayMs = 1000, maxDelayMs = 10000) {
  const maxDelay = Math.min(initialDelayMs * 2 ** attempt, maxDelayMs);
  return Math.floor(Math.random() * maxDelay);
}

async function retryBackoffJitter(send: () => Promise<any>, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await send();
    } catch (err) {
      if (attempt === maxRetries) throw err;
      await sleep(fullJitter(attempt));
    }
  }
}

해결책은 백오프를 제거하는 것이 아니라 지터를 추가하는 것입니다.
하지만, 지터가 반직관적인 생각처럼 보일 수 있습니다만 무작위성을 추가하여 시스템 성능을 향상시키려는 것입니다.

위에 “클라이언트가 경쟁하지 않는 시간대를 만들었습니다.”에 보면 변동성(jitter)을 주어 그 간격을 채워 보완하는 개념이라고 생각하면 좋을 것 같습니다.(스파이크를 거의 일정한 속도로 분산)
아래의 시계열은 지터의 장점을 잘 보여줍니다. 지터를 추가하는 것은 sleep 함수를 약간 변경하는 것입니다.

5

시계열이 훨씬 좋아 보입니다. 갭이 사라졌고, 초기 급증 이후 거의 일정한 통화율을 보이고 있습니다. 총 통화량에도 큰 영향을 미쳤습니다.

6

경쟁 클라이언트가 100명인 경우, 호출 횟수를 절반 이상 줄였습니다. 또한 지터링 없는 지수 백오프 방식과 비교했을 때 완료 시간도 크게 단축되었습니다.

7

위 그래프에서 Exponential 방식의 Completion Time이 시간이 지날수록(클라이언트 수가 많아질수록) 급격히 길어지는 이유는 백오프 간격이 기하급수적으로 커지기 때문입니다.

풀어서 설명하면:

  • Exponential Backoff는 실패할 때마다 대기 시간을 2배, 4배, 8배, …로 늘립니다.
  • 클라이언트가 많을수록 경쟁이 치열해지고, 동시에 실패하는 요청 수도 많아집니다.
  • 각 클라이언트는 여러 번 실패를 거듭하면서 대기 시간이 급격히 길어집니다.
  • 이렇게 되면 모든 요청이 완료되기까지의 총 소요 시간(Completion Time)이 크게 늘어납니다.

반면 Full Jitter(Backoff + Full Jitter)는 매번 대기 시간을 0~최대값 사이에서 랜덤으로 뽑기 때문에,

  • 일부 클라이언트는 짧은 대기 후 재시도하게 되고
  • 요청들이 더 균등하게 분산되며
  • 전체 Completion Time이 짧게 유지됩니다.

None은 대기 없이 즉시 재시도하기 때문에 Completion Time 자체는 짧게 나올 수 있지만,
그만큼 서버와 네트워크 부하는 심하고(부하로 인한 잦은 클라이언트 에러를 마주할 가능성이 높음), 실제 운영 환경에서는 오히려 장애를 더 악화시킬 수 있습니다.

참고