저희 서비스는 크롤링하는 서버를 EC2와 분리해서 서비스를 분리시켰기 때문에, Spring Boot(AWS EC2)에서 AWS Lambda 서버를 호출하는 형식으로 외부 API를 호출하고 있습니다.
따라서, 간략하게 AWS EC2에서 AWS Lambda를 호출하는 흐름을 아래의 그림과 같이 나타내었습니다.
그러면 Spring Boot에서는 어떻게 외부의 API를 호출시킬까요??
저희는 WebClient라는 외부 호출 방식를 사용하였습니다.
그러면 도대체 WebClient가 무엇이길래 이거를 선택했고 어떻게 사용할지 소개해드리겠습니다.
먼저, 'Spring으로 외부 API 호출하기'라고 구글에 검색했을 때 가장 많이 나오는 방식은 'RestTemplate'과 'WebClient'입니다.
그러면 두 가지 방식은 어떠한 차이가 있을까요??
그러나, 스프링 5.0에서 WebClient가 나오면서 WebClient를 공식 문서에서는 추천해주고 있다.
(아직은 소문이지만, RestTemplate이 deprecated 될 수도 있다는 소리를 들어서, 이전에 RestTemplate를 사용한 것이 아니라 새로운 프로젝트를 하는 저의 경우에는 WebClient가 더 좋다고 생각했습니다.)
NOTE: As of 5.0 this class is in maintenance mode, with only minor requests for changes and bugs to be accepted going forward. Please, consider using the org.springframework.web.reactive.client.WebClient which has a more modern API and supports sync, async, and streaming scenarios.
따라서 가장 큰 차이점은 Non-Blocking 여부와 비동기화 여부입니다.
RestTemplate | WebClient | |
---|---|---|
Non-Blocking | 불가능 | 가능 |
비동기화 | 불가능 | 가능 |
참고 자료 1 : https://velog.io/@chlwogur2/Spring-외부-API-호출-로직에-관해
참고 자료 2 : https://tecoble.techcourse.co.kr/post/2021-07-25-resttemplate-webclient/
implementation 'org.springframework.boot:spring-boot-starter-webflux'
WebClient webClient = WebClient.builder().baseUrl(crawlingApiEndPoint).build();
Map<String, Object> bodyMap = new HashMap<>();
bodyMap.put("url", pageUrl);
WebClientResponse webClientResponse = webClient.post()
.bodyValue(bodyMap)
.retrieve()
.bodyToMono(WebClientResponse.class)
.block();
최대 하나의 결과를 예상
할 때 Mono를 사용해야 합니다.여러 결과
가 예상되는 경우 Flux를 사용해야 합니다.WebClient 사용방법 참고 자료 : https://thalals.tistory.com/379
Mono, Flux 참고 자료 : https://recordsoflife.tistory.com/799
외부 API의 응답 status가 400대, 500대 에러가 발생할 경우, RuntimeException를 발생시켜서 null을 반환하도록 하였습니다.
WebClient webClient = WebClient.builder().baseUrl(crawlingApiEndPoint).build();
Map<String, Object> bodyMap = new HashMap<>();
bodyMap.put("url", pageUrl);
try {
WebClientResponse webClientResponse = webClient.post()
.bodyValue(bodyMap)
.retrieve()
.onStatus(HttpStatus::is4xxClientError, clientResponse -> {
throw new RuntimeException("4xx");
})
.onStatus(HttpStatus::is4xxClientError, clientResponse -> {
throw new RuntimeException("5xx");
})
.bodyToMono(WebClientResponse.class)
.block();
} catch (Exception e) {
return null;
}
에러 참고 자료 : https://dkswnkk.tistory.com/708
이전에는 Map<String, Object> response로 외부 API 호출 응답값을 받았습니다.
WebClient webClient = WebClient.builder().baseUrl(crawlingApiEndPoint).build();
Map<String, Object> bodyMap = new HashMap<>();
bodyMap.put("url", pageUrl);
Map<String, Object> response = webClient.post()
.bodyValue(bodyMap)
.retrieve()
.bodyToMono(Map.class)
.block();
JSONParser jsonParser = new JSONParser();
Object obj = jsonParser.parse(response.get("body").toString());
JSONObject jsonObject = (JSONObject) obj;
따라서, JSONObject에서 각각의 속성값(title, thumbnail_url, descrption 등)을 가져와서 Object를 각각의 속성값에 맞는 타입(string, long 등)으로 변환해줘야 했습니다.
이 과정에서 만약에 null이면 타입을 변환할 때 에러가 발생하므로 null인지 파악하는 코드도 추가해줘야 했습니다.
public Article saveArticle(JSONObject crawlingResponse, User user, String pageUrl) {
Article article = Article.builder().user(user).pageUrl(pageUrl)
.title(Optional.ofNullable(crawlingResponse.get("title")).map(Object::toString)
.orElse(null))
.thumbnailUrl(Optional.ofNullable(crawlingResponse.get("thumbnail_url"))
.map(Object::toString).orElse(null))
.description(Optional.ofNullable(crawlingResponse.get("description"))
.map(Object::toString).orElse(null))
.author(Optional.ofNullable(crawlingResponse.get("author")).map(Object::toString)
.orElse(null))
.authorImageUrl(Optional.ofNullable(crawlingResponse.get("author_image_url"))
.map(Object::toString).orElse(null))
.blogName(
Optional.ofNullable(crawlingResponse.get("blog_name")).map(Object::toString)
.orElse(null))
.publishedDate(Optional.ofNullable(crawlingResponse.get("published_date"))
.map(Object::toString).map(Long::parseLong).map(
TimeService::fromUnixTime).orElse(null))
.siteName(
Optional.ofNullable(crawlingResponse.get("site_name")).map(Object::toString)
.orElse(null)).build();
return articleRepository.save(article);
}
JSONParser 관련 자료 : https://myeongju00.tistory.com/77
따라서 ObjectMapper를 이용해서 원하는 DTO로 변환하도록 하였습니다!
현재 외부 API인 AWS Lambda에서는 아래의 값을 응답 Body로 반환하고 있습니다.
{
"statusCode": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"type\": \"place\", \"page_url\": \"https://map.kakao.com/?map_type=TYPE_MAP&itemId=\", \"site_name\": \"KakaoMap\", \"lat\": 37.50359, \"lng\": 127.044848, \"title\": \"서울역\", \"address\": \"서울\", \"phonenum\": \"1234-1234\", \"zipcode\": \"12345\", \"homepage\": \"https://www.seoul.co.kr\", \"category\": \"지하철\"}"
}
위의 구조를 큰 형태로 보면 statusCode, headers, body로 이루어져 있음을 알 수 있습니다.
아래와 같은 DTO로 응답을 받을 수 있습니다.
@Getter
@NoArgsConstructor
public class WebClientResponse {
private String statusCode;
private WebClientHeaderResponse headers;
private String body;
}
그리고 body는 아래의 WebClientBodyResponse 형태를 띄고 있습니다.
@Getter
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) //응답값이 python의 snake case이므로 아래의 속성값을 snake case로 변경해줍니다.
@NoArgsConstructor
public class WebClientBodyResponse {
// 공통 부분
private String type;
private String description;
private String pageUrl;
private String siteName;
private String thumbnailUrl;
private String title;
// Video 부분
private String channelImageUrl;
private String channelName;
private String embedUrl;
private Long playTime;
private Long watchedCnt;
private Long publishedDate; // Video, Article 공통 부분
// Article 부분
private String author;
private String authorImageUrl;
private String blogName;
// Product 부분
private String price;
// Place 부분
private String address;
@JsonProperty("lat")
private BigDecimal latitude;
@JsonProperty("lng")
private BigDecimal longitude;
@JsonProperty("phonenum")
private String phoneNumber;
private String zipCode;
@JsonProperty("homepage")
private String homepageUrl;
private String category;
}
따라서 아래의 .bodyToMono(WebClientResponse.class)에서 외부 API 호출하여 받은 JSON 값을 WebClientResponse DTO로 변환해주고.
ObjectMapper를 통해서 String으로 들어온 WebClientResponse의 body 부분을 WebClientBodyResponse DTO로 변환해주는 작업을 수행해주면 됩니다.
WebClient webClient = WebClient.builder().baseUrl(crawlingApiEndPoint).build();
Map<String, Object> bodyMap = new HashMap<>();
bodyMap.put("url", pageUrl);
try {
WebClientResponse webClientResponse = webClient.post()
.bodyValue(bodyMap)
.retrieve()
.onStatus(HttpStatus::is4xxClientError, clientResponse -> {
throw new RuntimeException("4xx");
})
.onStatus(HttpStatus::is4xxClientError, clientResponse -> {
throw new RuntimeException("5xx");
})
.bodyToMono(WebClientResponse.class)
.block();
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
return objectMapper.readValue(
webClientResponse != null ? webClientResponse.getBody() : null,
WebClientBodyResponse.class);
} catch (Exception e) {
return null;
}
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
을 사용했다는 점입니다.objectAmpper 관련 참고 자료 : https://tomining.tistory.com/191
public Article saveArticle(WebClientBodyResponse crawlingResponse, User user, String pageUrl) {
Article article = Article.builder().user(user).pageUrl(pageUrl)
.title(crawlingResponse.getTitle())
.thumbnailUrl(crawlingResponse.getThumbnailUrl())
.description(crawlingResponse.getDescription())
.author(crawlingResponse.getAuthor())
.authorImageUrl(crawlingResponse.getAuthorImageUrl())
.blogName(crawlingResponse.getBlogName())
.publishedDate(TimeService.fromUnixTime(crawlingResponse.getPublishedDate()))
.siteName(crawlingResponse.getSiteName()).build();
return articleRepository.save(article);
}
따라서 이전에 방식보다 속성값의 타입을 따로 변환해주지 않아도 되고 null 처리도 안해도 되어서 간편해졌습니다!