본문 바로가기
BackEnd/Project

[PharmNav] Ch05. Spring retry

by 개발 Blog 2024. 9. 3.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

Spring Retry란?

 https://github.com/spring-projects/spring-retry

Spring Retry는 Spring 프레임워크에서 제공하는 라이브러리로, 네트워크 통신이나 외부 API 호출 시 발생할 수 있는 일시적인 오류에 대해 자동으로 재시도 로직을 처리할 수 있게 해 준다. 이를 통해 개발자는 복잡한 재시도 로직을 직접 구현하지 않고도, 손쉽게 애플리케이션의 안정성을 높일 수 있다. Spring Retry는 설정이 간편하고, 다양한 재시도 정책 및 복구 전략을 제공해 실무에서 유용하게 사용된다.

 

실무에서 스마트하게 재처리하는 방법

실무에서는 시스템의 안정성을 높이기 위해 네트워크나 외부 API 호출 실패 시 어떻게 스마트하게 재처리할 것인가에 대해 많은 고민이 필요하다. 단순히 실패 시 재시도하는 것만으로는 충분하지 않다. 만약 재시도 자체가 모두 실패하게 되면 오히려 시스템 자원과 네트워크 리소스의 낭비로 이어질 수 있다.

예를 들어, 외부 카카오 API를 사용하는 시스템에서 일시적인 오류가 발생했을 때, 이를 바로 재처리하지 않고 약간의 딜레이를 준 후에 재시도하는 방법을 적용하면, 모두 실패하는 상황을 줄일 수 있다. 이는 일시적인 네트워크 장애나 API 서버의 부하로 인한 문제를 해결하는 데 매우 효과적이다.

 

Spring Retry를 사용하는 이유

이러한 시스템 안정성을 높이기 위해 Spring Retry 라이브러리를 사용할 것이다.

재처리를 할 때는 다음 세 가지를 주로 고민해야 한다.

  1. 재시도를 몇 번 실행할 것인가?
    재시도 횟수를 설정해, 불필요한 재시도를 줄이고 효율적으로 처리할 수 있도록 해야 한다.
  2. 재시도 전 지연 시간을 얼마나 줄 것인가?
    재시도 전 일정 시간 대기함으로써, 네트워크나 서버의 일시적 문제를 해결할 수 있다. 이때 Backoff 정책을 활용해 지연 시간을 설정할 수 있다.
  3. 재시도를 모두 실패했을 때 어떻게 처리할 것인가?
    모든 재시도가 실패할 경우, 이를 어떻게 처리할지에 대한 복구 전략이 필요하다. 이를 통해 시스템의 안정성을 유지할 수 있다.

이러한 고민을 바탕으로, 우리는 Spring Retry의 어노테이션을 활용해 쉽게 재시도 로직을 구현할 수 있다. 이를 통해 시스템의 복잡성을 줄이고, 네트워크 통신 및 외부 API 호출의 안정성을 높일 수 있다.

 

Spring Retry 실습

Spring Retry를 이용해 API 요청 실패 시 재시도를 자동으로 처리하는 방법을 실습한다. 이는 네트워크 장애나 일시적인 오류로 인해 API 요청이 실패했을 때, 자동으로 재시도하여 안정적인 서비스를 제공하는 데 유용하다.

 

1. Spring Retry 의존성 추가

먼저, build.gradle 파일에 Spring Retry와 테스트를 위한 mockWebServer 의존성을 추가한다.

// spring retry
implementation 'org.springframework.retry:spring-retry'

// mockWebServer
testImplementation('com.squareup.okhttp3:okhttp:4.11.0')
testImplementation('com.squareup.okhttp3:mockwebserver:4.11.0')

 

2. Retry 설정 클래스 생성

Spring Retry를 활성화하기 위해 @EnableRetry 어노테이션을 사용한 설정 클래스를 작성한다. RetryTemplate를 Bean으로 등록할 수도 있지만, 기본 설정을 활용하기 위해 주석 처리하였다.

package com.example.phamnav.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.retry.support.RetryTemplate;

@EnableRetry
@Configuration
public class RetryConfig {

//    @Bean
//    public RetryTemplate retryTemplate() {
//        return new RetryTemplate();
//    }
}

 

