외부 API 호출 시 서킷 브레이커 적용

joona95·2024년 8월 23일

문제 상황

레시피 검색 시에 블로그 검색 및 유튜브 검색을 하기 위해 외부 API 연동을 하고 있다.

해당 외부 API 들은 각각 일당 호출 횟수 제한이 존재한다.

예전에 우연한 계기로 사람이 갑자기 몰렸을 때 정해진 호출 제한 횟수를 넘으면서 외부 API 호출에 문제가 발생했던 적이 있었다.

제일 많이 사용하는 기능 중 하나인데 일당 호출 횟수를 넘기는 경우 제대로 작동하지 않는 문제를 해결해야겠다고 생각했다.

사람들이 검색하는 검색어가 중복인 경우도 자주 있기 때문에 검색 결과를 DB에 저장하는 방식으로 진행하는 게 좋겠다고 생각했다.

검색어로 DB 내부 조회를 먼저하고 DB 내에 저장된 레시피가 일정 갯수 미만인 경우에는 외부 API 호출을 하고 결과를 DB에 저장하여 외부 API 호출 횟수를 줄이고자 했다.

그럼에도 불구하고 외부 API 호출 횟수 제한을 넘기는 경우가 발생하였을 때 DB 내에 저장된 정보만으로 보여주도록 서킷브레이커를 적용하고자 했다.

해결 방안

    implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-aop'
  • circuit breaker 사용을 위한 라이브러리 추가
  • circuit breaker 는 AOP를 통해 이루어지므로 해당 라이브러리를 반드시 추가해야 함
  • circuit breaker 의 상태를 확인하기 위해 actuator 라이브러리 추가
resilience4j:
  circuitbreaker:
    configs:
      default:
        failureRateThreshold: 50
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 10
        minimumNumberOfCalls: 1
        automatic-transition-from-open-to-half-open-enabled: true
        waitDurationInOpenState: 30s
        registerHealthIndicator: true
        permittedNumberOfCallsInHalfOpenState: 3
    instances:
      recipe-blog-search:
        baseConfig: default
      recipe-youtube-search:
        baseConfig: default
  • application.yml 에 circuit breaker 설정 추가
  • failureRateThreshold: 실패율 임계치를 백분율로 설정한 것으로, 해당 값을 넘어갈 시 서킷 브레이커가 Open 상태로 전환되며, 호출을 차단함 (기본값: 50)
  • slidingWindowType: COUNT_BASED인 경우 slidingWindowSize 만큼의 마지막 call들이 집계되고, TIME_BASED인 경우 마지막 slidingWindowSize초 동안의 call들이 집계됨 (기본값: COUNT_BASED)
  • slidingWindowSize: CLOSED 상태에서 집계되는 슬라이딩 윈도우 크기 설정 (기본값: 100)
  • minimumNumberOfCalls: 서킷 브레이커 적용을 위한 최소 호출 수로, minimumNumberOfCalls 이상의 요청이 있을 때부터 faiure/slowCall rate를 계산함 (기본값: 100)
  • automatic-transition-from-open-to-half-open-enabled: 예외 발생시 OPEN 상태가 된 서킷 브레이커를 자동으로 HALF_OPEN 상태로 변경할지 여부
  • waitDurationInOpenState: OPEN에서 HALF_OPEN 상태로 전환하기 전 대기 시간
  • registerHealthIndicator: 서킷 브레이커 설정을 actuator 의 health로 확인하기 위한 설정
  • permittedNumberOfCallsInHalfOpenState: HALF_OPEN 상태일 때, OPEN/CLOSE 여부를 판단하기 위해 허용할 호출 횟수 (기본값: 10)
@Service
public class BlogRecipeService {

	...
    
     public RecipesResponse getBlogRecipes(User user, String keyword, Long lastBlogRecipeId, int size, String sort) throws UnsupportedEncodingException {

        badWordService.checkBadWords(keyword);

        long totalCnt = blogRecipeRepository.countByKeyword(keyword);

        List<BlogRecipe> blogRecipes;
        if (totalCnt < MIN_RECIPE_CNT) {
            blogRecipes = blogRecipeClientSearchService.searchNaverBlogRecipes(keyword, size);
            totalCnt = blogRecipeRepository.countByKeyword(keyword);
        } else {
            blogRecipes = findByKeywordSortBy(keyword, lastBlogRecipeId, size, sort);
        }

        return getRecipes(user, totalCnt, blogRecipes);
    }
    
}
@Slf4j
@Service
public class BlogRecipeClientSearchService {

    @Value("${naver.client-id}")
    private String naverClientId;
    @Value("${naver.client-secret}")
    private String naverClientSecret;
    private static final int NAVER_BLOG_SEARCH_START_PAGE = 1;
    private static final int NAVER_BLOG_SEARCH_DISPLAY_SIZE = 50;
    private static final String NAVER_BLOG_SEARCH_SORT = "sim";

    private final BlogRecipeRepository blogRecipeRepository;
    private final NaverFeignClient naverFeignClient;

    public BlogRecipeClientSearchService(BlogRecipeRepository blogRecipeRepository, NaverFeignClient naverFeignClient) {
        this.blogRecipeRepository = blogRecipeRepository;
        this.naverFeignClient = naverFeignClient;
    }

