Retry 전략 - 수치는 어떻게 지정해야 할까

반응형

Retry 전략을 코드로 직접 구현해 보니, 자연스럽게 몇 가지 숫자들이 눈에 들어오기 시작했습니다. max-attempts, backoff-millis 같은 값들입니다. 처음에는 AI의 도움을 받아 무난한 값으로 설정했지만, 구현이 진행될수록 하나의 의문이 생겼습니다. 이 값들이 정말 모든 상황에 동일하게 적용되어도 괜찮을까? 외부 API의 특성은 모두 다릅니다. 어떤 API는 일시적인 네트워크 지연만 견디면 되고, 어떤 API는 장애가 길게 이어질 수도 있습니다. 그럼에도 불구하고 모든 외부 통신에 동일한 retry 횟수와 backoff 시간을 적용하는 것이 과연 효율적인 설계일지 의문이 들었습니다. 물론 테스트와 운영을 거치며 수치를 조정해 나갈 수는 있습니다. 하지만 그 이전에, 어떤 기준으로 이 수치들을 정해야 하는지에 대한 최소한의 설계 원칙은 필요하다고 느꼈습니다. 또 하나의 고민은 backoff 방식이었습니다. 재시도 사이의 대기 시간을 고정값(fixed)으로 두는 것이 맞을지, 아니면 점진적으로 늘어나는 방식(exponential)이 더 합리적인 선택일지 판단이 필요했습니다. 단순히 "많이 쓰니까"가 아니라, 왜 이 방식이 적합한지 설명할 수 있어야 한다고 생각했습니다. 이 글에서는 Retry 전략을 설계하면서 마주했던 이러한 고민들을 정리해 보고, max-attempts와 backoff 값은 어떤 관점에서 결정하는 것이 좋은지, 그리고 backoff는 고정으로 두는 것이 언제 적절한 선택인지에 대해 제 나름의 기준을 정리해보려 합니다.

 

외부 API 실패는 어떻게 다뤄야 할까? Retry 전략 설계기

외부 API를 사용한다는 것은 내가 통제할 수 없는 환경에 의존한다는 의미입니다. 즉, 외부 API의 상태 변화는 언제든지 우리 서비스의 결과에 직접적인 영향을 줄 수 있습니다. 만약 외부 API 호출

b-programmer.tistory.com

max-attempts

max-attempts는 재시도를 최대 몇 번까지 허용할 것인지를 의미합니다.
이 값은 단순히 "몇 번 더 시도할 것인가"의 문제가 아니라, 실패를 어디까지 감수할 것인가에 대한 설계 결정에 가깝습니다.

재시도 횟수를 과도하게 높게 설정하면 다음과 같은 문제가 발생할 수 있습니다.

  • 사용자의 대기 시간이 불필요하게 길어지고
  • 동일한 요청이 여러 번 수행되면서 중복 처리 위험이 증가하며
  • 외부 시스템 장애 상황에서는 내부 자원이 장시간 점유될 수 있습니다

반대로 재시도 횟수를 지나치게 낮게 설정하면, 일시적인 네트워크 오류나 짧은 외부 장애조차 흡수하지 못한 채 실패로 확정될 수 있습니다.

이 지점에서 가장 중요한 판단 기준은 중복 처리 가능성입니다.

default 값에 대한 기준

default 값은 가장 보수적이면서, 대부분의 외부 API에 무리 없이 적용할 수 있는 기준선이어야 합니다.

본 설계에서는 그 기준값을 3으로 두었습니다.

  • 일시적인 실패를 흡수할 수 있고
  • 과도한 대기나 중복 실행 위험을 만들지 않으며
  • 특별한 도메인 판단이 없을 경우 그대로 적용해도 되는 값

이라는 점에서, 3은 default로서 가장 균형 잡힌 선택이라고 판단했습니다.

중복 처리가 위험한 요청(결제, 주문 생성, 상태 변경)

결제, 주문 생성, 상태 변경과 같이 중복 실행이 곧 데이터 불일치나 금전적 문제로 이어질 수 있는 요청의 경우,
재시도 자체가 부작용을 만들 수 있습니다.

이러한 요청에는 max-attempts를 default보다 낮게(1~2) 설정하는 것이 안전하다고 판단했습니다.
특히 결제와 같이 돈이 오가는 요청의 경우에는, 재시도를 최소화하기 위해 1회로 제한하는 선택도 충분히 합리적이라고 생각합니다.

