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

반응형

외부 API를 사용한다는 것은 내가 통제할 수 없는 환경에 의존한다는 의미입니다. 즉, 외부 API의 상태 변화는 언제든지 우리 서비스의 결과에 직접적인 영향을 줄 수 있습니다. 만약 외부 API 호출 과정에서 문제가 발생한다면, 선택지는 크게 두 가지로 나뉩니다. 첫 번째는 아무것도 하지 않는 방법입니다. 외부 API가 실패하면 그대로 실패로 처리하고, 그 결과를 그대로 받아들이는 방식입니다. 이 접근은 겉보기에는 단순해 보이지만, 사실상 외부 API의 안정성을 그대로 우리 서비스의 안정성으로 끌어오는 설계입니다. 이 경우, 외부 API의 일시적인 장애나 네트워크 지연만으로도 요청 스레드가 장시간 점유되거나 처리 지연이 누적되거나 심각한 경우 내부 서비스까지 함께 불안정해질 수 있습니다. 물론 try-catch를 사용해 예외를 우회할 수는 있습니다. 하지만 이는 문제를 해결하는 것이 아니라 외부 API의 상태에 따라 서비스 결과가 달라지는 구조를 그대로 방치하는 것에 가깝습니다. 두 번째는 외부 API 호출 실패를 내부에서 한 번 더 제어하는 방법입니다. 외부 API가 일시적으로 실패했을 경우, 이를 그대로 실패로 확정하지 않고 제한된 조건 하에서 다시 시도(retry) 하는 방식입니다. 이 접근은 외부 API의 성공 여부를 보장하려는 시도가 아닙니다. 오히려 핵심은, 외부 시스템의 일시적인 불안정성이 내부 서비스 전체로 전파되는 것을 차단하는 것에 있습니다. 즉, 외부 API를 완전히 통제하려는 것이 아니라, 외부 실패가 내부 시스템에 미치는 영향을 통제하려는 설계입니다. 이러한 접근을 일반적으로 리트라이 전략(Retry Strategy) 이라고 부릅니다. 이 글에서는 외부 API 실패를 무조건 재시도하지 않는 이유 어떤 실패만 재시도 대상으로 판단했는지 리트라이를 어디까지 허용하고, 언제 포기하도록 설계했는지에 대해 정리하고, 제가 실제로 리트라이 전략을 어떻게 가져갔는지 그 과정을 기록하려고 합니다.

리트라이 전략의 목적은 무엇일까?

리트라이 전략의 목적은 외부 실패로부터 내부 시스템을 보호하는 것입니다.
그렇다면 외부 실패로부터 내부 시스템을 어떻게 보호할 수 있을까요?

외부 실패를 다루는 방식은 크게 두 가지로 나눌 수 있습니다.

  • 실패를 즉시 차단하는 방법
  • 제한적으로 재시도하는 방법

외부 실패를 차단하는 방법

차단은 외부 API의 실패를 더 이상 내부로 전파하지 않고, 빠르게 실패를 확정함으로써 내부 자원의 소모를 막는 방식입니다.

외부 시스템의 상태가 명확히 잘못되었거나, 재시도하더라도 결과가 달라지지 않는 경우에는
추가적인 시도를 하지 않고 즉시 실패로 처리하는 것이 오히려 안전합니다.

이 방식은 내부 스레드 점유, 불필요한 대기, 장애가 내부로 확산되는 것을 방지하는 데 효과적입니다.
이러한 실패를 코드로 명확히 표현하기 위해 차단 대상 예외를 별도로 정의했습니다.

public class RetryableException extends RuntimeException {
  public RetryableException(Throwable cause) {
    super(cause);
  }

  public RetryableException(String message) {
    super(message);
  }
}

이 예외는 입력 값 오류, 인증 실패, 비즈니스 규칙 위반처럼 재시도해도 결과가 달라지지 않는 실패를 표현합니다.

외부 실패를 재시도하는 방법

반면 재시도는 외부 시스템의 일시적인 불안정성을 고려한 접근입니다.

네트워크 지연이나 순간적인 서버 오류처럼 시간이 지나면 회복될 가능성이 있는 실패의 경우,
이를 한 번의 실패로 확정하지 않고 제한된 조건 하에서 한 번 더 시도함으로써 불필요한 실패를 줄일 수 있습니다.

다만 재시도는 무조건 허용되어서는 안 되며, 재시도 횟수와 대기 시간에 명확한 한계를 두는 것이 중요합니다.

이러한 실패를 표현하기 위해 재시도 대상 예외를 별도로 정의했습니다.

public class NonRetryableException extends RuntimeException {
  public NonRetryableException(Throwable cause) {
    super(cause);
  }