3. KakaoAddressSearchService에 재시도 로직 추가

Kakao API 호출 서비스에 @Retryable과 @Recover 어노테이션을 추가하여, API 호출 실패 시 재시도하고, 모든 재시도가 실패했을 경우 복구 로직을 처리하도록 한다.

package com.example.phamnav.api.service;

import com.example.phamnav.api.dto.KakaoApiResponseDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.util.ObjectUtils;
import org.springframework.web.client.RestTemplate;

import java.net.URI;

@Slf4j
@Service
@RequiredArgsConstructor
public class KakaoAddressSearchService {

    private final RestTemplate restTemplate;
    private final KakaoUriBuilderService kakaoUriBuilderService;

    @Value("${kakao.rest.api.key}")
    private String kakaoRestApiKey;
	
    //추가
    @Retryable(
            value = {RuntimeException.class},
            maxAttempts = 2,
            backoff = @Backoff(delay = 2000)
    )
    public KakaoApiResponseDto requestAddressSearch(String address) {

        if(ObjectUtils.isEmpty(address)) return null;

        URI uri = kakaoUriBuilderService.buildUriByAddressSearch(address);

        HttpHeaders headers = new HttpHeaders();
        headers.set(HttpHeaders.AUTHORIZATION, "KakaoAK " + kakaoRestApiKey);
        HttpEntity httpEntity = new HttpEntity(headers);

        // kakao api 호출
        return restTemplate.exchange(uri, HttpMethod.GET, httpEntity, KakaoApiResponseDto.class).getBody();
    }
    
	//추가
    @Recover
    public KakaoApiResponseDto recover(RuntimeException e, String address) {
        log.error("All the retries failed. address: {}, error : {}", address, e.getMessage());
        return null;
    }
}

@Retryable 애노테이션

  • value: 어떤 예외가 발생했을 때 재시도를 할 것인지를 지정한다. 여기서는 RuntimeException이 발생하면 재시도하도록 설정했다.
  • maxAttempts: 재시도를 몇 번 할 것인지를 지정한다. 이 예제에서는 2번 재시도를 설정했으며, 즉 최초 호출을 포함해 총 3번의 시도를 하게 된다.
  • backoff: 재시도 간의 대기 시간을 설정한다. @Backoff 애노테이션을 사용해 대기 시간을 설정할 수 있으며, 여기서는 2초(2000ms)로 설정했다.

이 설정을 통해 requestAddressSearch 메서드는 처음 호출이 실패한 경우, 최대 2번 더 재시도한다. 재시도 간에는 2초의 대기 시간이 있다.

 

@Recover 애노테이션

  • @Recover 애노테이션이 적용된 메서드는 모든 재시도가 실패했을 때 호출된다. 즉, @Retryable 메서드가 설정된 횟수만큼 재시도한 후에도 예외가 발생하면 이 메서드가 실행된다.
  • @Recover 메서드는 @Retryable 메서드와 동일한 반환 타입을 가지며, 첫 번째 인자는 발생한 예외 타입이어야 한다. 이후 인자들은 @Retryable 메서드의 인자들과 일치해야 한다.

recover 메서드는 모든 재시도가 실패했을 때, 로그에 에러 메시지를 기록하고 null을 반환하는 역할을 한다. 이렇게 함으로써 재시도가 실패한 경우에 대한 처리를 할 수 있다.

 

4. 테스트 코드 작성

mockWebServer를 사용해 Kakao API 호출이 실패할 상황을 시뮬레이션하고, 재시도 로직이 정상적으로 작동하는지 테스트한다.

package com.example.phamnav.api.service

import com.example.phamnav.AbstractIntegrationContainerBaseTest
import com.example.phamnav.api.dto.DocumentDto
import com.example.phamnav.api.dto.KakaoApiResponseDto
import com.example.phamnav.api.dto.MetaDto
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.spockframework.spring.SpringBean
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper

class KakaoAddressSearchServiceRetryTest extends AbstractIntegrationContainerBaseTest {

    @Autowired
    private KakaoAddressSearchService kakaoAddressSearchService

    @SpringBean
    private KakaoUriBuilderService kakaoUriBuilderService = Mock()

    private MockWebServer mockWebServer

    private ObjectMapper mapper = new ObjectMapper()