중복 처리가 문제가 되지 않는 요청 (조회, 소셜 로그인, 토큰 검증)

중복 실행이 결과에 큰 영향을 주지 않는 요청의 경우에는 상황이 다릅니다.
이 경우에는 실패를 빠르게 확정하기보다는,
성공률을 높이기 위해 default보다 높은 재시도 횟수(4~5회)를 허용하는 것이
오히려 사용자 경험(UX) 측면에서 더 나은 선택이 될 수 있습니다.

backoff-millis 

backoff-millis를 설명하기 전에, 먼저 backoff가 무엇인지부터 짚고 가야 한다고 생각합니다.
backoff는 외부 API 요청이 실패했을 때, 다음 재시도를 수행하기 전까지 대기하는 시간을 의미합니다.

이 값이 존재하는 이유는, 외부 API 호출 실패의 상당수가 영구적인 오류가 아니라 시간에 따라 회복될 가능성이 있는 문제이기 때문입니다.

예를 들면 다음과 같은 상황들입니다.

  • 순간적인 네트워크 지연
  • 외부 서버의 일시적인 부하
  • GC 또는 스레드 스파이크
  • 커넥션 재수립 과정

이러한 문제들은 구조적인 결함이라기보다는, 잠시 시간이 지나면 자연스럽게 회복되는 경우가 많습니다.

만약 실패 직후 즉시 재시도를 수행한다면, 아직 회복되지 않은 동일한 상태에서 다시 요청을 보내게 되고
그 결과 동일한 실패가 반복될 가능성이 높아집니다.

반대로 재시도 사이에 일정한 대기 시간(backoff)을 두게 되면, 외부 시스템이 회복할 수 있는 시간을 확보할 수 있고
그 결과 재시도 시점에서는 성공할 가능성이 높아집니다.

즉, backoff는 단순히 요청을 지연시키기 위한 장치가 아니라,
재시도를 '무작정 반복하는 행위'가 아닌 '회복을 전제로 한 시도'로 바꾸기 위한 설계 요소라고 볼 수 있습니다.

default 값에 대한 기준

default 값은 다음 조건을 만족해야 한다고 판단했습니다.

  • 외부 시스템이 회복할 시간을 제공할 수 있고
  • 사용자 체감 지연을 과도하게 늘리지 않으며
  • 대부분의 외부 HTTP API에 무리 없이 적용 가능한 값

이 기준을 바탕으로, default backoff는 100ms로 설정했습니다.

100ms는

  • 네트워크 지연이나 순간적인 부하가 해소되기에는 충분히 짧지 않고
  • 사용자 입장에서 느리다고 인식되기에는 아직 짧은 시간

이라는 점에서, 기본값으로 가장 무난한 선택이라고 판단했습니다.

backoff를 줄이는 경우

 

backoff를 줄인다는 것은, 재시도 사이의 대기 시간을 최소화하겠다는 의미입니다.
이는 다음과 같은 요청에 적합하다고 판단했습니다.

  • 빠른 응답이 중요한 요청
  • 재시도 자체가 거의 발생하지 않는 API

이러한 경우는 이미 안정성이 충분히 검증되었거나,
실패하더라도 즉시 다시 시도해볼 수 있을 만큼 가볍고 단순한 요청일 가능성이 높습니다.

또한 max-attempts를 1로 설정한 경우,
재시도 자체가 발생하지 않기 때문에 backoff는 사실상 의미를 가지지 않습니다.
이 경우 backoff는 설계상 존재하더라도, 실제 실행 흐름에서는 사용되지 않습니다.

  • 실패 시 빠르게 결과를 확정해야 하고
  • 사용자 경험이 무엇보다 중요한 요청이라면

짧은 backoff 혹은 backoff 자체를 고려하지 않는 선택도 충분히 합리적이라고 생각합니다.

backoff를 늘리는 경우

반대로 backoff를 늘린다는 것은,
재시도 자체보다 시스템 안정성을 더 우선시하겠다는 선택에 가깝습니다.