  public NonRetryableException(String message) {
    super(message);
  }
}

이 예외는 일시적인 네트워크 오류나 외부 시스템의 순간적인 장애처럼 재시도해볼 수 있는 실패를 표현합니다.

이것들을 어디에서 실행을 시키지?

이제 고민거리가 exception을 차단과 재시도를 구분을 지어서 만들었는데 생각해보니 어디에서 실행을 할지 결정하지 않았습니다.

public class RetryExecutor {
  private final int maxAttempts = 3;
  public <T> T execute(Supplier<T> action) {
    int attempt = 0;

    while (true) {
      try {
        return action.get();   // ← 여기 다시 실행됨
      } catch (RetryableException e) {
        attempt++;
        if (attempt >= maxAttempts) {
          throw e;           // 더 이상 못 버팀
        }
      } catch (NonRetryableException e) {
        throw e;               // 즉시 종료
      }
    }
  }
}

RetryExecutor는 재시도 실행 책임을 담당하는 컴포넌트입니다. 재시도 대상이 되는 로직을 Supplier로 전달받아 실행하며,
RetryableException이 발생한 경우에만 최대 3회까지 재실행을 시도합니다.
재시도 횟수를 3회로 제한한 이유는, 외부 시스템의 일시적인 장애를 흡수하되 무한 재시도로 인한 내부 자원 고갈을 방지하기 위함입니다.
또한 Supplier<T>를 사용한 이유는 재시도 대상이 특정 클래스나 반환 타입에 종속되지 않도록 하여, 다양한 외부 API 호출을 동일한 재시
도 메커니즘으로 감싸기 위함입니다.

Backoff는?

그렇다면 이것은 왜 필요할까요? 실패했을 때 즉시 재시도를 하면 안 되는 걸까요?
외부 API 호출 실패의 대부분은 영구적인 오류가 아니라 시간에 따른 일시적인 문제에서 발생합니다.
예를 들어 순간적인 네트워크 지연, GC 또는 스레드 스파이크, 짧은 서버 hiccup, 커넥션 재수립 과정 등이 대표적입니다.
이러한 문제들은 구조적인 결함이라기보다는, 잠시 시간이 지나면 자연스럽게 회복되는 경우가 대부분입니다.

이런 상황에서 실패 직후 즉시 재시도를 수행하면, 아직 회복되지 않은 동일한 상태에서 다시 요청을 보내는 것이기 때문에
동일한 실패가 반복될 가능성이 높습니다.

반대로, 재시도 사이에 짧은 대기 시간(backoff)을 두게 되면 외부 시스템이 스스로 회복할 수 있는 시간을 확보할 수 있고,
그 결과 재시도 시점에서는 성공 확률이 유의미하게 증가합니다.

즉, backoff는 단순히 요청을 지연시키기 위한 장치가 아니라,
재시도를 무작정 반복하는 행위가 아니라 회복을 전제로 한 시도로 바꾸기 위한 설계입니다. 외부 시스템에게는 회복할 시간을 주고,
이를 사용하는 우리 시스템 입장에서도 불필요한 실패를 줄여 전체 성공률을 높이는 효과를 가져옵니다.

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

코드는 간단하게 작성하였습니다. 그런데 InterrunptedException이거는 도대체 왜 하는걸까요?
InterruptedException은 재시도 실패가 아니라 스레드 중단 요청이기 때문에, retry 흐름에서 복구하지 않고 즉시 실행을 중단하며
interrupt 상태는 상위 계층으로 전달해야 합니다.

자 이제 사용해볼까요?

 return retryExecutor.execute(() -> {
      ... 생략
      try {
        return restTemplate.postForObject(url, entity, TossPaymentConfirmation.class);
      } catch (RestClientException e) {
        log.error("Toss 결제 확인 실패: paymentKey={}, orderId={}", paymentKey, orderId, e);
        throw new RetryableException(e);
      }
    });

외부 API 호출에 리트라이를 적용하면서 가장 먼저 마주친 문제는 재시도 대상 예외를 어디에서, 어떻게 관리할 것인가였습니다.

모든 RestClientException을 재시도 대상으로 처리하는 방식은 단순하지만 위험합니다. RestClientException에는 네트워크 오류처럼 재시도 가치가 있는 실패뿐 아니라, 요청 자체가 잘못된 경우(4xx)까지 포함되어 있기 때문입니다.

