웅글웅글
article thumbnail

들어가면서 지난 글에서 다루었던 외부 서비스 장애에 대응하는 방법 중 하나인 재시도 패턴을 현재 외부 서비스를 사용 중인 서비스에 적용시켜 보도록 하겠습니다.

 

현재 토스 페이먼츠를 사용하여 결제 기능을 구현했습니다. 이 과정에서 토스 퍼이먼츠 API를 불러와야 하는 상황이 발생하고 이는 토스 페이먼츠 API에서 장애가 발생할 시 장애가 전파될 위험이 있습니다. 그래서 서킷 브레이커를 적용하기로 결정하였지만 그전에 네트워크 이슈나 일시적인 오류로 인해 요청이 실패할 상황을 대비해 재시도 로직을 먼저 구현해 보겠습니다.


OpenFeign으로 데이터 요청

현재 코드를 보면 외부 API에 요청을 보내기 위해 OpenFeign을 사용하고 있습니다. 토스 페이먼츠 API를 요청하는 코드는 아래와 같습니다.

 

@FeignClient(
	name = "TossPaymentClient",
	url = "https://api.tosspayments.com/v1/payments"
)
public interface TossPaymentClient {

	@PostMapping(value = "/confirm")
	PaymentResponse confirmPayment(
		@RequestHeader("Idempotency-Key") String idempotencyKey,
		@RequestBody TossPaymentConfirmRequest request
	);

	@GetMapping(value = "/{paymentKey}")
	PaymentResponse fetchPaymentInfo(
		@PathVariable(name = "paymentKey") String paymentKey
	);

	@PostMapping(value = "/{paymentKey}/cancel")
	void refundPayment(
		@PathVariable(name = "paymentKey") String paymentKey,
		@RequestBody TossPaymentRefundRequest request
	);
}

 

필자는 OpenFeign을 사용하였지만 WebClientRestTemplate를 사용하여 외부 API 요청을 보내도 됩니다. 여기서 토스 페이먼츠 API를 이용하기 위해선 발급받은 시크릿 키를 활용하여 토큰을 같이 넣어주어야 하는데 이 과정은 인터셉터에서 자동으로 주입시켜 주었습니다.

 

@Component
public class PaymentAuthInterceptor implements RequestInterceptor {

	@Value("${toss.payments.secret-key}")
	private String secretKey;

	@Override
	public void apply(RequestTemplate template) {
		String authHeader = "Basic " + Base64.getEncoder().encodeToString((secretKey + ":").getBytes(UTF_8));
		template.header("Authorization", authHeader);
		template.header("Content-Type", "application/json");
	}
}

이제 재시도 로직을 추가해 보겠습니다.


Retry 설정

먼저 Retry 로직을 적용하기 전에 Retry 모듈에는 어떤 설정들이 있는지 알아보도록 하겠습니다.

 

Config property Default value Description
maxAttempts 3 최대 재시도 횟수(최초 호출도 포함)
waitDuration 500 [ms] 재시도할 때마다 기다리는 고정 시간
intervalFunction numOfAttempts -> waitDuration 실패했을 때 대기할 시간을 수정하는 함수 (기본적으로는 대기 시간이 일정)
intervalBiFunction (numOfAttempts, Either < throwable, result) -> waitDuration 실패했을 때 대기할 시간을 시도 횟수와 결과/예외에 따라 수정하는 함수
retryOnResultPredicate result -> false 결과를 보고 재시도해야 할지 평가하는 Predicate
retryOnExceptionRedicate throwable -> true 예외를 보고 재시도해야 할지 평가하는 Predicate
retryExceptions empty 실패로 기록해서 재시도할 Throwable 클래스 목록
igoreException empty 무시하고 재시도하지 않을 Throwable 클래스 목록
failAfterMaxAttempts false 설정한 maxAttpemts만큼 재시도하고 나서도 결과가 여전히 retryOnResultPredicate를 통과하지 못했을 때 MaxRetriesExceededException을 발생을 활성화 / 비활성화

 