다음과 같은 상황에서는 의도적으로 backoff를 길게 가져갈 수 있습니다.

  • 외부 시스템 부하가 잦은 경우
  • 재시도 실패가 반복적으로 발생하는 API
  • 사용자 경험보다 시스템 안정성이 더 중요한 경우

특히 민감한 외부 API를 사용할 때는, 짧은 간격으로 재시도를 반복하는 것이 오히려 외부 시스템에 부담을 주거나
장애를 악화시키는 결과를 만들 수 있습니다.

이런 경우에는 backoff를 늘려 외부 시스템이 회복할 수 있는 시간을 충분히 확보하고,
내부 시스템 역시 불필요한 재시도로 인한 자원 소모를 줄이는 것이 더 안전한 선택이라고 판단했습니다.

또한 외부 API 호출 여부와 상관없이, 전체 시스템의 안정성이 가장 중요한 경우에도
backoff를 길게 가져가는 전략은 충분히 의미가 있다고 생각합니다.

그렇다면, backoff는 고정적으로 가져가는 게 좋을까?

초기 구현에서는 다음과 같이 고정된 backoff를 사용했습니다.

sleep(retryPolicy.backoffMillis());

... 중략

private void sleep(final long millis) {
  try {
     Thread.sleep(millis);
  } catch (InterruptedException ie) {
    Thread.currentThread().interrupt(); // 인터럽트 복구
    throw new RuntimeException("Retry interrupted", ie);
  }
}

즉, 재시도 간 대기 시간을 항상 동일하게 유지하는 방식입니다.
이렇게 설계한 이유는 단순했습니다. 가장 기본적인 형태의 retry를 먼저 완성하는 것이 목적이었기 때문입니다.
동작이 직관적이고, 이해하기 쉬우며, 디버깅 역시 수월합니다.

고정 backoff의 한계

동시에 많은 요청이 실패하는 경우

예를 들어, 외부 API에 일시적인 장애가 발생했다고 가정해 봅니다.
동시에 10개의 요청이 들어오고 모두 동일한 이유로 실패하고 backoff가 100ms로 고정되어 있다면

결과는 명확합니다. 10개의 요청이 모두 100ms 뒤에 다시 동시에 재시도됩니다.

이는 외부 시스템 입장에서 보면,

  • 부하가 회복되기도 전에
  • 동일한 요청이 다시 한번 몰려오는 상황이 됩니다.

즉, 실패 → 대기 → 재실패 패턴이 반복되며 장애를 완화하기는커녕 오히려 악화시킬 가능성이 생깁니다.

네트워크 상태는 항상 동일하지 않다

고정 backoff는 암묵적으로 이런 가정을 깔고 있습니다.
외부 시스템은 일정한 시간만 지나면 회복된다

하지만 현실의 네트워크와 외부 시스템은 그렇지 않습니다.
순간적인 멈춘 현상일 수도 있고 수백 ms 단위의 지연일 수도 있고 짧은 장애가 연속으로 발생할 수도 있습니다

이런 상황에서 항상 동일한 대기 시간으로 재시도하는 것은 회복 타이밍을 전혀 고려하지 않는 재시도에 가깝습니다.

이 두 가지 이유로 인해, backoff를 고정 값이 아니라 동적으로 계산하는 방식으로 변경하기로 했습니다.

사실 현재 구조에서는 backoff 값을 요청 상황에 따라 즉각적으로 조정하기는 어렵습니다.
retry 로직은 이미 실패 이후에 실행되며, 외부 시스템의 상태를 실시간으로 판단할 수 없기 때문입니다.
즉, "지금은 더 기다려야 할지, 바로 다시 시도해도 될지"를 정확히 알 수 있는 정보가 없습니다.

그렇다면 어떻게 해야 할까요?

이 문제를 해결하는 방법은 완벽하게 맞추는 것이 아니라, 같은 시간에 다시 시도하지 않도록 만드는 것입니다.
이를 가능하게 하는 두 가지 개념이 있습니다.

jitter  – 재시도 타이밍을 분산시키기

jitter는 원래 네트워크 용어로, 데이터 패킷이 전송될 때 지연 시간이 일정하지 않고 변동하는 현상을 의미합니다.
하지만 retry 전략에서 말하는 jitter는, 이 현상을 이용하는 개념에 가깝습니다.
즉, 재시도 시점에 의도적으로 랜덤 한 지연을 추가하여 외부 API로 요청이 나가는 시간을 서로 다르게 만드는 방식입니다.
다시 말해, 모든 요청을 정확히 같은 시간에 다시 보내지 않기 위해 일부러 타이밍을 어긋나게 만드는 장치라고 이해하면 됩니다.