    @CircuitBreaker(name = "recipe-blog-search", fallbackMethod = "fallback")
    public List<BlogRecipe> searchNaverBlogRecipes(String keyword, int size) {

        log.info("naver blog search api call");

        List<BlogRecipe> blogRecipes = naverFeignClient.searchNaverBlog(naverClientId,
                naverClientSecret,
                NAVER_BLOG_SEARCH_START_PAGE,
                NAVER_BLOG_SEARCH_DISPLAY_SIZE,
                NAVER_BLOG_SEARCH_SORT,
                keyword + " 레시피").toEntity();

        for (BlogRecipe blogRecipe : blogRecipes) {
            blogRecipe.changeThumbnail(getBlogThumbnailUrl(blogRecipe.getBlogUrl()));
        }

        createBlogRecipes(blogRecipes);

        return blogRecipes.subList(0, size);
    }

    public List<BlogRecipe> fallback(String keyword, int size, Exception e) {

        log.info("fallback call - " + e.getMessage());

        return blogRecipeRepository.findByKeywordLimit(keyword, size);
    }
    
	...

}
@Service
public class YoutubeRecipeService {

	...

	public RecipesResponse getYoutubeRecipes(User user, String keyword, Long lastYoutubeRecipeId, int size, String sort) throws IOException {

        badWordService.checkBadWords(keyword);

        long totalCnt = youtubeRecipeRepository.countByKeyword(keyword);

        List<YoutubeRecipe> youtubeRecipes;
        if (totalCnt < MIN_RECIPE_CNT) {
            youtubeRecipes = youtubeRecipeClientSearchService.searchYoutube(keyword, size);
            totalCnt = youtubeRecipeRepository.countByKeyword(keyword);
        } else {
            youtubeRecipes = findByKeywordSortBy(keyword, lastYoutubeRecipeId, size, sort);
        }

        return getRecipes(user, totalCnt, youtubeRecipes);
    }

}
@Slf4j
@Service
public class YoutubeRecipeClientSearchService {

    private static final HttpTransport HTTP_TRANSPORT = new NetHttpTransport();
    private static final JsonFactory JSON_FACTORY = new JacksonFactory();
    private static final String YOUTUBE_SEARCH_FIELDS = "items(id/kind,id/videoId,snippet/title,snippet/channelTitle,snippet/thumbnails/default/url,snippet/publishedAt,snippet/description)";
    private static final long NUMBER_OF_VIDEOS_RETURNED = 50;
    @Value("${google.api-key}")
    private String youtubeApiKey;

    private final YoutubeRecipeRepository youtubeRecipeRepository;

    public YoutubeRecipeClientSearchService(YoutubeRecipeRepository youtubeRecipeRepository) {
        this.youtubeRecipeRepository = youtubeRecipeRepository;
    }

    @CircuitBreaker(name = "recipe-youtube-search", fallbackMethod = "fallback")
    public List<YoutubeRecipe> searchYoutube(String keyword, int size) throws IOException {

        log.info("youtube search api call");

        YouTube youtube = new YouTube.Builder(HTTP_TRANSPORT, JSON_FACTORY, new HttpRequestInitializer() {
            public void initialize(HttpRequest request) throws IOException {
            }
        }).setApplicationName("youtube-cmdline-search-sample").build();

        // Define the API request for retrieving search results.
        YouTube.Search.List search = youtube.search().list("id,snippet");

        search.setKey(youtubeApiKey);
        search.setQ(keyword + " 레시피");
        search.setType("video");
        search.setMaxResults(NUMBER_OF_VIDEOS_RETURNED);
        search.setFields(YOUTUBE_SEARCH_FIELDS);

        SearchListResponse searchResponse = search.execute();
        List<SearchResult> searchResultList = searchResponse.getItems();

        List<YoutubeRecipe> youtubeRecipes = new ArrayList<>();
        if (searchResultList != null) {
            for (SearchResult rid : searchResultList) {
                youtubeRecipes.add(YoutubeRecipe.builder()
                        .title(rid.getSnippet().getTitle())
                        .description(rid.getSnippet().getDescription())
                        .thumbnailImgUrl(rid.getSnippet().getThumbnails().getDefault().getUrl())
                        .postDate(LocalDate.ofInstant(Instant.ofEpochMilli(rid.getSnippet().getPublishedAt().getValue()), ZoneId.systemDefault()))
                        .channelName(rid.getSnippet().getChannelTitle())
                        .youtubeId(rid.getId().getVideoId())
                        .build());
            }
        }

        createYoutubeRecipes(youtubeRecipes);

        return youtubeRecipes.subList(0, size);
    }

    public List<YoutubeRecipe> fallback(String keyword, int size, Exception e) {

        log.info("fallback call - " + e.getMessage());

        return youtubeRecipeRepository.findByKeywordLimit(keyword, size);
    }
    
    ...
}
  • @CircuitBreaker 어노테이션으로 서킷 브레이커를 적용할 메소드에 적용
    • name 은 application.yml 의 서킷 브레이커 설정 적용을 할 이름 의미
    • fallbackMethod 옵션에는 예외 발생 시 적용할 fallback 메소드명 적용
    • fallback 메소드는 서킷 브레이커를 적용할 메소드의 리턴값과 파라미터값을 전부 가지도록 하고, Exception 파라미터를 추가
    • fallback 메소드는 반드시 서킷 브레이커 적용할 메소드와 같은 클래스에 있어야 함
  • 서킷브레이커가 적용된 메소드와 해당 메소드를 호출하는 메소드는 클래스가 분리돼 있어야 정상적으로 동작함

0개의 댓글