ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • RestTemplate Retry와 Interceptor 동작원리 - RestTemplate Hands On 8
    Spring Framework/RestTemplate 2023. 9. 30. 00:01

    개요

    Retry와 Interceptor의 동작원리에 대해 알아보고자 합니다.

     

    Interceptor의 등록

    public RestTemplateBuilder additionalInterceptors(ClientHttpRequestInterceptor... interceptors) {
    		Assert.notNull(interceptors, "interceptors must not be null");
    		return additionalInterceptors(Arrays.asList(interceptors));
    	}

    additionalInterceptors 메서드를 통해 인터셉터가 추가됩니다.

    ClientHttpRequestInterceptor는 Spring 3.1부터 사용가능하며 해당 인터페이스를 구현하여 RestTemplate에 등록하고, Http Request를 인터셉트할 수 있습니다.

     

     

     

    Interceptor의 호출과정

    //Controller에서 retryRestTemplate의 getForEntity 호출
    retryRestTemplate.getForEntity(url, String::class.java)     
    
    //getForEntity의 마지막 반환전 execute 호출
    return nonNull(execute(url, HttpMethod.GET, requestCallback, responseExtractor, uriVariables));
    
    //execute 메서드에서 다시 doExecute 호출
    return doExecute(url, uriTemplate, method, requestCallback, responseExtractor);
    
    //doExecute 메서드에서 ClientHttpRequest를 만들어냄, ClientHttpRequest는 인터페이스이며
    //SimpleClientHttpRequestFactory에 의해 생성됨
    //생성된 request로 실제 execute 호출
    ClientHttpRequest request;
    request = createRequest(url, method);
    response = request.execute();
    
    //ClientHttpRequest인터페이스를 구현하는 AbstractClientHttpRequest 추상클래스의 execute가 호출됨
    ClientHttpResponse result = executeInternal(this.headers);
    
    //AbstractClientHttpRequest 추상클래스를 구현하는 AbstractBufferingClientHttpRequest에서 executeInternal 호출
    //호출 결과로 ClientHttpResponse를 반환
    ClientHttpResponse result = executeInternal(headers, bytes);
    
    
    //해당 메서드를 다시 AbstractBufferingClientHttpRequest클래스를 구현하는 InterceptingClientHttpRequest가 처리
    //InterceptingRequestExecution 클래스를 하나 만들어냄
    InterceptingRequestExecution requestExecution = new InterceptingRequestExecution();
    return requestExecution.execute(this, bufferedOutput);
    
    //InterceptingClientHttpRequest 클래스는 interceptors를 필드로 가지고 있음
    //intercetpor들이 존재하면 순회하면서 인터셉터를 호출!
    private final List<ClientHttpRequestInterceptor> interceptors;
    if (this.iterator.hasNext()) {
    	ClientHttpRequestInterceptor nextInterceptor = this.iterator.next();
    	return nextInterceptor.intercept(request, body, this);
    }

     

    Retry의 호출과정

    //3회 재시도
    val retryTemplate = RetryTemplate()
    retryTemplate.setRetryPolicy(SimpleRetryPolicy(3))
    
    //재시도 2초에 한번
    val fixedBackOffPolicy = FixedBackOffPolicy()
    fixedBackOffPolicy.backOffPeriod = 2000L
    retryTemplate.setBackOffPolicy(fixedBackOffPolicy)
    
    
    
    //RetryTemplate의 기본값
    private volatile BackOffPolicy backOffPolicy = new NoBackOffPolicy();
    private volatile RetryPolicy retryPolicy = new SimpleRetryPolicy(3);

    SimpleRetryPolicy와  FixedBackOffPolicy를 사용합니다.

    RetryTemplate의 기본값은 NoBackOffPolicy이며 해당 클래스의 doBackOff 메서드를 들어가 보면 아무것도 처리하지 않습니다.

     

    반면 FixedBackOffPolicy의 클래스의 doBackOff 메서드를 들어가보면 지정된 시간만큼 sleep 하는 것을 볼 수 있습니다.

    sleeper.sleep(this.backOffPeriod.get());

     

    RetryTemplate 로직분선

    //RetryTemplate에서 핵심으로 canRetry를 통해 반복호출이 가능한 경우에 반복호출을 수행합니다.
    while (canRetry(retryPolicy, context) && !context.isExhaustedOnly()){
    	try{
    		//성공시 반환
    		T result = retryCallback.doWithRetry(context);
    		return result;
    	} catch(e: Exception){
    		//발생한 예외 context에 등록
    		registerThrowable(retryPolicy, state, context, e);
    		//재처리가능한 예외라면 다시 재처리
    		if (canRetry(retryPolicy, context) && !context.isExhaustedOnly()) {
    			//지정된 시간만큼 대기
    			backOffPolicy.backOff(backOffContext);
    		}
    	}
    }

    SimpleRetry에서 canRetry를 구현하고 있습니다.

     

    canRetry

    	@Override
    	public boolean canRetry(RetryContext context) {
    		Throwable t = context.getLastThrowable();
    		boolean can = (t == null || retryForException(t)) && context.getRetryCount() < getMaxAttempts();
    		if (!can && t != null && !this.recoverableClassifier.classify(t)) {
    			context.setAttribute(RetryContext.NO_RECOVERY, true);
    		}
    		else {
    			context.removeAttribute(RetryContext.NO_RECOVERY);
    		}
    		return can;
    	}

    context에 등록된 가장 최근의 Throwable을 꺼내와서 다음과 같은 일을 수행합니다.

    • 에러가 발생하지 않아 null이면 false 반환
    • retryForException을 통해  재시도 가능한 예외인지 체크합니다.
    • 최대 재시도 횟수가 넘었다면 false 반환

     

    기본적으로 BinaryExceptionClassifier의 기본값으로 defaultClassfier가 사용되는데 Exception.Class를 처리합니다.

    public SimpleRetryPolicy(int maxAttempts) {
    	this(maxAttempts, BinaryExceptionClassifier.defaultClassifier());
    }
    
    public static BinaryExceptionClassifier defaultClassifier() {
    	// create new instance for each call due to mutability
    	return new BinaryExceptionClassifier(
    		Collections.<Class<? extends Throwable>, Boolean>singletonMap(Exception.class, true), false
            );
    }

     

    만약 Exception.class 대신 다른 클래스를 지정하는경우에는 SocketTimeoutException이 재시도 처리되지 않습니다.

    val retryableExceptions = BinaryExceptionClassifier(
        Collections.singletonMap(IllegalArgumentException::class.java, true), false
    )
    
    //3회 재시도
    val retryTemplate = RetryTemplate()
     retryTemplate.setRetryPolicy(SimpleRetryPolicy(3,retryableExceptions))

     

     

    만약 Timeout이 아니라 IllegalArgumentException을 반환한다면 재시도를 수행할까?

    재처리가 되지 않습니다.

    기존에 Timeout이 재시도되었던 이유는 Connection이 SimpleClientHttpResponse에 의해 반환되기 전에 Connection 예외가 발생해서 try-catch에 잡혔기 때문입니다.

     

    예를 들어 IllegalArgumentException은 result가 정상응답처리되었다고 판단하여 retry를 수행하지 않고 그대로 ClientHttpResponse를 반환하게 되고 해당 예외는 DeafultResponseErrorHandler가 처리해 버립니다.

     

    그러면 어떻게 해야 할까?

        @GetMapping("time-out-rest-template-retry-v2")
        fun timeOutSpringRetryV2(){
            retryRestTemplateV2.execute<ResponseEntity<String>, Throwable> { context ->
                restTemplate.getForEntity(url, String::class.java)
            }
        }
        
        @Bean
        fun retryRestTemplateV2(): RetryTemplate{
            val retryTemplate = RetryTemplate()
            retryTemplate.setRetryPolicy(SimpleRetryPolicy(3))
    
            //재시도 2초에 한번
            val fixedBackOffPolicy = FixedBackOffPolicy()
            fixedBackOffPolicy.backOffPeriod = 2000L
            retryTemplate.setBackOffPolicy(fixedBackOffPolicy)
    
            return retryTemplate
        }

    RetryTemplate을 정의하고 재시도를 원하는 곳에서는 해당 retryRestTemplateV2를 사용하면 된다!

    IllegalArgumentException, Timeout 모두 재시도가 수행된다

     

    마무리

    인터셉터를 활용하여 Retry를 수행해 보고, 한계점을 만나서 RetryRestTemplveV2를 등록하면서 재처리를 다시 구현해 보았습니다.

    개인적으로 디버깅은 조금 고통스럽고, 지루한 과정이지만 내부코드를 살펴보면서 동작에 대해 더 깊이 이해하게 됩니다.

    원래의 계획이라면 해당 챕터 이후 Circuit Braker 적용하여 재시도가 필요 없는 경우에는 더 우아하게 에러를 핸들링 하는 방법을 다루어보려다가 RestTemplate의 Hands On 시리지는 여기서 마무리하고자 합니다. (관심 있으신 분들은 시도해 보세요 ㅎ)

     

    급마무리하는 이유는 RestTemplate에 대한 흥미가 조금 떨어졌고(FeignClient를 주력으로 쓰기 때문), 실제로 Circuit Braker를 적용하는 날이 온다면 다시 정리해 보겠습니다.

    댓글

Designed by Tistory.