Spring에서 AWS SQS 요청, 응답 메시지 처리하기에서 생성된 이미지를 사용자에게 전달하는 과정까지 완료했다.
만약 사용자가 생성된 이미지가 마음에 들었을 때 해당 이미지와 관련된 상품을 바로 검색해볼 수 있으면 좋을 것 같다는 생각이 들어서 관련된 기능을 개발하게 되었다.
구현 화면처럼 Google Lens API를 사용해서 관련된 상품을 추천해주는 방향으로 기능을 개발하게 되었다. (추천 알고리즘까지는 직접 구현할 시간은 없어서..)
구글에서 바로 API를 제공해주었으면 좋았겠지만, Lens API를 제공하지 않아서 SerpAPI라는 사이트의 API를 활용했다.
연동할 API도 선택했고, Spring에서는 어떻게 외부 API를 호출할지 고민하게 되었다.
그런데 Spring에서 AWS SQS 요청, 응답 메시지 처리하기를 진행하면서 CompletableFuture
를 공부하면서 비동기 호출 방식인 Webclient
에 대해 알게 되었다.
Spring에서 API를 호출하는 방식은 크게 RestTemplate
과 Webclient
를 사용하는 두 가지 방식으로 나뉜다.
RestTemplate
은 동기(Blocking) 방식의 통신 방법을 사용한다. 만약 요청이 온다면 스레드 풀에서 스레드를 꺼내 해당 요청에 스레드를 할당해서 처리한다. 만약 응답 처리 속도가 느리다면 그동안 스레드는 Blocking 되어 있을 것이고, 요청 수가 많아진다면 결국 금방 스레드 풀에 있는 스레드들을 소진해서 급격히 느려질 것이다.
Raw Performance Numbers - Spring Boot 2 Webflux vs. Spring Boot 1 글에서 Boot 2은 Webclient하고 Boot 1은 RestTemplate을 사용해서 성능을 비교했다. 그 결과, 요청 수가 1000이 넘어가면서 성능 차이가 급격하게 나는 것을 확인할 수 있다.
반면에 Webclient는 비동기(Non-Blocking) 방식의 통신 방법을 사용한다. 만약 요청이 오면 스레드를 할당하는데, 응답이 올 때까지 기다리지 않고 다른 작업을 처리할 수 있다. 이때 응답이 오면 해당 방식으로 처리하도록 함수형 방식으로 미리 정의해둔다. 이런 방식으로 적은 스레드로도 훨씬 효율적으로 많은 요청을 처리할 수 있다.
Spring에서는 Spring Webflux의 Webclient로 이런 기능을 사용할 수 있다.
implementation 'org.springframework.boot:spring-boot-starter-webflux'
내가 사용한 Webclient 설정은 다음과 같다.
@Configuration
public class WebClientConfig {
private static final int TIMEOUT_MS = 60000;
private String serpUrl = "https://serpapi.com/search.json";
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, TIMEOUT_MS)
.doOnConnected(conn -> conn
.addHandlerLast(new ReadTimeoutHandler(TIMEOUT_MS))
.addHandlerLast(new WriteTimeoutHandler(TIMEOUT_MS))
)
.responseTimeout(Duration.ofSeconds(TIMEOUT_MS));
@Bean
public WebClient webClient() {
return WebClient.builder()
.baseUrl(serpUrl)
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(4 * 1024 * 1024))
.clientConnector(new ReactorClientHttpConnector(httpClient))
.defaultHeaders(httpHeaders -> {
httpHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
httpHeaders.add(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE);
})
.build();
}
}
혹시 몰라서 타임아웃 시간을 늘려주었고, Content Type이랑 Accpet Type을 JSON으로 지정해주었다.
그리고 WebFlux에서는 응답 결과로 CompletableFuture 대신 Mono나 Flux를 사용한다. Mono의 경우 응답 스트림이 0 또는 1개일 경우, Flux는 0 ~ N개일 경우 사용한다.
나는 이미지 하나에 대한 API 결과만 받을 예정이라 Mono를 사용했다.
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class RecommendService {
private static final String ENGINE = "engine";
private static final String GOOGLE_LENS = "google_lens";
private static final String URL = "url";
private static final String HL = "hl";
private static final String KO = "ko";
private static final String COUNTRY = "country";
private static final String KR = "kr";
private static final String API_KEY = "api_key";
@Value("${spring.cloud.google.search.serp.api.key}")
private String googleSearchSerpApiKey;
private final WebClient webClient;
public Mono<Object> getRecommendations(final RecommendationRequest request){
return getSerpApiResponse(request);
}
private Mono<Object> getSerpApiResponse(RecommendationRequest request) {
return webClient.get()
.uri(baseUrl -> baseUrl
.queryParam(ENGINE, GOOGLE_LENS)
.queryParam(URL, request.imgUrl())
.queryParam(HL, KO)
.queryParam(COUNTRY, KR)
.queryParam(API_KEY, googleSearchSerpApiKey)
.build())
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.bodyToMono(Object.class);
}
}
그리고 어떤 블로그 글에서는 .block()으로 결과를 받아와서 DTO로 변환하던데, 이러면 Blocking 방식으로 바뀌어서 좋은 방법은 아니다.
Webclient로 Mono나 Flux를 다루면서 가장 고민했던 부분이 어떻게 해야 응답 API에 맞게 변환할 수 있는지였다. (아마 이런 변환 부분 때문에 Block을 사용했을 것이다.)
그래서 이것도 공식 문서를 살펴보면서 Mono의 메서드들 중 적절한 메서드를 찾아 헤맸다.
그러다가 flatMap을 발견해서 아래와 같은 방식으로 응답 API에 맞게 변환해주었다.
@RequiredArgsConstructor
@RequestMapping("/api")
@RestController
public class RecommendController {
private final RecommendService recommendService;
@PostMapping("/recommendations")
@PreAuthorize("hasRole('ROLE_USER')")
@Operation(summary = "이미지 URL을 전달하면 GoogleLens API로 응답", description = "사용자가 이미지 URL을 전달하면 GoogleLens API로 응답 합니다.")
@Parameter(name = "request.imgUrl", description = "이미지 URL")
public Mono<ApiResponse<Object>> getRecommendations(@RequestBody final RecommendationRequest request){
final var response = recommendService.getRecommendations(request);
return response.flatMap(data -> Mono.just(ApiResponse.ok(data)));
}
}
전체 코드는 여기서 확인할 수 있다.
아무튼 이렇게 내가 처음에 기획했던 기능들을 전부 구현할 수 있었다.
이 프로젝트를 진행하면서 새로운 기술들을 정말 많이 접하고 다루게 되었다. (AWS도 비용 때문에 무서워서 못 쓰고 있었는데, 이번 기회에 마음껏 사용해볼 수 있었다.)
사실 프로젝트 초반에는 '이걸 내가 다 구현할 수 있을까?...'라는 마음도 많았는데, 잘 마무리하게 돼서 나에겐 좋은 경험이 된 것 같다! 😄