그럼 이제 Resilience4J Retry 모듈을 사용하기 위해 의존성을 추가해 주겠습니다.

implementation 'io.github.resilience4j:resilience4j-retry'

그다음 Retry 설정을 해주도록 하겠습니다.

@Configuration
public class Resilience4jRetryConfig {

	public static final String TOSS_PAYMENT_RETRY = "tossPaymentRetry";

	@Bean(name = TOSS_PAYMENT_RETRY)
	public RetryRegistry retryConfig() {
		return RetryRegistry.of(RetryConfig.custom()
			.maxAttempts(3)
			.waitDuration(Duration.ofMillis(5000))
			.retryExceptions(FeignException.FeignServerException.class)
			.retryOnException(
				throwable -> !(throwable instanceof FeignException.FeignClientException)
					&& !(throwable instanceof RetryableException))
			.build());
	}
}
  • 재시도 횟수 3번 (첫 요청 + 2번 재시도)
  • 5초마다 재시도
  • FeignServerException 발생 시 재시도
  • FeignClientException, RetryableException 발생 시 재시도하지 않음

외부 서비스에 일시적으로 접근을 못하는 상황인 50x 에러 -> FeiginServerException의 경우에는 복구될 가능성이 있기 때문에 재시도를 진행하였습니다.

 

하지만 잘못된 정보 요청을 보내는 상황인 40x 에러 -> FeignClientException이거나 타임 아웃이 발생하는 경우 -> RetryableException일 때는 재시도하지 않고 실패 처리를 하도록 설정했습니다.

Exponential Backoff

이미 트래픽이 증가한 상태에서 일정 시간(n분 OR n초) 마다 재시도하면 네트워크를 더 혼잡하게 만들 것이며, 네트워크가 혼잡하기 때문에 대부분의 재시도가 실패할 가능성이 높습니다.

 

이런 이슈를 해결하기 위해 일정 횟수로 재시도를 제한하고 지수적으로 간격을 두어 재시도하는 전략을 사용하면 됩니다.

실제 토스 증권에서도 이런 이슈를 해결하기 위해 Exponential Backoff 전략을 사용하고 있습니다.

 

지수적 증가는 2분, 4분, 8분처럼 고정된 시간마다 재시도하는 것이 아닌 점차 시간을 늘려서 재시도하는 것을 의미합니다.

Exponential Backoff 전략을 사용하면 지수적 간격을 두어 재시도할 수 있습니다.

Exponential Backoff의 문제점

Exponential Backoff 전략을 사용하면 네트워크에 부담을 주는 문제를 완벽하게 해결할 수 있을까요? 결론은 아닙니다. 왜 그럴까요?

만약 100명이 동시에 요청을 보내게 되면 100명이 똑같은 시간에 재시도를 하게 될 것입니다. 이렇게 되면 또 동시에 많은 요청이 처리되기 때문에 네트워크에 부담이 가게 됩니다.

 

100명이 동시에 요청 시 재시도 시

그렇다면 어떻게 해결해야 할까요? 해결법은 간단합니다.

바로 Exponential Backoff에 randomness를 더해 같은 시간대에 재시도가 집중되는 것을 분산시킬 수 있습니다.

Exponential Backoff And Jitter

Exponential Backoff And Jitter 전략을 사용하면 Exponential Backoff에 randomness를 더할 수 있습니다.

 

재시도 시 randomness 추가

이렇게 되면 동시에 많은 요청이 몰려도 모든 요청이 재시도하는 시간이 다르기 때문에 요청이 몰리지 않고 분산처리 시킬 수 있습니다. 즉 네트워크 부담을 줄일 수 있는 것이죠.

 

이제 한 번 적용해 보겠습니다.

IntervalFunction.ofExponentialRandomBackoff(Duration.ofMillis(3000), 2)

