본문 바로가기

Backend/Spring

[ SpringBoot ] 장르 기반 간단한 영화 추천 API 설계하기 #2

안녕하세요.

 

https://chanho0912.tistory.com/93

 

[ SpringBoot ] 장르 기반 간단한 영화 추천 API 설계하기 #1

안녕하세요. 이번에는 간단한 토이 프로젝트를 소개해드리겠습니다. 간단하게 영화 추천 API를 설계해보려 합니다. 전체 코드는 https://github.com/KimChanHoLeeJunSung/MovieRecommendationApplication GitHub -..

chanho0912.tistory.com

 

저번 포스팅에 이어 이번 포스팅에서는 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을 반영하고,

유저 인증정보와 같이 애플리케이션에 필요한 부분들을 추가해주시면 좋을 것 같습니다.

 

 

포스팅을 마치도록 하겠습니다.

 

*저의 글에 대한 피드백이나 지적은 언제나 환영합니다.