코드에 적용해 봅시다.

public long nextBackoffMillis() {
  long base = property.backoffMillis();
  double ratio = property.jitterRatio();

  if (ratio <= 0) {
    return base;
  }

  long bound = Math.max(1, (long) (base * ratio));
  long jitter = ThreadLocalRandom.current().nextLong(0, bound + 1);
  return base + jitter;
}

jitter비율은 jitter를 얼마나 흔들 것인지를 비율로 표현합니다. 

 

  • 0.2 → 최대 20%까지 랜덤 증가
  • 0.5 → 최대 50%까지 랜덤 증가

ratio가 0.2이고 기본이 100ms라면 100ms ~ 120ms으로 랜덤 한 값이 부여가 됩니다.

그렇다면 jitter는 어떤 기준으로 설정해야 할까?

jitter는 재시도 시점에 얼마나 큰 변화를 허용할 것인가를 결정하는 값입니다.
jitter 비율이 커질수록, 각 재시도의 대기 시간은 더 넓은 범위로 흩어지게 됩니다.

여기서 중요한 점은, jitter가 외부 API의 안정성을 표현하는 값은 아니라는 점입니다.

jitter는 다음 질문에 대한 우리 쪽의 판단에 가깝습니다. 이 API에 대해, 동시에 다시 시도하는 상황을 얼마나 적극적으로 피하고 싶은가?

변화량의 크기는 무엇을 의미하는가

jitter의 변화량을 이렇게 해석하는 것이 더 정확합니다.

변화량이 작은 경우 (낮은 jitter)

  • 재시도 타이밍을 약간만 분산시킨다
  • 동시에 재시도할 가능성을 완전히 없애지는 않지만, 최소한의 완화만 제공한다

이 선택은 보통 다음과 같은 상황에 적합합니다.

  • 외부 API가 비교적 안정적이라고 판단되는 경우
  • 재시도 자체가 자주 발생하지 않는 요청
  • 빠른 응답이 중요한 요청

즉, 외부 시스템을 기본적으로 신뢰하지만, 최악의 상황만 피하고 싶은 경우에 해당합니다.

변화량이 큰 경우 (높은 jitter)

  • 재시도 타이밍을 적극적으로 분산시킨다
  • 동일한 실패 요청들이 거의 동시에 다시 실행되는 상황을 최대한 피한다

이 선택은 다음과 같은 경우에 적합합니다.

  • 외부 API 장애가 반복적으로 발생한 경험이 있는 경우
  • 동시 재시도가 외부 시스템에 부담이 될 수 있는 경우
  • 실패 상황에서 안정성을 최우선으로 가져가야 하는 요청

여기서 중요한 점은, 변화량이 크다는 것은 외부 API가 불안정하다는 단정이 아니라
우리가 이 API를 불안정하다고 가정하고 방어적으로 접근하겠다는 의미입니다.


하지만 backoff를 동적으로 만들었다고 해서 모든 문제가 해결되는 것은 아니었습니다.
여전히 마음에 걸리는 지점이 하나 있었습니다.

만약 외부 시스템이 하필 그 순간에 아주 조금만 더 시간이 필요했던 경우라면 어떨까요?
예를 들어, 외부 API는 약 110ms 후에 회복되는데 base backoff가 100ms이고 jitter가 적용되어도 최대 대기 시간이 105ms라면
결과는 이렇습니다. 실제로는 10ms만 더 기다리면 성공했을 요청이, 재시도 타이밍이 조금 빨랐다는 이유로 다시 실패합니다.
이 경우 retry는 실패를 "흡수"하지 못하고, 오히려 회복 직전의 타이밍을 계속해서 비껴가는 패턴을 만들 수 있습니다.
jitter는 동시 재시도를 분산시켜 줄 뿐, 충분히 오래 기다려 주는 것까지 보장하지는 않습니다.
backoff 자체가 일정 범위 안에서만 흔들린다면
외부 시스템의 회복 시간이 그 범위를 초과하는 순간, retry는 여전히 실패할 수밖에 없다는 뜻입니다.

