코드 저장소.

[Coffies Vol.02] @Retryable을 사용한 재시도 설정 본문

포폴/Coffies Vol.02

[Coffies Vol.02] @Retryable을 사용한 재시도 설정

slown 2024. 7. 21. 12:46

목차

1.문제상황 및 해결 방안 

2.적용

 

1.문제상황 및 해결 방안

만들고 있는 기능 중에 회원의 위치를 기준으로 해서 가까운 거리에 있는 카페를 카카오맵으로 보여주는 기능에서 서버에서 카카오맵api를 사용해서 가게 정보를 가져오는데 가끔씩 연결이 끊기는 문제가 생겨서 데이터를 못가져오는 경우가 간간히 발생. 그래서 스프링에서 제공을 하는 @Retryable을 사용하면 api의 재시도와 예외처리를 설정을 할 수 있어서 적용하기로 했습니다.  

2.적용

2-1.build.gradle에 주입을 한다.

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

 

2-2.설정 클래스 작성

@EnableRetry
@Configuration
public class RestTemplateConfig {

    @Bean
    public RestTemplate retryTemplate(){

        FixedBackOffPolicy backOffPolicy = new FixedBackOffPolicy();
        backOffPolicy.setBackOffPeriod(1000);

        SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy();
        retryPolicy.setMaxAttempts(2);
        RetryTemplate retryTemplate = new RetryTemplate();
        retryTemplate.setBackOffPolicy(backOffPolicy);
        retryTemplate.setRetryPolicy(retryPolicy);

        return new RestTemplate();
    }
}

 

설정 클래스의 내용을 설명을 하자면 다음과 같다.

 

FixedBackOffPolicy backOffPolicy = new FixedBackOffPolicy();

  • FixedBackOffPolicy 은 재시도의 간격을 설정을 합니다.
  • backOffPolicy.setBackOffPeriod(1000); 재시도 간격을 1000밀리초(1초)로 설정.

SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy();

  • SimpleRetryPolicy은 재시도의 횟수를 설정을 합니다.
  • retryPolicy.setMaxAttempts(2); 은 최대 재시도 횟수를 2회로 설정합니다. 즉, 최초 시도 외에 한 번 더 시도합니다.

RetryTemplate retryTemplate = new RetryTemplate();:

 

  • RetryTemplate은 Spring Retry 라이브러리에서 재시도 로직을 캡슐화하는 객체입니다.
  • retryTemplate.setBackOffPolicy(backOffPolicy);을 통해 앞서 정의한 FixedBackOffPolicy를 설정합니다.
  • retryTemplate.setRetryPolicy(retryPolicy);을 통해 앞서 정의한 SimpleRetryPolicy를 설정합니다.

다음은 Kakao API 요청을 위한 URI를 생성하는 서비스 클래스입니다. 

@Log4j2
@Service
public class KakaoUriBuilderService {

    private static final String KAKAO_LOCAL_SEARCH_ADDRESS_URL = "https://dapi.kakao.com/v2/local/search/address.json";

    private static final String KAKAO_PLACE_SEARCH_ADDRESS_URL = "https://dapi.kakao.com/v2/local/search/category.json";

    public URI buildUriByAddressSearch(String address) {
        UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromHttpUrl(KAKAO_LOCAL_SEARCH_ADDRESS_URL);
        uriBuilder.queryParam("query", address);

        URI uri = uriBuilder.build().encode().toUri(); // encode default utf-8
        log.info("[KakaoAddressSearchService buildUriByAddressSearch] address: {}, uri: {}", address, uri);

        return uri;
    }

    public URI buildUriByCategorySearch(String x, String y, int page){
        UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromHttpUrl(KAKAO_PLACE_SEARCH_ADDRESS_URL);
        log.info(uriBuilder);
        uriBuilder.queryParam("category_group_code", "CE7"); 
        uriBuilder.queryParam("x", x);
        uriBuilder.queryParam("y", y);
        uriBuilder.queryParam("radius", 3000); //3km 반경
        uriBuilder.queryParam("page", page); 

        URI uri = uriBuilder.build().encode().toUri(); // encode default utf-8
        log.info(uri);
        return uri;
    }
}

 

해당 클래스에서의 내용은 다음과 같습니다.

 