그래서 예외를 좀 더 명확하게 분리해 보았습니다.

 try {
    return restTemplate.postForObject(url, entity, TossPaymentConfirmation.class);
  } catch (HttpStatusCodeException e) {
    log.error("Toss 결제 확인 실패: paymentKey={}, orderId={}", paymentKey, orderId, e);
    if (e.getStatusCode().is4xxClientError()) {
      throw new NonRetryableException(e); // 재시도해도 의미 없는 실패 (4xx, validation, auth)
    }
    throw new RetryableException(e);
  } catch (ResourceAccessException e) {
    // 재시도해볼 가치가 있는 실패 (timeout, 5xx, network)
    throw new RetryableException(e);
  }
});

이 방식은 재시도 기준을 코드 레벨에서 명확히 표현할 수 있다는 장점이 있습니다.
요청이 잘못된 경우는 즉시 실패로 처리하고, 외부 시스템의 상태에 의존하는 오류만 재시도 대상으로 분리할 수 있기 때문입니다.

하지만 이 접근에는 또 다른 문제가 있습니다. 외부 통신이 늘어날수록 동일한 예외 분기 로직이 여러 Client에 반복된다는 점입니다.
특히 외부 API마다 재시도 정책이 달라지는 경우, 정책 변경이 곧 코드 수정으로 이어지면서 관리 비용이 급격히 증가할 가능성이 있습니다.

그렇다면, 정책을 만들어서 관리하면 괜찮지 않을까요?

외부 API 호출에 리트라이를 적용하면서 가장 먼저 마주친 문제는 재시도 대상 예외를 어디에서, 어떻게 관리할 것인가였습니다.
처음에는 RestTemplate 호출부에서 try-catch를 통해 직접 재시도 여부를 판단했습니다.

try {
  return restTemplate.postForObject(url, entity, TossPaymentConfirmation.class);
} catch (HttpStatusCodeException e) {
  log.error("Toss 결제 확인 실패", e);
  if (e.getStatusCode().is4xxClientError()) {
    throw new NonRetryableException(e); // 재시도 의미 없음
  }
  throw new RetryableException(e);
} catch (ResourceAccessException e) {
  // 타임아웃, 커넥션 문제
  throw new RetryableException(e);
}


이 방식은 재시도 기준을 코드 레벨에서 명확하게 표현할 수 있다는 장점이 있습니다.
요청 자체가 잘못된 경우(4xx)는 즉시 차단하고, 외부 시스템의 상태에 의존하는 오류만 재시도 대상으로 분리할 수 있기 때문입니다.

하지만 외부 통신이 늘어나기 시작하면서 문제가 드러났습니다. 외부 API마다 동일한 예외 분기 로직이 반복되기 시작했고,
만약 각 외부 API마다 재시도 정책이 달라진다면 정책 변경이 곧 여러 Client 코드 수정으로 이어질 수 있는 구조였습니다.
이는 확장성과 관리 측면에서 부담이 될 수 있다고 판단했습니다.

재시도 판단을 정책으로 분리하기

이 문제를 해결하기 위해 재시도 여부를 판단하는 책임을 개별 Client가 아닌 정책으로 분리했습니다.

public class DefaultRetryPolicy implements RetryPolicy {

  @Override
  public boolean retryable(Throwable e) {
    if (e instanceof ResourceAccessException) {
      return true; // timeout, connection issue
    }
    if (e instanceof HttpStatusCodeException httpEx) {
      return httpEx.getStatusCode().is5xxServerError();
    }
    return false;
  }
}

이제 Client는 더 이상 예외를 분기하지 않습니다. 외부 API 호출 중 발생한 예외를 그대로 던지고,
이 예외를 재시도할지 차단할지는 정책이 판단하도록 구조를 변경했습니다.

public <T> T execute(Supplier<T> action) {
  int attempt = 0;

  while (true) {
    try {
      return action.get();
    } catch (Throwable e) {
      if (!retryPolicy.retryable(e)) {
        throw new NonRetryableException(e);
      }
      attempt++;
      if (attempt >= maxAttempts) {
        throw new RetryableException(e);
      }
      sleep(backoffMillis);
    }
  }
}

현재는 DefaultPolicy만 존재하지만 외부 API마다 각각 Policy를 설정할 수 도 있습니다.

그렇다면, maxAttempts와 backoffMillis도 관리할 수 있지 않을까?

재시도 여부뿐만 아니라, 재시도를 몇 번까지 허용할지(maxAttempts), 그리고 재시도 사이에 얼마나 대기할지(backoffMillis) 역시
정책의 일부로 관리할 수 있지 않을까 하는 생각이 들었습니다. 처음에는 다음과 같이 정책 내부에 값을 직접 정의했습니다.

public class DefaultRetryPolicy implements RetryPolicy {
  
  ... 생략