그래서 필요한 것이 Exponential Backoff

이 방식은 재시도가 반복될수록 대기 시간을 지수적으로 증가시키는 전략이기 때문에 이런 이름이 붙었습니다.
즉, backoff를 매번 동일하게 유지하는 것이 아니라 재시도 횟수(attempt)에 따라 점점 더 오래 기다리게 만드는 방식입니다.

예를 들어,

  • 1번째 실패 → 100ms 대기
  • 2번째 실패 → 200ms 대기
  • 3번째 실패 → 400ms 대기
  • 4번째 실패 → 800ms 대기

처럼, 재시도 횟수가 증가할수록 대기 시간도 함께 커집니다.

이 접근이 의미를 가지는 이유는 단순합니다.
실패가 계속된다는 것은 외부 시스템이 아직 회복되지 않았을 가능성이 높다는 신호이기 때문입니다.

즉, Exponential Backoff는 지금은 조금만 기다리면 될 수도 있다는 가정에서 출발하지만,
실패가 반복될수록 점점 더 보수적으로 접근하도록 만드는 전략이라고 볼 수 있습니다.

앞서 언급한 jitter가 재시도 타이밍을 가로로 흩트리는 역할을 한다면,
Exponential Backoff는 재시도 간격을 세로로 늘려주는 역할을 담당합니다.

그래서 실제로는 두 전략을 함께 사용하는 경우가 많습니다.

Exponential Backoff + Jitter

이 조합을 통해

  • 동시에 다시 시도하는 문제를 완화하고
  • 회복에 시간이 필요한 상황도 흡수할 수 있게 됩니다.

일단 코드로 확인해 봅시다.

public long nextBackoffMillis(int attempt) {
    int safeShift = Math.min(attempt - 1, 62);
    long exponential = property.backoffMillis() * (1L << safeShift);

    // 상한선 적용
    long capped = Math.min(exponential, property.maxBackoffMillis());

    // 지터 적용
    if (property.jitterRatio() <= 0) {
      return capped;
    }

    long jitterBound = (long) (capped * property.jitterRatio());
    long jitter = ThreadLocalRandom.current().nextLong(0, jitterBound + 1);

    return capped - jitter;
}

구현 방식은 여러 가지가 있을 수 있지만, Exponential Backoff를 설계할 때 공통적으로 지켜야 할 원칙은 비교적 명확합니다.
그 핵심이 바로 상한선(max backoff)입니다.

상한선의 존재 이유

long capped = Math.min(exponential, property.maxBackoffMillis());

이 줄이 전체 설계에서 가장 중요합니다.

Exponential Backoff는 말 그대로 지수 증가이기 때문에 상한선이 없으면 backoff는 매우 빠르게 비현실적인 값이 됩니다.

예를 들어 base가 100ms라면,

  • attempt 6 → 3.2초
  • attempt 8 → 12.8초
  • attempt 10 → 51.2초
  • attempt 12 → 수 분 단위

이렇게 되면 retry는 더 이상 회복을 기다리는 전략이 아니라 사실상 요청을 포기하는 것과 다름없는 상태가 됩니다.

그래서 Exponential Backoff에는 반드시 최대 대기 시간이 필요합니다.
maxBackoffMillis는 이 요청에 대해 우리가 감내할 수 있는 최대 대기 시간을 명시하는 값입니다.

default 값으로는 보통 1~5초 사이를 선택하는 경우가 많다고 합니다. 저는 참고로 2s정도를 설정하였습니다.

Exponential Backoff는 retry 횟수가 늘어날수록 대기 시간을 빠르게 키웁니다.

하지만 maxBackoffMillis가 너무 크면 다음 문제가 발생합니다.

  • 요청 스레드가 너무 오래 점유된다
  • 사용자는 멈춘 것처럼 느낀다
  • 장애 상황에서 시스템 전체 처리량이 급격히 떨어진다

반대로 너무 작으면,

  • 회복 직전의 타이밍을 흡수하지 못하고
  • retry 전략의 의미가 사라집니다

그래서 maxBackoffMillis는 보통 이렇게 결정됩니다.

지수 증가

그리고 코드를 다시 보시면 지수 증가 방식이 뭔가 이상하다는 것을 알 수 있습니다.

