본문 바로가기

Backend/Spring

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

안녕하세요. 이번에는 간단한 토이 프로젝트를 소개해드리겠습니다.

간단하게 영화 추천 API를 설계해보려 합니다.

 

전체 코드는

https://github.com/KimChanHoLeeJunSung/MovieRecommendationApplication

 

GitHub - KimChanHoLeeJunSung/MovieRecommendationApplication

Contribute to KimChanHoLeeJunSung/MovieRecommendationApplication development by creating an account on GitHub.

github.com

해당 레포지토리에 Public으로 공개해 놓긴 했는데... 포스팅을 따라 하시면서 간단하게 해 보시면 좋을 것 같습니다.

 

시나리오

0. 영화 데이터를 MySQL에 모두 저장해줍니다.

1. 우선 사용자에게 전체 영화 리스트를 넘겨주겠습니다. 

2. 사용자는 서버에 10개의 영화를 선택하여 다시 전송해줍니다.

3. 사용자가 선택한 영화에서 장르만 추출합니다. 가장 많이 선택된 장르 2개를 선정합니다.

4. 전체 영화 리스트 중 선택된 장르 2개가 포함된 영화들을 추천 영화로 반환해줍니다.

 

Dependency

Spring data JPA, QueryDSL, MySQL 이 세 가지가 꼭 설정되어 있어야 합니다.

 

<pom.xml>

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.5.5</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com._chanho</groupId>
	<artifactId>movie_recommendation</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>movie_recommendation</name>
	<description>Demo project for Spring Boot</description>
	<properties>
		<java.version>11</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
		</dependency>
		<!-- https://mvnrepository.com/artifact/com.auth0/java-jwt -->
		<dependency>
			<groupId>com.auth0</groupId>
			<artifactId>java-jwt</artifactId>
			<version>3.18.2</version>
		</dependency>

		<dependency>
			<groupId>com.querydsl</groupId>
			<artifactId>querydsl-apt</artifactId>
			<scope>provided</scope>
		</dependency>

		<dependency>
			<groupId>com.querydsl</groupId>
			<artifactId>querydsl-jpa</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-devtools</artifactId>
			<scope>runtime</scope>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>mysql</groupId>
			<artifactId>mysql-connector-java</artifactId>
			<scope>runtime</scope>
		</dependency>
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-test</artifactId>
		</dependency>
	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
				<configuration>
					<excludes>
						<exclude>
							<groupId>org.projectlombok</groupId>
							<artifactId>lombok</artifactId>
						</exclude>
					</excludes>
				</configuration>
			</plugin>

			<plugin>
				<groupId>com.mysema.maven</groupId>
				<artifactId>apt-maven-plugin</artifactId>
				<version>1.1.3</version>
				<executions>
					<execution>
						<goals>
							<goal>process</goal>
						</goals>
						<configuration>
							<outputDirectory>target/generated-sources/java</outputDirectory>
							<processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
						</configuration>
					</execution>
				</executions>
			</plugin>
		</plugins>
	</build>

</project>

 

저의 경우 유저에 대한 인증정보를 위해 Spring Security와 JWT를 추가해 주었는데 본 포스팅에서는 해당 부분에서 다루지 않겠습니다.

https://chanho0912.tistory.com/85 

 

[Spring Boot] JWT token, refresh token을 활용한 회원 도메인 구현

안녕하세요. 이번 포스팅에서는 JWT token과 refresh token을 활용한 회원 가입, 로그인 구현을 진행해보겠습니다. Spring을 활용하여 애플리케이션을 개발할 때 Rest API를 구현하신다면, JWT token을 들어

chanho0912.tistory.com

 

영화 데이터

데이터는 MovieLens 데이터를 활용하겠습니다.

https://grouplens.org/datasets/movielens/

 

MovieLens

GroupLens Research has collected and made available rating data sets from the MovieLens web site ( The data sets were collected over various periods of time, depending on the size of the set. …

grouplens.org

 

위 사이트에서

이 small 데이터로 활용해보겠습니다.

 

0. 영화 데이터 적재하기

 

1) Entity

Movie Entity

package com._chanho.movie_recommendation.movie;

import com._chanho.movie_recommendation.genre.Genres;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.Set;


@NamedEntityGraph(name="Movies.withGenres", attributeNodes = {
        @NamedAttributeNode("genres")
})
@Entity @Data @Builder
@NoArgsConstructor @AllArgsConstructor
public class Movies {
    @Id
    @GeneratedValue
    private Long id;

    private String title;

    private Long tId;

    @ManyToMany
    private Set<Genres> genres;

    public MovieData toMovieData() {
        return MovieData.builder().movieId(this.id).tId(this.tId).rating(4.0).build();
    }
}

 

영화 엔티티를 위와 같이 설정하였습니다.

 

id: Primary Key

title: 영화 제목

tId: tmdb Id (보통 프런트에서 tmdb API를 많이 활용할 수 있기 때문에 해당 영화의 tmdbId도 같이 저장해주었습니다.)

 

genres: Genres를 Set의 형태로 갖고 있게 만들었습니다. 

 

Genres Entity

package com._chanho.movie_recommendation.genre;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Data
@Entity
@AllArgsConstructor @NoArgsConstructor
public class Genres {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    public Genres(String name) {
        this.name = name;
    }
}

 

