[코드로 배우는 스프링부트 웹 프로젝트] -영화 리뷰 게시판(1) : Repository 작성

Jongwon·2023년 1월 14일
0

먼저 Repository부터 생성하겠습니다. MovieRepositoryMovieImageRepository를 생성하고 JpaRepository를 상속받도록 합니다.

MovieRepository

public interface MovieRepository extends JpaRepository<Movie, Long> {}

MovieRepository

public interface MovieImageRepository extends JpaRepository<MovieImage, Long> {}

이어서 테스트 코드를 통해 DB에 Movie 데이터 100개와, 각각에 MovieImage 데이터를 삽입하겠습니다.

MovieRepositoryTests

package org.zerock.mreview.repository;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Commit;
import org.springframework.transaction.annotation.Transactional;
import org.zerock.mreview.entity.Movie;
import org.zerock.mreview.entity.MovieImage;

import java.util.UUID;
import java.util.stream.IntStream;

@SpringBootTest
public class MovieRepositoryTests {

    @Autowired
    private MovieImageRepository imageRepository;

    @Autowired
    private MovieRepository movieRepository;

    @Commit
    @Transactional
    @Test
    public void insertMovies() {
        IntStream.rangeClosed(1, 100).forEach(i -> {
            Movie movie = Movie.builder().title("Movie....." + i).build();

            System.out.println("---------------------------");
            
            movieRepository.save(movie);
            
            int count = (int)(Math.random()*5)+1;
            
            for(int j = 0; j < count; j++) {
                MovieImage movieImage = MovieImage.builder()
                        .uuid(UUID.randomUUID().toString())
                        .movie(movie)
                        .imgName("test"+j+".jpg").build();
                
                imageRepository.save(movieImage);
            }
            System.out.println("---------------------------");
        });
    }
}

@Commit 어노테이션은 Test에서 DB관련 작업을 진행한 후, 결과를 RollBack하지 않고 DB에서 유지하라는 의미입니다.
Movie가 생성되는 동시에 MovieImage가 생성되어야 하므로 Transaction 처리를 해주어야 합니다.



멤버에 관해서도 레포지토리를 생성하고 데이터를 삽입하겠습니다.

MemberRepository

public interface MemberRepository extends JpaRepository<Member, Long> {}

MemberRepositoryTests

package org.zerock.mreview;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.zerock.mreview.entity.Member;
import org.zerock.mreview.repository.MemberRepository;

import java.util.stream.IntStream;

@SpringBootTest
public class MemberRepositoryTests {

    @Autowired
    private MemberRepository memberRepository;

    @Test
    public void insertMembers() {
        IntStream.rangeClosed(1, 100).forEach(i -> {
            Member member = Member.builder()
                    .email("r" + i + "@aaa.com")
                    .pw("1111")
                    .nickname("reviewer" + i)
                    .build();

            memberRepository.save(member);
        });
    }
}


두 테스트를 실행하면 DB에 더미 데이터가 생성됩니다.

Movie와 Member의 더미 데이터가 생성되었으므로, 매핑 테이블에 데이터를 추가할 수 있습니다. ReviewRepository를 생성하고 데이터를 삽입하겠습니다.

ReviewRepository

public interface ReviewRepository extends JpaRepository<Review, Long> {}

ReviewRepositoryTests

package org.zerock.mreview.repository;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.zerock.mreview.entity.Member;
import org.zerock.mreview.entity.Movie;
import org.zerock.mreview.entity.Review;

import java.util.stream.IntStream;

@SpringBootTest
public class ReviewRepositoryTests {

    @Autowired
    private ReviewRepository reviewRepository;

    @Test
    public void insertMovieReviews() {
        IntStream.rangeClosed(1, 200).forEach(i -> {
            Long mno = (long)(Math.random()*100)+1;

            Long mid = ((long)(Math.random()*100)+1);
            Member member = Member.builder().mid(mid).build();

            Review movieReview = Review.builder()
                    .member(member)
                    .movie(Movie.builder().mno(mno).build())
                    .grade((int)(Math.random()*5)+1)
                    .text("이 영화에 대한 느낌..." + i)
                    .build();

            reviewRepository.save(movieReview);
        });
    }
}





목록 화면에서는 영화 번호, 이미지, 평점 평균, 리뷰 수, 날짜를 출력하고자 합니다.
영화와 영화 이미지는 1:N 관계이고, 영화와 리뷰 역시 1:N 관계이므로 Left Join하면 됩니다.

이때 사진을 여러장 가져오기 위해서 max(mi)를 하게 된다면 영화-리뷰 관계의 1개의 쿼리의 결과마다 영화 이미지를 모두 불러오는 N+1문제가 발생하게 되므로, mi중 1개만 불러오도록 합니다.
MovieRepository

    @Query("select m, mi, avg(coalesce(r.grade, 0)), count(distinct r) from Movie m "
            + "left join MovieImage mi on mi.movie = m "
            + "left join Review r on r.movie = m group by m")
    Page<Object[]> getListPage(Pageable pageable);

리스트를 불러오는 테스트를 작성합니다.
MovieRepositoryTests

    @Test
    public void testListPage() {
        PageRequest pageRequest = PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "mno"));

        Page<Object[]> result = movieRepository.getListPage(pageRequest);

        for (Object[] objects : result.getContent()) {
            System.out.println(Arrays.toString(objects));
        }
    }


