1 Open API
Open API 는 개발자가 접근할 수 있도록 공개적으로 제공되는 프로그래밍 인터페이스이다. 일반적으로 HTTP 프로토콜을 사용하여 데이터를 주고받으며 REST , SOAP 등 다양한 방식으로 구현될 수 있다. 공개적으로 접근 가능하고 JSON / XML 형식으로 데이터를 교환한다. 개발자는 특정 API 를 사용하여 새로운 애플리케이션을 만들거나 기존 제품에 새로운 기능을 추가할 수 있다.
예를 들어 개발자는 Google Maps API 를 사용하여 개발하는 애플리케이션에서 지도를 표시하거나 위치 데이터를 활용할 수 있고 기상청의 API 를 사용하여 날씨 정보를 얻어 활용할 수 있다.
❓OpenAPI Specification
OAS 는 API 디자인에 관한 명세를 말한다. API 의 구조를 정의하는 표준화된 방법을 제공하여 개발자가 API 를 더 쉽게 이해하고 사용할 수 있게 돕는다.
2 프로젝트에서 Open API 를 사용하는 이유
최종 프로젝트의 주제로 영화를 리뷰하고 함께 시청하기 위한 모임을 모집하는 서비스를 만들기로 했다.
영화 리뷰를 하기 위해선 특정 영화의 정보를 가져올 수 있어야 하는데 이 때 요청 인자에 따른 영화들을 적절하게 반환해주는 영화 관련 open api 를 활용하면 더 효과적인 서비스를 만들 수 있을 것이라 생각하여 open api 를 사용하기로 하였다.
영화 데이터베이스를 가져와서 DB 에 저장해놓고 쓸 수도 있지만 수많은 영화정보들을 필요로 하지 않고 리뷰 생성 시 리뷰를 작성하길 원하는 특정 영화를 찾는 기획 상 호출을 해오는게 알맞다 생각하여 open api 를 사용하기로 했다. 데이터들을 DB 에 저장해두고 사용하는 방안은 API 서버에 문제가 생겼을 때 활용할 수 있지 않을까 생각했다.
3 TMDB 를 선택한 이유
영화 데이터를 얻기 위한 open api 를 사용하기로 결정한 이후 관련된 데이터를 제공하는 어떤 곳이 있는지 찾아보았다.
1 영화진흥위원회
일별, 주간, 주말 박스오피스를 지원할 뿐만 아니라 한국영화/외국영화를 구분할 수 있으며 박스오피스 순위까지 함께 제공해준다. 프로젝트에 필요한 정보들이 많지 않았기에 이 정도면 적절하다고 여겼다. 그러나 포스터 이미지는 제공해 주지 않아 보류해두었다.
영화의 포스터 이미지 경로을 지원하는 것을 큰 장점으로 생각하고 이 api 를 테스트해보았을 때 서비스상의 문제점이 있었다.
박스오피스와 같이 대중적이고 상업적인 영화를 구분지을 수 없다보니 모든 목록을 불러왔을 때 부적절한 콘텐츠를 필터링할 방법이 없었다. 우리 프로젝트는 인기 영화를 불러와서 리뷰를 생성하는 식의 서비스를 기획했는데 상업영화를 구분지을 수 없으면 적절한 데이터를 얻을 수 없다는 것이 큰 문제점이었다. 그래서 다시 방향을 틀어 인기 영화 순위와 함께 포스터 이미지도 지원하는 api 를 찾아보았다.
3 tmdb
tmdb 는 포스터 이미지를 포함한 영화의 상세 정보를 제공할 뿐만 아니라 인기 영화, 상영 중, 개봉 예정 등 다양한 호출 url 을 제공하여 최신 인기 영화정보를 이용하기에 유용하다. 또한 영어 기반 api 이지만 언어 및 지역 설정을 필터링하여 알맞은 정보를 제공해주고 문서화가 잘 되어있어 개발 과정에서 발생할 수 있는 문제 해결에 큰 도움이 될 수 있어 최종적으로 채택하게 되었다.
속도 제한이 있다. 그러나 우리 프로젝트에 제한될만한 사용량이 없을거라고 판단했다.
4 HTTP Client 선정
스프링에서 open api 를 활용할 때 어떤 기술을 사용하는지 찾아보았다.
REST Clients :: Spring Framework
- restTemplate : 스프링 프레임워크에서 제공하는 동기적, 블로킹 방식의 클라이언트
- webClient : 스프링 WebFlux 의 일부로 비동기, 논블로킹 방식의 클라이언트
- restClient : 스프링 프레임워크 6.1 의 새로운 동기적 클라이언트
- feign : 스프링 클라우드에서 제공하는 클라이언트
참조 : https://octoping.tistory.com/41
spring 은 3.0 버전부터 간편하게 HTTP 통신을 할 수 있는 RestTemplate 를 선보였고 이는 오랜 시간 잘 쓰이다가 WebClient 의 등장 이후 유지관리 모드에 들어갔다.
WebClient 는 spring 5.0 버전에서 WebFlux 와 함께 내놓은 인터페이스이다. WebFlux 라는 의존성 안에 들어있기 때문에 WebFlux 의존성을 설치해야 하고 Spring WebFlux 의 일부로서 추가적인 학습이 필요해 단순히 api 호출 역할만하기에는 불필요하다고 판단했다.
인터페이스에 어노테이션을 추가함으로써 요청을 쉽게 할 수 있는 feign 은 마찬가지로 추가적인 의존성을 설치해야 한다.
restClient 는 기존에 프로젝트를 생성하면서 추가되어있는 web 의존성을 통해 바로 사용할 수 있으며 아래와 같은 간단한 방식으로, 추가적인 학습곡선 없이 api 호출 기능을 구현할 수 있어서 채택했다.
val result= restClient.get()
.uri("https://tmdb-url?parameter=something")
.retrieve()
.body<String>()
println(result)
5 로직 흐름
- 사용자의 영화 검색 시도 (parameter : title?)
- reviewController 의 getMovies 호출
- reviewService 의 getMovies 호출
- title 로 캐시키 생성 ( movies::$title)
- 캐시키에 해당하는 값이 있으면 캐시저장소에서 값을 가져와서 반환 없으면 다음
- title null 여부로 url 생성 (null 이면 popular , null 이 아니면 search)
- 생성된 url 로 api 호출
- 반환값을 dto 객체로 변환 후 장르 id 를 장르 이름으로 변환 (genre url 호출)
- customPage dto 객체로 변환, 캐시 저장 후 반환
6 코드 사용
@Service
class TmdbApiService(
private val apiProperties: ApiProperties,
private val redisTemplate: RedisTemplate<String, String>
) {
val restClient: RestClient = RestClient.create()
val objectMapper: ObjectMapper =
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) // 정의되지 않은 필드 무시
// 영화 호출 api
fun getMovies(title: String?): CustomPageResponse {
val cacheKey = "movies::$title"
val movieJson = redisTemplate.opsForValue().get(cacheKey)
if (movieJson != null) {
return objectMapper.readValue(movieJson)
}
val type = if (!title.isNullOrEmpty()) "search" else "popular"
val url = getBaseUrl(type).let { if (!title.isNullOrEmpty()) "$it&query=$title" else it }
val movieListResponse = getMovieResponse(getResult(url))
if (movieListResponse.results.isEmpty()) throw BaseException(BaseResponseCode.INVALID_TITLE)
val response = matchGenreNames(movieListResponse)
val responseJson = objectMapper.writeValueAsString(response)
val ttl = if (title.isNullOrEmpty()) 7L else 1L // 인기 목록은 7일 그 외는 1일
redisTemplate.opsForValue().set(cacheKey, responseJson, ttl, TimeUnit.DAYS)
return response
}
// 호출 url 생성
private fun getBaseUrl(type: String) =
when (type) {
"popular" -> apiProperties.popularUrl
"search" -> apiProperties.searchUrl
"genre" -> apiProperties.genreUrl
else -> apiProperties.popularUrl
} + "?${getDefaultUrlParameter()}"
// Default 요청 인자
private fun getDefaultUrlParameter(): String {
return "?include_adult=false" +
"&language=ko-KO" +
"&include_video=false" +
"®ion=140" +
"&page=1" +
"&api_key=${apiProperties.key}"
}
// 호출 결과값
private fun getResult(url: String) = restClient.get()
.uri(url)
.retrieve()
.body(String::class.java)!!
// DTO 변환
private fun getMovieResponse(result: String): MovieListResponse {
return result.let { objectMapper.readValue(it, MovieListResponse::class.java) }
}
// 장르명 변환 후 최종 객체 반환
private fun matchGenreNames(movieListResponse: MovieListResponse): CustomPageResponse {
val genreResponse = getGenres()
movieListResponse.results.forEach { movie ->
movie.genreNames = movie.genre_ids.mapNotNull { id ->
genreResponse.genres.find { genre -> genre.id == id }?.name
}.joinToString(", ")
}
return CustomPageResponse(
movieListResponse.results,
3,
20,
9
)
}
// 장르 목록 호출
private fun getGenres(): GenreResponse {
val url = getBaseUrl("genre")
return getResult(url).let { objectMapper.readValue(it, GenreResponse::class.java) }
}
}
캐시 적용
영화 데이터는 우리 프로젝트의 핵심이다.
리뷰를 작성하거나 모임을 생성하기 위해 필수로 영화를 찾고 선택하여야 하는 만큼 제일 자주 호출되는 로직으로 더 빠른 조회를 위한 즉 성능 개선이 필요하다고 판단하였다.
인메모리 데이터 저장소로 읽는 작업을 빠르게 처리할 수 있는 redis 를 적용하기로 하였고
기본 결과인 인기 영화는 7일, 검색 결과는 1일의 TTL 을 설정하여 캐시를 적용하였다.
그 결과 영화 조회 응답속도 (인기영화 기준) 가 약 550ms 에서 50ms 이하로 개선되는 결과를 확인할 수 있었다.
7 트러블 슈팅
장르 ID를 이름으로 변환하는 과정에서 로직 설계에 어려움을 겪었다. 선택한 API는 고유의 장르 ID를 가지고 있어, 이름으로 반환하려면 장르 API를 추가로 호출해야 했다. 이때 고려했던 문제는 영화 정보를 검색하는 과정에서 두 번의 API 호출이 필요한데 이것이 효율적인지 여부였다.
초기에는 장르 목록을 데이터베이스에 저장하고 필요할 때마다 DB에서 가져오는 방식을 고려했다. 그러나 장르 정보는 외부 API에서 변경될 가능성이 있어, 이를 주기적으로 동기화해야 하는 관리 부담이 있었다.
결국, 영화 검색 결과를 Redis를 사용해 캐싱할 예정인 상황에서 별도로 장르 정보를 DB에서 관리하는 것은 불필요하다고 판단하여, DB 저장 없이 장르 API를 직접 호출하는 방식을 채택하기로 했다.
현재는 검색어가 없는 경우 인기 영화를 호출하여 7일의 TTL(Time To Live)로 설정하고, 검색어가 있는 검색 결과에는 1일의 TTL을 설정해 두었다. 이 설정은 사용자 경험에 부담을 주지 않는 것으로 나타났다.
// 장르명 반환
private fun matchGenreNames(movieListResponse: MovieListResponse): CustomPageResponse {
val genreResponse = getGenres()
movieListResponse.results.forEach { movie ->
movie.genreNames = movie.genre_ids.mapNotNull { id ->
genreResponse.genres.find { genre -> genre.id == id }?.name
}.joinToString(", ")
}
return CustomPageResponse(
movieListResponse.results,
3,
20,
9
)
}
처음 받은 영화정보를 forEach 문으로 하나씩 순회하면서
그 안의 장르 id 와 따로 호출해서 받아온 장르 목록의 id 와 일치한다면 name 으로 교체하는 로직을 짰다.
이 방법이 최선인지는 확신하지못하겠다.
'왕초보일지' 카테고리의 다른 글
GitHub Actions + Docker + EC2 (+ S3) 을 활용한 배포 (1) | 2024.04.03 |
---|---|
240315 TIL | 3주차 마무리 전체 테스트 (0) | 2024.03.15 |
스프링 부트 build contextLoads() Failed (1) | 2024.03.06 |
240227 TIL | ERD 설계 고민 (2) | 2024.02.29 |
240223 TIL | (0) | 2024.02.23 |