Exponential Backoff And Jitter 전략을 사용하기 위해선 Retry 설정에서 intervalFunction에 IntervalFunction.ofExponentialRandomBackoff를 추가해 주면 됩니다.

 

첫 번째 파라미터에는 initialInterval(최초 재시도 시간), 두 번째 파라미터에는 multiplier(재시도 간격 증가 배율)를 넣어주면 됩니다.

쉽게 설명하면 첫 요청이 실패하면 두 번째 요청(첫 재시도)은 3초 뒤에 요청하게 되고 만약 두 번째 요청(첫 재시도)도 실패하게 되면 multiplier 파라미터를 이용하게 되는 것이죠.

 

return (attempt) -> {
            IntervalFunctionCompanion.checkAttempt((long)attempt);
            long interval = (Long)of(initialIntervalMillis, (x) -> {
                return (long)((double)x * multiplier);
            }).apply(attempt);
            return (long)IntervalFunctionCompanion.randomize((double)interval, randomizationFactor);
        };

 

재시도 시간이 위 코드와 같이 변하게 되는데 checkAttempt( ) 메소드를 살펴보게 되면 재시도 횟수가 세 번째 요청(두 번째 재시도)부터 적용되게 되어 있습니다. 즉 Retry 설정에서 재시도 횟수를 3 ~ 4회 이상 설정하는 것을 권장합니다.

 

아래는 최종적으로 결정한 Retry 설정입니다.

 

@Configuration
public class Resilience4jRetryConfig {

	public static final String TOSS_PAYMENT_RETRY = "tossPaymentRetry";

	@Bean(name = TOSS_PAYMENT_RETRY)
	public RetryRegistry retryConfig() {
		return RetryRegistry.of(RetryConfig.custom()
			.maxAttempts(4)
			.intervalFunction(IntervalFunction.ofExponentialRandomBackoff(Duration.ofMillis(3000), 2))
			.retryExceptions(FeignException.FeignServerException.class)
			.retryOnException(
				throwable -> !(throwable instanceof FeignException.FeignClientException)
					&& !(throwable instanceof RetryableException))
			.build());
	}
}

 

이제 설정한 Retry를 사용해 보도록 하겠습니다.

 

@FeignClient(
	name = "TossPaymentClient",
	url = "https://api.tosspayments.com/v1/payments"
)
public interface TossPaymentClient {

	@Retry(name = TOSS_PAYMENT_RETRY)
	@PostMapping(value = "/confirm")
	PaymentResponse confirmPayment(
		@RequestHeader("Idempotency-Key") String idempotencyKey,
		@RequestBody TossPaymentConfirmRequest request
	);

	@Retry(name = TOSS_PAYMENT_RETRY)
	@GetMapping(value = "/{paymentKey}")
	PaymentResponse fetchPaymentInfo(
		@PathVariable(name = "paymentKey") String paymentKey
	);

	@Retry(name = TOSS_PAYMENT_RETRY)
	@PostMapping(value = "/{paymentKey}/cancel")
	void refundPayment(
		@PathVariable(name = "paymentKey") String paymentKey,
		@RequestBody TossPaymentRefundRequest request
	);
}

 

이렇게 간단하게 어노테이션만으로도 적용할 수 있습니다.

마무리

이번 재시도 로직을 구현하면서 재시도 로직도 고려해야 하는 점이 많다는 것을 알게 되었습니다. 단순히 일정 시간마다 재시도하게 설정하면 되는 줄 알았는데 네트워크 부담까지 고려해야 하고 한 번에 재시도가 몰리는 상황도 고려해야 했습니다. 단순하게 보여도 생각해야 할 부분은 정말 많은 것 같습니다. 이제부터 단순해 보여도 무시하지 않는 자세를 가지도록 하겠습니다...

 

다음 글에서는 재시도를 해도 실패할 경우 장애 전파를 막기 위해 서킷 브레이커를 적용해 보도록 하겠습니다.