buildUriByAddressSearch : 카카오 api에서 주소를 입력을 했을때 uri를 만들어주는 메서드

 

buildUriByCategorySearch : 카카오 api에서 위경도와 카테고리 코드를 입력을 했을 경우 uri를 만들어주는 메서드 

 

위의 클래스를 가지고 실제 가게의 정보를 알려주는 클래스는 다음과 같습니다.

 

@Log4j2
@Service
@RequiredArgsConstructor
public class KakaoApiSearchService {

    private final RestTemplate restTemplate;

    private final KakaoUriBuilderService kakaoUriBuilderService;

    @Value("${kakao.rest.api.key}")
    private String kakaoRestApiKey;

    /**
     * kakao map api 검색
     * @param address 가게검색주소
     * @return KakaoApiResponseDto
     **/
    @Retryable(
            value = {RuntimeException.class},//api가 호출이 되지 않은 경우에 runtimeException을 실행
            maxAttempts = 3,//재시도 횟수
            backoff = @Backoff(delay = 2000)//재시도 전에 딜레이 시간을 설정(ms)
    )
    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);

        return restTemplate.exchange(uri, HttpMethod.GET, httpEntity, KakaoApiResponseDto.class).getBody();
    }

    /**
     * 가게명 추출
     * @member 회원 객체
     * @return 추출된 가게이름 (리스트 타입)
     **/
    @Retryable(
            value = {RuntimeException.class},//api가 호출이 되지 않은 경우에 runtimeException을 실행
            maxAttempts = 3,//재시도 횟수
            backoff = @Backoff(delay = 2000)//재시도 전에 딜레이 시간을 설정(ms)
    )
    public List<String>extractPlaceName(Member member){
        Set<URI> uris = new HashSet<>();

        int page =1;
        uris.add(kakaoUriBuilderService.buildUriByCategorySearch(
                String.valueOf(member.getMemberLng()),
                String.valueOf(member.getMemberLat()),
                page));

        HttpHeaders headers = new HttpHeaders();

        headers.set(HttpHeaders.AUTHORIZATION, "KakaoAK " + kakaoRestApiKey);

        HttpEntity<Object> httpEntity = new HttpEntity<>(headers);

        List<KakaoPlaceApiResponseDto> results = new ArrayList<>();

        for(URI uri : uris){
            KakaoPlaceApiResponseDto result = restTemplate.exchange(uri, HttpMethod.GET, httpEntity,
                    KakaoPlaceApiResponseDto.class).getBody();
            results.add(result);

            for (PlaceDocumentDto document : result.getDocumentList()) {
                log.info("placeName::::" + document.getPlaceName());
            }
        }

        List<String> placeNames = new ArrayList<>();

        for (KakaoPlaceApiResponseDto result : results) {
            for (PlaceDocumentDto document : result.getDocumentList()) {
                placeNames.add(document.getPlaceName());
                log.info("placeName::::" + document.getPlaceName());
            }
        }

        return placeNames;
    }

    @Recover
    public KakaoApiResponseDto recover(String address, CustomExceptionHandler customExceptionHandler){
        log.error("All the retries failed. address: {}, error : {}", address, customExceptionHandler.getErrorCode().getMessage());
        return null;
    }
}

 

이 클래스에서 봐야될 점은 @Retryable 어노테이션이다. 

 

각 메서드는 카카오 api와 통신을 해서 정보를 가져와야 하는데 재시도를 해야 되는 메서드에 어노테이션을 붙이면 됩니다.

 

@Retryable의 내용은 다음과 같습니다.

@Retryable(
        value = {RuntimeException.class},//api가 호출이 되지 않은 경우에 runtimeException을 실행
        maxAttempts = 3,//재시도 횟수
        backoff = @Backoff(delay = 2000)//재시도 전에 딜레이 시간을 설정(ms)
)

 

그리고 재시도를 했음에도 불구하고 실패를 했을 경우 후처리를 하기 위해서 @Recover를 사용합니다. 여기서 주의해야 할 점은 @Retryable 메서드의 반환타입이 일치해야 해야 합니다. 

 

이렇게 처리를 하면 간단하게 api의 재시도를 시도를 할 수 있습니다. 하지만 일시적인 오류면 괜찮지만 빈번하게 발생하는 오류면 재시도 처리가 아닌 다른 방법을 시도를 해야 할 것입니다.