장르의 경우 간단하게 장르의 이름만 저장해주었습니다.

 

이제 MovieLens 데이터를 활용하여 해당 엔티티의 스키마에 맞게 저장해주는 API를 먼저 설계해 보겠습니다.

 

 

2) dummy Controller

 

해당 API의 시작 전 데이터베이스에 모든 영화 정보를 기록하는 과정을 dummy라고 했습니다.

 

package com._chanho.movie_recommendation.movie;

import com._chanho.movie_recommendation.genre.GenreService;
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.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.io.*;
import java.util.*;
import java.util.stream.Collectors;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/dummy")
public class AddDummyData {

    private final GenresRepo genresRepo;
    private final GenreService genreService;

    private final MovieRepo movieRepo;
    private final Map<Long, Long> MovieIdToTid;

    @GetMapping("/add-movies")
    public ResponseEntity<?> addMovies() throws IOException {

        File csv = new File("{your_path}\\movies.csv");
        BufferedReader br = new BufferedReader(new BufferedReader(new FileReader(csv)));

        String line = "";
        boolean skipFirstLine = true;
        while ((line = br.readLine()) != null) {
            if(skipFirstLine) {
                skipFirstLine = false;
                continue;
            }

            String[] token = line.split(",");
            Long movieId = Long.parseLong(token[0]);
            String[] genre = token[token.length - 1].split("\\|");

            StringBuilder title = new StringBuilder();
            for(int i = 1; i < token.length - 1; i++) {
                title.append(token[i]);
                if(i != token.length-2) title.append(",");
            }


            movieRepo.save(Movies.builder()
                    .id(movieId).tId(MovieIdToTid.get(movieId))
                    .title(title.toString())
                    .genres(Arrays.stream(genre)
                            .map(genreService::findOrCreateNew)
                            .collect(Collectors.toSet()))
                    .build());

        }

        return ResponseEntity.ok().build();
    }

}

 

movies.csv 파일을 읽어서 line마다 파싱해주었습니다.

 

movies.csv 파일을 보시면, 

 

위와 같은 형태로, movieId, title, genres 형태로 들어가 있습니다.

또한 genres의 경우 '|'를 기반으로 나누어져 있습니다.

 

따라서 ', '를 기준으로 split 한 문자열 배열에서

0번째 원소 -> movieId

마지막 원소 -> genres

나머지 -> title

 

의 형태로 각각 parsing 해주었습니다.

 

첫 번째 라인을 제외하기 위해 skipFirstLine이라는 Boolean 변수를 사용하였습니다.

 

movieRepo.save(Movies.builder()
                    .id(movieId).tId(MovieIdToTid.get(movieId))
                    .title(title.toString())
                    .genres(Arrays.stream(genre)
                            .map(genreService::findOrCreateNew)
                            .collect(Collectors.toSet()))
                    .build());

 

이 부분에서 findOrCreateNew라는 메서드는, 데이터베이스에 해당 이름의 genres가 존재하면 그 genres를 사용하고, 만약에 없다면 새로 저장한다는 메서드입니다.

 

package com._chanho.movie_recommendation.genre;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class GenreService {
    private final GenresRepo genresRepo;

    public Genres findOrCreateNew(String name) {
        return genresRepo.findByName(name).orElseGet(
                () -> genresRepo.save(new Genres(name))
        );
    }
}

 

또한, 저는 movieId를 바로바로 tmdbId로 변환하기 위해서 하나의 @Bean을 등록해주었습니다.

 

package com._chanho.movie_recommendation.api;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.io.*;
import java.util.HashMap;
import java.util.Map;

@Configuration
@RequiredArgsConstructor
public class AppConfig {

    @Bean
    public Map<Long, Long> MovieIdToTid() {
        Map<Long, Long> movieIdToTid = new HashMap<>();

        File csv = new File("{your_path}\\links.csv");
        BufferedReader br = null;
        try {
            br = new BufferedReader(new BufferedReader(new FileReader(csv)));
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }

        String line = "";
        boolean skipFirstLine = true;
        while (true) {
            try {
                assert br != null;
                if ((line = br.readLine()) == null) break;
            } catch (IOException e) {
                e.printStackTrace();
            }

            if (skipFirstLine) {
                skipFirstLine = false;
                continue;
            }

            String[] token = line.split(",");

            if(token.length > 2) {
                Long movieId = Long.parseLong(token[0]);
                Long tId = Long.parseLong(token[2]);

                movieIdToTid.put(movieId, tId);
            }

        }
        return movieIdToTid;
    }
}

 

link.csv 파일에 해당 정보가 들어있습니다.

이게, 가끔 movieId에 해당하는 tmdbId가 없는 경우도 있어서 있는 경우만 반환해주기 위해 예외처리를 추가해주었습니다.

 

이제 해당 api를 호출하면 데이터베이스에 다음과 같이 영화들이 저장되어야 합니다.

 

보니까 small data를 활용했음에도 10000개 정도의 영화가 있는 걸 확인할 수 있었습니다...

 

 

이번 포스팅은 여기까지를 목표로 하겠습니다.

 

이제 다음 포스팅에서는 Spring data JPA와 QueryDSL을 사용하여 여러 API를 만들어보겠습니다.

 

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