안녕하세요.
https://chanho0912.tistory.com/93
저번 포스팅에 이어 이번 포스팅에서는 Spring data JPA와 QueryDSL을 사용하여 여러 API를 만들어보겠습니다.
Controller
package com._chanho.movie_recommendation.movie;
import com._chanho.movie_recommendation.genre.Genres;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.persistence.criteria.CriteriaBuilder;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/movie")
public class MovieController {
private final MovieRepo movieRepo;
private final MovieService movieService;
@GetMapping("/movies")
public ResponseEntity retrieveMovies(@PageableDefault(size = 100) Pageable pageable) {
return movieService.retrieveMovies(pageable);
}
@PostMapping("/recommendation")
public List<Movies> getRecommendation(@RequestBody RecommendationDto recommendationDto) {
HashMap<Genres, Integer> pickedGenres = movieService.getPickedGenres(recommendationDto);
HashMap<Genres, Integer> pickedGenresWithSort = movieService.sortByValue(pickedGenres);
Set<Genres> selectBestInKeys = movieService.selectKeyInMap(pickedGenresWithSort);
return movieRepo.findByGenres(selectBestInKeys, recommendationDto);
}
}
앞서 설명해드린 시나리오에 맞추어 두 개의 API를 설계했습니다.
~/api/movie/movies
~/api/movie/recommendation
첫 번째 API는 전체 영화를 반환해 주는 API입니다. 전체 영화가 거의 10000개쯤 되기 때문에 Pagination이 필수입니다. 저는 기본 사이즈를 100개로 설정하였는데,
~/api/movie/movies?size=50
의 형태로 size를 수정할 수 있습니다.
MovieService
package com._chanho.movie_recommendation.movie;
import com._chanho.movie_recommendation.genre.Genres;
import com._chanho.movie_recommendation.genre.GenresRepo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.transaction.Transactional;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class MovieService {
private final MovieRepo movieRepo;
private final GenresRepo genresRepo;
public ResponseEntity retrieveMovies(Pageable pageable) {
Page<Movies> moviesPage = movieRepo.findAll(pageable);
return new ResponseEntity<>(moviesPage, HttpStatus.OK);
}
public HashMap<Genres, Integer> sortByValue(HashMap<Genres, Integer> raw) {
return raw.entrySet()
.stream()
.sorted((i1, i2) -> i1.getValue().compareTo(i2.getValue()))
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue,
(e1, e2) -> e2, LinkedHashMap::new
));
}
public HashMap<Genres, Integer> getPickedGenres(RecommendationDto recommendationDto) {
HashMap<Genres, Integer> pickedGenres = new HashMap<>();
recommendationDto.getPickedMovies().forEach(
movieData -> {
Movies movie = movieRepo.findById(movieData.getMovieId()).orElseThrow(
() -> new IllegalStateException("Cannot find Movies with given id: " + movieData.getMovieId().toString()));
Set<Genres> genresList = movie.getGenres();
for(Genres g : genresList) {
Integer count = pickedGenres.getOrDefault(g, 0);
pickedGenres.put(g, count);
}
}
);
return pickedGenres;
}
public Set<Genres> selectKeyInMap(HashMap<Genres, Integer> pickedGenresWithSort) {
Iterator<Genres> keys = pickedGenresWithSort.keySet().iterator();
Set<Genres> selectBestInKeys = new HashSet<>();
int count = 0;
while(keys.hasNext() && count < 2) {
Genres genres = keys.next();
selectBestInKeys.add(genres);
count++;
}
return selectBestInKeys;
}
}
위와 같이 짜 보았습니다.
retrieveMovies는 JPA repository의 findAll 메서드를 활용하여 Pagination 된 영화 정보들을 반환해주었습니다.
위와 같이 영화 정보가 담겨서 반환됩니다.
getPickedGenres 메서드는 DTO에 담겨 있는(사용자가 선택한 영화들)의 장르를 분석하여 각 장르의 출현 빈도를 파악하는 Map을 만듭니다.
예를 들어서
Adventure 10번
Fantasy 1번
Animation 3번
...
이런 식으로 어떤 장르가 몇 번 선택되었는지를 파악하는 맵을 만들어서 반환해 줍니다.
추가로 RecommendationDto는 다음과 같이 정의되었습니다.
package com._chanho.movie_recommendation.movie;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data @Builder
@NoArgsConstructor @AllArgsConstructor
public class RecommendationDto {
private Long userId;
private List<MovieData> pickedMovies;
}
@Data @Builder
class MovieData{
private Long tId;
private Long movieId;
private Double rating;
}
sortByValue는 Value를 기준으로 Map의 값을 내림차순으로 정렬하는 메서드입니다.
저희는 가장 많이 선택된 장르 1, 2 두 가지만 사용하여 매칭 되는 영화 정보를 받아올 예정입니다.
selectKeyInMap 메서드는 위와 같이 정렬된 Map에서 가장 많이 선택된 장르 두 개를 넣어서 반환해주는 메서드입니다.
Repository
Repository는 QueryDSL을 적용하여 만들었습니다.
QueryDSL을 적용한 이유는, 저희가 장르 두개를 사용하여 이 두 장르에 해당하는 영화 10편을 반환할 예정인데, 이를 직접 쿼리 메서드로 작성하는 것보다 JPQL Query로 작성하는 편이 더 좋을 것이라 판단하였습니다.
우선, 두 개의 Predicate를 만들어 주었습니다.
첫 번째는 선택된 두 개의 Genres가 모두 포함되어있는가?
두 번째는 이미 recommendationDTO에 포함된 영화가 아닌가?
이 두 가지를 where절에 추가해주었고,
Genres table과 left join을 해서 fetch 하였습니다.
MovieRepo
package com._chanho.movie_recommendation.movie;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import javax.transaction.Transactional;
@Repository
public interface MovieRepo extends JpaRepository<Movies, Long>, MovieRepoExtension {
@EntityGraph(value = "Movies.withGenres", type= EntityGraph.EntityGraphType.FETCH)
Page<Movies> findAll(Pageable pageable);
}
MovieRepoExtension
package com._chanho.movie_recommendation.movie;
import com._chanho.movie_recommendation.genre.Genres;
import java.util.List;
import java.util.Set;
public interface MovieRepoExtension {
List<Movies> findByGenres(Set<Genres> genres, RecommendationDto recommendationDto);
}
MovieRepoExtensionImpl
package com._chanho.movie_recommendation.movie;
import com._chanho.movie_recommendation.genre.Genres;
import com._chanho.movie_recommendation.genre.QGenres;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.jpa.JPQLQuery;
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport;
import java.util.List;
import java.util.Set;
public class MovieRepoExtensionImpl extends QuerydslRepositorySupport implements MovieRepoExtension {
public MovieRepoExtensionImpl() {
super(Movies.class);
}
@Override
public List<Movies> findByGenres(Set<Genres> genres, RecommendationDto recommendationDto) {
com._chanho.movie_recommendation.movie.QMovies movies
= com._chanho.movie_recommendation.movie.QMovies.movies;
BooleanBuilder containGenres = new BooleanBuilder();
genres.forEach(genre -> {
containGenres.and(movies.genres.contains(genre));
});
BooleanBuilder notInRecommendation = new BooleanBuilder();
recommendationDto.getPickedMovies().forEach(movieData -> {
notInRecommendation.and(movies.id.notIn(movieData.getMovieId()));
});
JPQLQuery<Movies> query = from(movies)
.where(containGenres)
.where(notInRecommendation)
.leftJoin(movies.genres, QGenres.genres).fetchJoin()
.distinct().limit(10);
return query.fetch();
}
}
여기까지 모두 정상적으로 작성하신 분이라면, 이제 API 설계에 대한 부분은 거의 끝났습니다.
requestBody에 10개의 객체를 넣어줘야 하기 때문에, PostMan이 아닌 TestCode로 확인해보겠습니다.
TestCode
package com._chanho.movie_recommendation.movie;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import javax.transaction.Transactional;
import java.util.ArrayList;
import java.util.List;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Transactional
@SpringBootTest
@AutoConfigureMockMvc
public class MovieControllerTest {
@Autowired
MockMvc mockMvc;
@Autowired
MovieRepo movieRepo;
@Autowired
ObjectMapper objectMapper;
@DisplayName("checkRecommendation")
@Test
public void checkRecommendation() throws Exception {
List<MovieData> movieDataList = new ArrayList<>();
for(long i = 1L; i <= 10L; i++) {
MovieData movieData = movieRepo.findById(i)
.orElseThrow(() -> new IllegalArgumentException("test failed"))
.toMovieData();
movieDataList.add(movieData);
}
RecommendationDto recommendationDto = RecommendationDto.builder()
.pickedMovies(movieDataList)
.build();
String requestBody = objectMapper.writeValueAsString(recommendationDto);
MvcResult result = mockMvc.perform(post("/api/movie/recommendation")
.content(requestBody)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andDo(print()).andReturn();
String content = result.getResponse().getContentAsString();
System.out.println(content);
}
}
영화 1 ~ 영화 10을 선택해보았습니다. 해당 선택으로 골라진 장르는 다음과 같습니다.
Thriller와 Action입니다.
Thriller와 Action을 모두 포함하며, 1~10까지의 영화가 아닌 영화 10개가 반환되어야 합니다.
content를 출력해보면 다음의 결과가 출력됩니다.
Result
[{"id":20,"title":"Money Train (1995)","genres":[{"id":10,"name":"Thriller"},{"id":8,"name":"Action"},{"id":4,"name":"Comedy"},{"id":9,"name":"Crime"},{"id":7,"name":"Drama"}],"tid":11517},
{"id":23,"title":"Assassins (1995)","genres":[{"id":10,"name":"Thriller"},{"id":8,"name":"Action"},{"id":9,"name":"Crime"}],"tid":9691},
{"id":60,"title":"Lawnmower Man 2: Beyond Cyberspace (1996)","genres":[{"id":10,"name":"Thriller"},{"id":8,"name":"Action"},{"id":13,"name":"Sci-Fi"}],"tid":11525},
{"id":63,"title":"From Dusk Till Dawn (1996)","genres":[{"id":10,"name":"Thriller"},{"id":11,"name":"Horror"},{"id":8,"name":"Action"},{"id":4,"name":"Comedy"}],"tid":755},
{"id":69,"title":"Screamers (1995)","genres":[{"id":10,"name":"Thriller"},{"id":8,"name":"Action"},{"id":13,"name":"Sci-Fi"}],"tid":9102},
{"id":71,"title":"\"Crossing Guard, The (1995)\"","genres":[{"id":10,"name":"Thriller"},{"id":8,"name":"Action"},{"id":9,"name":"Crime"},{"id":7,"name":"Drama"}],"tid":27526},
{"id":81,"title":"Nick of Time (1995)","genres":[{"id":10,"name":"Thriller"},{"id":8,"name":"Action"}],"tid":2086},
{"id":85,"title":"Broken Arrow (1996)","genres":[{"id":10,"name":"Thriller"},{"id":8,"name":"Action"},{"id":1,"name":"Adventure"}],"tid":9208},
{"id":119,"title":"Bad Boys (1995)","genres":[{"id":10,"name":"Thriller"},{"id":8,"name":"Action"},{"id":4,"name":"Comedy"},{"id":9,"name":"Crime"},{"id":7,"name":"Drama"}],"tid":9737},
{"id":139,"title":"Die Hard: With a Vengeance (1995)","genres":[{"id":10,"name":"Thriller"},{"id":8,"name":"Action"},{"id":9,"name":"Crime"}],"tid":1572}]
TODO
이렇게 간단하게 장르를 기반으로 한 영화 추천 API를 만들어보았습니다.
만약에 프로젝트를 확장하고 싶다면 rating을 반영하고,
유저 인증정보와 같이 애플리케이션에 필요한 부분들을 추가해주시면 좋을 것 같습니다.
포스팅을 마치도록 하겠습니다.
*저의 글에 대한 피드백이나 지적은 언제나 환영합니다.
'Backend > Spring' 카테고리의 다른 글
Spring Boot의 의존성 관리 및 Bean 생성 과정 (0) | 2021.10.22 |
---|---|
[ SpringBoot ] 장르 기반 간단한 영화 추천 API 설계하기 #1 (0) | 2021.10.15 |
[Spring Boot] JWT token, refresh token을 활용한 회원 도메인 구현 (4) | 2021.09.30 |
[Spring/SpringBoot] Spring Security - Remember Me (0) | 2021.08.11 |
[Spring/SpringMVC] spring 빌드 시 npm 라이브러리 추가하기 (0) | 2021.08.05 |