다음으로 특정 영화를 조회하는 쿼리도 Repository에 작성합니다.
MovieRepository

    @Query("select m, mi from Movie m left join MovieImage mi on mi.movie = m where m.mno = :mno")
    List<Object[]> getMovieWithAll(Long mno);

94번 영화를 출력하는 테스트도 생성합니다.
MovieRepositoryTests

    @Test
    public void testGetMovieWithAll() {
        List<Object[]> result = movieRepository.getMovieWithAll(94L);

        System.out.println(result);

        for(Object[] arr : result) {
            System.out.println(Arrays.toString(arr));
        }
    }

특정 영화를 조회할 때 리뷰 수와 평점도 넣어야 합니다. 하지만 avgcount를 하기 위해서는 group by가 필요한데, 영화와 영화 이미지가 1:N 관계이기 때문에 Join을 했다면 영화로 group by가 불가능합니다. 따라서 MovieImage를 기준으로 group by하도록 위의 조회 퀴리를 수정합니다.

MovieRepository

//getMovieWithAll() 수정
    @Query("select m, mi, avg(coalesce(r.grade, 0)), count(r) "
            + "from Movie m "
            + "left join MovieImage mi on mi.movie = m "
            + "left join Review r on r.movie = m "
            + "where m.mno = :mno "
            + "group by mi")
    List<Object[]> getMovieWithAll(Long mno);






특정 영화의 모든 리뷰를 리스트로 가져오도록 Repository에 Movie 객체를 전달하면 Review를 반환하는 메서드를 생성해줍니다.

ReviewRepository

public interface ReviewRepository extends JpaRepository<Review, Long> {

    List<Review> findByMovie(Movie movie);
}

테스트 코드를 아래와 같이 작성해보겠습니다.

    @Test
    public void testGetMovieReviews() {
        Movie movie = Movie.builder().mno(92L).build();

        List<Review> result = reviewRepository.findByMovie(movie);

        result.forEach(movieReview -> {
            System.out.print(movieReview.getReviewnum());
            System.out.print("\t"+movieReview.getGrade());
            System.out.print("\t"+movieReview.getText());
            System.out.print("\t"+movieReview.getMember().getEmail());
            System.out.println("------------------");
        });
    }

하지만 테스트를 실행해보면 에러가 발생합니다.

이전에도 보았던 no Session에러입니다.(이전글) Review 엔티티가 Member를 가져올 때 Lazy Loading 방식을 채택했기 때문에 발생합니다. 하지만 이전 글처럼 @Transactional을 사용해도 Member를 다시 로딩해야 하므로 아래와 같이 Member 호출 쿼리도 계속 사용됩니다.

이러한 방법을 해결하는 방법은 2가지가 있습니다.

  1. 쿼리에 Join을 사용
  2. @EntityGraph사용

1번은 이미 아는 방법이기 때문에 2번을 이용하여 수정하겠습니다.
@EntityGraph방식은 fetchtype이 지정되어 있어도 그래프 방식에 지정된 방식으로 데이터를 검색합니다. 따라서 @EntityGraph에 엔티티와 타입을 지정해주면 Lazy로 이미 지정해둔 방식도 Eager로 처리합니다.

@EntityGraph(attributePaths = {?}, type = ?)
attributePaths에는 로딩 설정을 변경할 엔티티를 작성해줍니다.
type은 Fetch방식(지정한 엔티티 제외 Lazy 처리)과 Load방식(지정한 엔티티는 Eager, 나머지는 엔티티에서 지정한대로)이 있습니다.

아래와 같이 findByMovie를 수정합니다.

ReviewRepository

    @EntityGraph(attributePaths = {"member"}, type = EntityGraph.EntityGraphType.FETCH)
    List<Review> findByMovie(Movie movie);

테스트를 다시 실행하여 결과를 보면 @EntityGraph가 자동적으로 Join 연산을 실행해주고, 결과도 한번에 출력되는 것을 확인할 수 있습니다.






M:N 관계에서 삭제 처리를 할 때는 매핑 테이블에 있는 연관된 데이터 역시 삭제하여야 합니다. 예를 들어 Member에 있는 레코드 하나를 삭제한다면, 그 Member가 작성한 모든 Review도 삭제해야합니다.

Review에서 Member가 작성한 모든 Review를 삭제한 후, Member를 삭제하는 방식으로 진행하겠습니다.

ReviewRepository

    void deleteByMember(Member member);

MemberRepositoryTests

    @Test
    @Transactional
    @Commit
    public void testDeleteMember() {
        Long mid = 1L;

        Member member = Member.builder().mid(mid).build();

        reviewRepository.deleteByMember(member);
        memberRepository.deleteById(mid);
    }

두 Repository에서 삭제처리를 하는데 순서가 중요하기 때문에(Review 우선 삭제) @Transactional어노테이션이 필요합니다.

하지만 @Commit을 통해 실제 데이터를 삭제하고자 하면 review 삭제쿼리가 여러번 호출됩니다.

때문에 @Query로 직접 지정해주어야 한번의 호출로 데이터를 삭제할 수 있습니다.

ReviewRepository

//deleteByMember() 수정
    @Modifying
    @Query("delete from Review r where r.member = :member")
    void deleteByMember(Member member);

profile
Backend Engineer

0개의 댓글