    private String inputAddress = "서울 성북구 종암로 10길"

    def setup() {
        mockWebServer = new MockWebServer()
        mockWebServer.start()
        println mockWebServer.port
        println mockWebServer.url("/")
    }

    def cleanup() {
        mockWebServer.shutdown()
    }

    def "requestAddressSearch retry success"() {
        given:
        // 테스트 데이터 및 응답 설정
        def metaDto = new MetaDto(1)
        def documentDto = DocumentDto.builder()
                .addressName(inputAddress)
                .build()
        def expectedResponse = new KakaoApiResponseDto(metaDto, Arrays.asList(documentDto))
        def uri = mockWebServer.url("/").uri()

        when:
        // 첫 번째 시도는 504 에러, 두 번째 시도는 성공 응답
        mockWebServer.enqueue(new MockResponse().setResponseCode(504))
        mockWebServer.enqueue(new MockResponse().setResponseCode(200)
                .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .setBody(mapper.writeValueAsString(expectedResponse)))

        def kakaoApiResult = kakaoAddressSearchService.requestAddressSearch(inputAddress)

        then:
        // URI 빌드 메서드가 2번 호출되고, 최종 결과가 예상된 응답과 일치하는지 확인
        2 * kakaoUriBuilderService.buildUriByAddressSearch(inputAddress) >> uri
        kakaoApiResult.getDocumentList().size() == 1
        kakaoApiResult.getDocumentList().get(0).getAddressName() == inputAddress
    }
    
    def "requestAddressSearch retry fail "(){
        given:
        def uri = mockWebServer.url("/").uri()

        when:
        // 두 번의 시도 모두 504 에러를 반환
        mockWebServer.enqueue(new MockResponse().setResponseCode(504))
        mockWebServer.enqueue(new MockResponse().setResponseCode(504))

        def result = kakaoAddressSearchService.requestAddressSearch(inputAddress)

        then:
        // URI 빌드 메서드가 2번 호출되고, 최종 결과가 null인지 확인
        2 * kakaoUriBuilderService.buildUriByAddressSearch(inputAddress) >> uri
        result == null
    }
}

MockWebServer 설정

테스트에서는 MockWebServer를 사용하여 실제 API 서버와의 통신을 모방한다. MockWebServer는 테스트 서버 역할을 하며, 요청에 대한 응답을 설정할 수 있다.

  • setup(): 각 테스트 케이스가 실행되기 전에 MockWebServer를 시작한다.
  • cleanup(): 테스트가 종료되면 MockWebServer를 종료한다.

테스트 케이스 1: requestAddressSearch retry success

이 테스트는 API 호출이 실패한 후 성공적으로 재시도되는 상황을 시뮬레이션한다.

  • given: 테스트에 필요한 데이터와 기대되는 응답을 설정한다.
  • when: MockWebServer가 첫 번째 요청에 504 에러를 반환하고, 두 번째 요청에 성공적인 응답을 반환하도록 설정한다.
  • then: kakaoUriBuilderService의 URI 빌드 메서드가 두 번 호출되었는지, 그리고 최종 결과가 기대한 바와 일치하는지 검증한다.

테스트 케이스 2: requestAddressSearch retry fail

이 테스트는 모든 재시도가 실패하는 상황을 시뮬레이션한다.

  • when: MockWebServer가 두 번의 요청에 대해 모두 504 에러를 반환하도록 설정한다.
  • then: kakaoUriBuilderService의 URI 빌드 메서드가 두 번 호출되었고, 최종 결과가 null인지 검증한다.

이 테스트를 통해 Spring Retry의 재시도 로직이 의도한 대로 동작하고 있는지를 확인할 수 있다. 각 테스트는 재시도 로직이 실제 상황에서 어떻게 동작하는지 시뮬레이션하여, 재시도가 성공적으로 수행되거나 실패했을 때의 처리를 검증한다.

이번 포스팅에서는 Spring Retry를 이용한 재시도 로직 구현과 테스트 방법에 대해 다루었다. Spring Retry를 통해 실패한 요청에 대한 자동 재시도와 복구 로직을 손쉽게 적용할 수 있다. 이를 통해 API 통신의 신뢰성과 안정성을 높이는 방법을 확인할 수 있었다.