작년 가을학기 영화 리뷰 아카이빙 프로젝트를 개발하면서,
영화 관련 데이터를 끌어오기 위해 Tmdb API를 사용하게 되었다.
API 요청 방식은 간단했고,
영화 제목이나 고유 ID를 포함한 다양한 검색 방식도 지원되었다.
문제는 Json으로 오는 API 응답을
스프링 애플리케이션에서 적절한 항목마다 값을 읽어서
엔티티를 깔끔하게 생성해야 한다는 점이었다.
{
"adult": false,
"backdrop_path": "/l515lS7nyKc4T490He6SFvgfqFG.jpg",
"belongs_to_collection": null,
"budget": 0,
"genres": [
{
"id": 10749,
"name": "Romance"
},
{
"id": 18,
"name": "Drama"
}
],
"homepage": "http://www.finecut.co.kr/html/fulltitle-view.php?no=232",
"id": 578209,
"imdb_id": "tt11311974",
"original_language": "ko",
"original_title": "윤희에게",
"overview": "The arrival of an intimate letter prompts a young woman to bring her mother on vacation to a small Japanese town, where someone special resides.",
"popularity": 7.86,
"poster_path": "/6968mZj0jlDp7gmcjLJSd42r9pU.jpg",
"production_companies": [
{
"id": 114024,
"logo_path": "/ixf4fHczIUGyOa0x0TSzrlfX4ld.png",
"name": "Film Run",
"origin_country": "KR"
},
{
"id": 140673,
"logo_path": null,
"name": "J.One Film",
"origin_country": "KR"
}
],
"production_countries": [
{
"iso_3166_1": "KR",
"name": "South Korea"
}
],
"release_date": "2019-11-14",
"revenue": 0,
"runtime": 106,
"spoken_languages": [
{
"english_name": "English",
"iso_639_1": "en",
"name": "English"
},
{
"english_name": "Japanese",
"iso_639_1": "ja",
"name": "日本語"
},
{
"english_name": "Korean",
"iso_639_1": "ko",
"name": "한국어/조선말"
}
],
"status": "Released",
"tagline": "The words that made my heart beat again, \"To Yunhee, how are you?\"",
"title": "Moonlit Winter",
"video": false,
"vote_average": 7.031,
"vote_count": 49
}
위 Json 응답은 영화 ID를 기반으로 특정하여
검색한 결과다.
보면 알겠지만 단순히 하나의 '키-값' 리스트로 쭉 나열된 것이 아니라
{전체 Json 리스트 -> 장르 -> id, name 리스트}
와 같은 방식으로 2,3차 계층을 보인다.
그래서 해결 방법을 찾아보던 중,
Jackson의 JsonNode를 알게 되었다.
public abstract class JsonNode
extends JsonSerializable.Base // i.e. implements JsonSerializable
implements TreeNode, Iterable<JsonNode>
JsonNode는 String 형태의 Json 데이터를 트리 형태로 매핑해준다.
그러면 자세한 사용 방법을 알아보자 !
// for json
implementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.12.7.1'
우선 build.gradle에 Jackson 관련 의존성을 추가해주자.
public JsonNode readTree(String content) throws JsonProcessingException, JsonMappingException {
_assertNotNull("content", content);
try { // since 2.10 remove "impossible" IOException as per [databind#1675]
return _readTreeAndClose(_jsonFactory.createParser(content));
} catch (JsonProcessingException e) {
throw e;
} catch (IOException e) { // shouldn't really happen but being declared need to
throw JsonMappingException.fromUnexpectedIOE(e);
}
}
그리고 ObjectMapper의 readTree()를 활용해야 한다.
우리는 TMDB API의 응답을 문자열로 받아 이 메서드의 파라미터로 넣어줄 것이다.
try {
return new ObjectMapper().readTree(result);
} catch (JsonProcessingException e) {
return null;
}
그래서 정확한 호출 부분은 위와 같다.
(readTree()가 던지는 JsonProcessingException은
체크 예외이므로 예외 처리를 강제한다)
WebClient client = WebClient.builder()
.baseUrl(TMDB_URL)
.defaultHeader(HttpHeaders.AUTHORIZATION, TMDB_KEY)
.build();
스프링 웹에서 지원하는 WebClient는
스프링 애플리케이션에서 HTTP API 요청을 날려준다.
빌더 패턴을 활용하여 HTTP 요청에 필요한
헤더와 기본 주소를 포함한 인스턴스를 생성해준다.
String result = client.get()
.uri(uriBuilder -> uriBuilder
.path(path)
.queryParam(APPEND_TO_RESPONSE, append)
.queryParam(QUERY, query)
.queryParam(LANGUAGE, LANGUAGE_KOREAN)
.queryParam(PAGE, DEFAULT_PAGE)
.build())
.retrieve()
.bodyToMono(String.class)
.block();
그리고 get()으로부터 HTTP GET 요청으로 확정해서
더 자세한 요청 스펙을 추가해준다.
이 때 uri()에서 람다를 활용해
URI 주소와 요청 파라미터를 추가해준다.
retriecve()와 bodyToMono()는
HTTP 응답을 어떻게 변환할 것인지 명시하는 데 필요하다.
(bodyToMono()는 변환 옵션 중 하나인데
응답의 body만 가져다가 파라미터 클래스로 변환한다)
마지막으로 block()은 HTTP 요청 후 응답이 올 때까지
block 상태를 유지시키는 메서드다.
그래서 HTTP 요청을 날려 트리로 변환하고,
결국엔 트리의 루트 노드를 반환하는 메서드는 아래와 같다.
public JsonNode mapJsonNode(String path, String append, String query) {
WebClient client = WebClient.builder()
.baseUrl(TMDB_URL)
.defaultHeader(HttpHeaders.AUTHORIZATION, TMDB_KEY)
.build();
String result = client.get()
.uri(uriBuilder -> uriBuilder
.path(path)
.queryParam(APPEND_TO_RESPONSE, append)
.queryParam(QUERY, query)
.queryParam(LANGUAGE, LANGUAGE_KOREAN)
.queryParam(PAGE, DEFAULT_PAGE)
.build())
.retrieve()
.bodyToMono(String.class)
.block();
try {
return new ObjectMapper().readTree(result);
} catch (JsonProcessingException e) {
return null;
}
}
public static MovieTmdbDto create(List<JsonNode> nodes) {
MovieTmdbDto dto = new MovieTmdbDto();
JsonNode node = nodes.get(0);
JsonNode gradeNode = nodes.get(1);
dto.setId(node.get(JSON_NODE_ID).asText());
titleMapper(dto, node);
dto.setPopularity(node.get(JSON_NODE_POPULARITY).asDouble(DEFAULT_POPULARITY));
dto.setRunTime(node.get(JSON_NODE_RUNTIME).asText());
dto.setPrdtYear(prdtYearMapper(node));
dto.setNation(nationMapper(node));
dto.setGenres(genreMapper(node));
creditMapper(dto, node.get(JSON_NODE_CREDITS));
dto.setWatchGrade(gradeMapper(gradeNode));
return dto;
}
위에서 만든 JsonNode를 받아서 dto 객체를 생성하는 메서드다.
기본적으로 get(String fieldName)을 호출해서
Json의 '키-값' 쌍에서 '키'를 문자열로 직접 지정해준다.
(그래서 이 부분에서 반복적으로 동일한 키를 검색한다면
예처럼 상수화를 한다면 좋을 것 같다)
get()의 반환 타입은 다시 JsonNode라는 점을 꼭 알아두자.
어찌 보면 트리처럼 깊이 깊이 들어가려면
동일한 타입을 반환하는 것이 당연하다.
dto.setId(node.get(JSON_NODE_ID).asText());
dto.setPopularity(node.get(JSON_NODE_POPULARITY).asDouble(DEFAULT_POPULARITY));
그래서 위와 같이 값으로 변환하기 위한 메서드를
추가적으로 호출해줘야 한다.
(그래서?)
당신이 받을 Json 데이터가 정적이고 여러 계층으로 둘러싸여 있다면
JsonNode를 사용해보는 것도 좋은 옵션이다.
get()을 통해 Json을 계층적으로 접근할 수 있으며,
asText()나 asDouble() 등을 통해 원하는 타입으로 받기도 편하다.