레시피 검색 시에 블로그 검색 및 유튜브 검색을 하기 위해 외부 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'
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
@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 어노테이션으로 서킷 브레이커를 적용할 메소드에 적용