코드 저장소.

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

포폴/Coffies Vol.02

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

slown 2025. 3. 9. 19:21

목차

1.기존의 문제점

2.코드 리팩토링

 

1.기존의 문제점

 

기존에 작성을 했던 회원의 위치의 위경도를 기준으로 해서 반경3km에 있는 가게를 보여주는 기능에서 리팩토링을 해야되는 부분이 발생을 해서 고쳐보기로 했다.

 

기존의 코드의 문제점은 다음과 같다.

 

1. 현재 @Retryable을 사용해서 API 호출이 실패하면 3번까지 재시도하도록 되어 있음.

    하지만 3번 재시도 후에도 실패하면 null을 반환함.

 

2. @Recover에서 응답값이 null이라는 점.

 

3. URI 중복 요청 가능성 Set<URI> uris = new HashSet<>(); -> 현재 Set을 사용하지만, page가 1로 고정되어 있어 항상 한 개의 URI만 추가됨.

 

2.코드 리팩토링

이러한 문제점을 해결하기 위해서 기존의 코드에 변경을 했습니다. 

 

1.기존의 api에서 가게 이름을 추출하는 과정에서 api의 응답이 null인 경우 NullPointException이 발생할 가능성이 있습니다.

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());
    }
}

 

그래서 반복문 안에 null체크를 포함해서 수정했습니다.

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

    if (result == null || result.getDocumentList() == null) { // Null 체크 추가
        log.warn("API Response is null. Skipping this request.");
        continue;
    }

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

 

2. 중복적인 api요청을 피하기 위해서 캐싱을 적용을 하기로 했습니다.

 

  @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();
    }

 

위의 메서드에 캐싱을 적용을 하고 api에서 이름을 추출하는 extractName()메서드에도 동일한 요청이 나오지 않게끔 캐싱을 도입을 합니다. 

 

/**
     * 카카오 API 요청 (캐싱 적용)
     * - 동일한 주소에 대해 Redis 캐시 적용하여 API 요청 최소화
     * - API 실패 시 @Retryable로 재시도
     * - 재시도 후에도 실패하면 @Recover로 대체 데이터 제공
     */
    @Cacheable(value = "addressCache", key = "#address", unless = "#result == null")
    @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 추출된 가게이름 (리스트 타입)
     **/
    @Cacheable(value = "placeCache", key = "#member.memberLat + ',' + #member.memberLng", unless = "#result == null")
    @Retryable(
            value = {RuntimeException.class},//api가 호출이 되지 않은 경우에 runtimeException을 실행
            maxAttempts = 3,//재시도 횟수
            backoff = @Backoff(delay = 2000)//재시도 전에 딜레이 시간을 설정(ms)
    )
    public List<String>extractPlaceName(Member member){
        URI uri = kakaoUriBuilderService.buildUriByCategorySearch(
                String.valueOf(member.getMemberLng()),
                String.valueOf(member.getMemberLat()),
                1);

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

        ResponseEntity<KakaoPlaceApiResponseDto> response = restTemplate.exchange(uri, HttpMethod.GET, httpEntity, KakaoPlaceApiResponseDto.class);
        KakaoPlaceApiResponseDto result = response.getBody();

        if (result == null || result.getDocumentList() == null) {
            log.warn("Failed to fetch place names. Returning fallback list.");
            return List.of("Fallback Place 1", "Fallback Place 2");
        }

        return result.getDocumentList().stream()
                .map(PlaceDocumentDto::getPlaceName)
                .collect(Collectors.toList());
    }

 

위의 코드와 같이 requestAddressSearch()는 동일한 주소에 대해 캐싱을 적용을 하고 그 다음에 extractPlaceName()에 중복요청 방지 캐싱을 도입을 해서 회원의 위경도를 조합으로 해서 캐싱키로 만들었습니다.

 

그리고 마지막으로 @Recover어노테이션에서 api호출 실패시 fallback을 적용해서 기존에 캐싱이 적용된 데이터 혹은 디비에 저장된 가게정보를 보여주고 마지막에 정보가 없는 경우에는 빈 리스트를 보여주는 방식으로 진행을 하기로 했고 수정된 코드는 다음과 같습니다.

@Recover
    public KakaoApiResponseDto recover(CustomExceptionHandler e, String address) {
        log.error("All retries failed. Using fallback response. Address: {}, Error: {}", address, e.getErrorCode().getMessage());

        KakaoApiResponseDto cachedResponse = (KakaoApiResponseDto) redisTemplate.opsForValue().get("addressCache::" + address);
        if (cachedResponse != null) {
            log.info(" Redis 캐싱된 데이터 반환: {}", address);
            return cachedResponse;
        }

        // DB에서 기존 가게 데이터를 조회
        List<Place> places = placeRepository.findPlacesByName(Collections.singletonList(address));
        if (!places.isEmpty()) {
            log.info(" DB에서 조회한 데이터 반환: {}", address);

            // DB 데이터 → KakaoApiResponseDto로 변환
            List<DocumentDto> documents = places.stream()
                    .map(DocumentDto::new) // Place → DocumentDto 변환
                    .collect(Collectors.toList());

            return new KakaoApiResponseDto(documents);
        }
        //모드 방법이 실패한 경우 빈 리스트를 출력.
        return getFallbackResponse();
    }

    private KakaoApiResponseDto getFallbackResponse() {
        KakaoApiResponseDto fallbackResponse = new KakaoApiResponseDto();
        fallbackResponse.setDocumentList(Collections.emptyList());
        return fallbackResponse;
    }

 

이렇게 작성을 하면 외부api를 사용하는데 있어서 null처리와 가게조회를 하는데 있어서 필요한 후속조치를 할 수 있게 되었습니다.