  @Override
  public int maxAttempts() {
    return 3;
  }

  @Override
  public long backoffMillis() {
    return 100;
  }
}

이 방식은 단순하고 명확하지만, 재시도 강도를 코드에 고정하게 된다는 한계가 있습니다.
운영 환경이나 외부 API의 특성에 따라 값을 조정하고 싶어지면서 자연스럽게 yml로 관리하고 싶어졌습니다.

yml로 재시도 강도 분리하기

가장 먼저 떠올린 방식은 다음과 같습니다.

retry:
  default:
    max-attempts: 3
    backoff-millis: 100
@ConfigurationProperties(prefix = "retry.default")
public class RetryProperties {
  private int maxAttempts;
  private long backoffMillis;
}

이 방식도 동작에는 문제가 없습니다. 하지만 정책이 추가될 때마다 RetryProperties와 유사한 설정 클래스가
계속 늘어날 수 있다는 점이 마음에 걸렸습니다.

지금은 정책이 하나뿐이라 괜찮지만, 확장성을 고려하면 그리 좋은 선택은 아니라고 판단했습니다.

정책을 Map 구조로 관리하기

그래서 yml 구조를 다음과 같이 변경했습니다.

retry:
  policies:
    default:
      max-attempts: 3
      backoff-millis: 100

이제 설정은 "정책 단위"로 묶이게 됩니다. 이에 맞춰 @ConfigurationProperties 역시
특정 prefix가 아닌 전체 retry를 기준으로 바인딩하도록 변경했습니다.

@ConfigurationProperties(prefix = "retry")
@Getter
public class RetryProperties {
  private final Map<String, RetryProperty> policies = new HashMap<>();

이 구조에서 yml은 다음과 같이 해석됩니다.

"default" : {"max-attempts":3,"backoff-mills":100}

즉, 각 정책은 key(default)를 기준으로 설정 값 묶음(RetryProperty)에 매핑됩니다.

특정 정책을 선택해 사용하는 방식

이제 필요한 정책을 선택해서 사용할 수 있습니다.

public RetryPolicy retryPolicy(RetryProperties properties) {
    return new DefaultRetryPolicy(properties.getPolicies().get("default"));
}

RetryExecutor 역시 이전과 동일하게 정책을 주입받아 사용합니다.

public RetryExecutor retryExecutor(RetryPolicy retryPolicy) {
    return new RetryExecutor(retryPolicy);
}

이 방식의 장점은 다음과 같습니다.

  • 설정 클래스는 하나로 유지할 수 있고
  • 정책이 늘어나도 yml만 확장하면 되며
  • 실행 로직과 설정 구조가 깔끔하게 분리됩니다

마무리

이번 글에서는 외부 API 통신에서 재시도와 차단을 어떻게 설계할 수 있을지 고민하며, 리트라이 전략을 직접 구성해보았습니다. 리트라이 전략을 사용하는 핵심 이유는 단순히 한 번 더 요청하기 위해서가 아니라, 외부 API의 실패를 그대로 내부 서비스로 끌어들이지 않기 위해서라고 생각합니다. 외부 시스템의 일시적인 불안정성이 내부 스레드 점유, 처리 지연, 장애 확산으로 이어지지 않도록 실패의 성격을 구분하고, 내부에서 한 번 더 통제하는 것이 목적이었습니다. 특히 Supplier를 통해 재시도 대상을 추상화하면서, 외부 API 호출이라는 특정 구현에 종속되지 않고실행 자체를 재시도 단위로 다룰 수 있다는 점이 인상 깊었습니다. 이를 통해 다양한 외부 통신을 동일한 메커니즘으로 감쌀 수 있는 구조를 만들 수 있었습니다. 현재 backoff는 fixed 방식으로 구현되어 있습니다. 재시도의 목적이 외부 시스템의 짧은 지연을 흡수하는 데 있다고 판단했기 때문에 동작이 예측 가능하고 단순한 방식을 우선 선택했습니다. 다만, 상황에 따라 지수 백오프나정책별  backoff를 어떻게 확장할 수 있을지는 추후 더 고민해볼 만한 지점이라고 생각합니다.

마지막으로, 이번 설계를 통해 리트라이와 서킷 브레이커는 비슷해 보이지만 해결하려는 문제가 다르다는 점도 다시 한 번 인식하게 되었습니다. 리트라이는 실패를 흡수하기 위한 전략이고, 서킷 브레이커는 실패를 차단하기 위한 전략이라는 관점에서 두 접근을 어떻게 조합할 수 있을지도 이후에 다뤄보면 재미있을 것 같습니다.

반응형

댓글

Designed by JB FACTORY