int safeShift = Math.min(attempt - 1, 62);
long exponential = property.backoffMillis() * (1L << safeShift);

처음 보면 이런 의문이 듭니다.

  • 왜 Math.min(attempt - 1, 62) 인가?
  • 왜 굳이 1L << safeShift 같은 비트 연산을 쓰는가?
  • 그냥 Math.pow(2, attempt) 쓰면 안 되나?

이 코드는 지수 증가를 안전하게 만들기 위한 방어적 구현입니다.
코드에서 지수 증가를 담당하는 부분은 바로 아래입니다.

1L << safeShift

이 표현은 수학적으로 2^safeShift와 동일한 의미를 가집니다.
이 방식을 사용한 이유는 Math.pow()를 사용하는 것보다 여러 면에서 유리하기 때문입니다.

먼저 Math.pow()는 double 타입을 반환합니다. 부동소수점 연산 특성상 정밀도 손실 가능성이 존재하고,
결국 다시 long으로 캐스팅해야 하는 문제가 발생합니다.

retry backoff처럼 시간(ms)을 다루는 로직에서는 부동소수점 연산보다 정수 기반 연산이 더 안전하다고 판단했습니다.
또한 비트 시프트 연산은 성능적으로도 가볍고, 의도 역시 명확합니다. 그래서 long 기반의 비트 시프트 연산을 사용했습니다.

그렇다면 다음과 같은 의문이 생깁니다.

Math.min(attempt - 1, 62);

왜 safeShift를 이렇게 제한했을까요?
만약 이 코드가 없다고 가정해 보겠습니다.

핵심은 바로 62라는 숫자에 있습니다. 자바의 long 타입은 총 64비트로 구성되어 있습니다.

  • 이 중 1비트는 부호 비트
  • 나머지 63비트가 값 표현용 비트입니다

비트 시프트 연산에서,

1L << 62

까지는 양수 범위에서 안전하게 표현할 수 있습니다.
하지만,

1L << 63

이 되는 순간, 1이 부호 비트로 이동하게 되고
결과는 음수가 됩니다.
즉, safeShift가 63 이상이 되는 순간 지수 증가 계산 결과는 의미 없는 값으로 변해버립니다.

문제는 여기서 끝나지 않습니다. 이 값은 이후 backoff 계산에 그대로 사용되고, 결국 Thread.sleep() 같은 코드로 전달될 수 있습니다.
retry 로직은 이미 실패 상황에서 실행되는 코드이기 때문에, 이 시점에서 숫자 overflow가 발생하면
"실패를 처리하다가 또 다른 실패를 만드는 코드"가 되어버립니다.

그래서 safeShift를 다음과 같이 제한했습니다.

Math.min(attempt - 1, 62);

이 코드는 지수 증가 자체를 막기 위한 것이 아니라, long 타입이 표현할 수 있는 범위 안에서만 지수 증가를 허용하기 위한 안전장치입니다.

결론

월요일에 retry 전략을 학습하면서, 정작 max-attempt와 backoff를 어떤 기준으로 설정해야 하는지에 대해서는 깊게 고민하지 않았다는 사실을 깨달았습니다. 그러다 보니 자연스럽게 이런 질문이 생겼습니다. backoff는 고정 값으로 두는 게 맞을까, 아니면 동적으로 가져가는 게 맞을까? 외부 API와의 통신, 네트워크 환경, 장애 발생 패턴을 함께 떠올려보니 고정된 backoff보다는 외부 환경을 고려해 동적으로 동작하는 방식이 더 합리적이라고 판단하게 되었습니다. 하지만 여기서 또 하나의 고민이 생겼습니다. 이 방식은 과연 신뢰할 수 있을까라는 의문이었습니다. 이 고민을 따라가다 보니, 실패가 반복될수록 재시도 간격을 점진적으로 늘리는 전략인 exponential Backoff까지 자연스럽게 학습하게 되었습니다. 처음에는 단순히 retry를 구현하는 것이 목표였지만, 결과적으로는 재시도를 어떻게 ‘통제’할 것인가에 대한 고민으로 확장된 것 같습니다. 생각보다 오래 걸렸지만, 그만큼 retry 전략을 숫자 설정이 아닌 설계의 문제로 바라볼 수 있게 된 시간이었다고 생각합니다.

반응형

댓글

Designed by JB FACTORY