MovieReview 프로젝트 (1) (MovieReview 프로젝트 설정, 다대다, Entity, Repository, Test)
MovieReview 프로젝트 (1) (MovieReview 프로젝트 설정, 다대다, Entity, Repository, Test)
GuestBook 프로젝트 (마지막) (Reply 댓글 추가 / 수정 / 삭제 적용, @RestController) GuestBook 프로젝트 (마지막) (Reply 댓글 추가 / 수정 / 삭제 적용, @RestController) GuestBook 프로젝트 (9) (QuerydslRepositorySupport, 검
soohykeee.tistory.com
앞서 작성한 test코드가 정상적으로 실행이 되었다면, 현재 H2 db에는 100명의 member, 200개의 review, 100개의 movie와 해당 movieImage들이 존재할 것이다. 해당 데이터들을 활용하여 화면에 보여줘야한다.
- 목록 화면에서 영화의 제목, 이미지, 영화 리뷰 평점, 리뷰 개수를 출력
- 영화 조회 화면에서 영화와 영화의 이미지들, 리뷰의 평균점수, 리뷰 개수 출력
- 리뷰에 대한 정보에는 회원의 이메일, 닉네임과 같은 정보 출력
위 처럼 출력해줘야 하는 데이터들이 있다. 데이터 출력 방식은 @Query를 주로 이용해서 처리할 것이고, 이번 예제에서는 @EntityGraph, 서브쿼리를 활용할 것이다.
https://soohykeee.tistory.com/19
@EntityGraph 란 무엇인가?
프로젝트를 진행하던 중 @EntityGraph 어노테이션을 사용하는 것을 보고 해당 어노테이션을 사용하는 이유와 사용법이 궁금하여 찾아보았다. @EntityGraph 란? 연관관계가 있는 엔티티를 조회할 경우,
soohykeee.tistory.com
현재 작성한 테이블 관계로 보면 Movie, MovieImage, Review를 같이 조인하면, 아래와 같은 구조가 된다.
위의 구조들 처럼 조인을 하고 쿼리를 날려보는 테스트 코드를 작성해보고 실행해봐야한다.
1. 목록 화면에서 보여줄 영화의 제목과 하나의 이미지, 리뷰의 평점, 개수를 출력하기 위한 쿼리는 다음과 같다.
package com.example.moviereview.repository;
import com.example.moviereview.entity.Movie;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface MovieRepository extends JpaRepository<Movie, Long> {
@Query("select m, max(mi), avg(coalesce(r.grade,0)), count(distinct r) " +
"from Movie m " +
"left outer join MovieImage mi on mi.movie = m " +
"left outer join Review r on r.movie = m " +
"group by m")
Page<Object[]> getListPage(Pageable pageable);
}
MovieRepsitoryTests
@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));
}
}
위와 같이 작성하면, 예상과 달리 각 영화마다 이미지를 찾는 쿼리가 실행되면서 비효율적으로 여러번 쿼리가 실행되는 것을 볼 수 있다. 이렇게 중간에 불필요한 동일한 쿼리가 날라가게 된다면 효율이 떨어지므로 수정이 필요하다.
책에서는 mariaDB에 맞춰 쿼리를 짜놓아서, H2에는 다른 방식으로 쿼리를 수정해줘야한다. 아래에 작성한 것처럼 작성해야 원하는 방식으로 쿼리가 날라가게 된다.
package com.example.moviereview.repository;
import com.example.moviereview.entity.Movie;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface MovieRepository extends JpaRepository<Movie, Long> {
@Query("select m, min(mi.inum), min(mi.imgName), avg(coalesce(r.grade,0)), count(distinct r.reviewnum) " +
"from Movie m " +
"left outer join MovieImage mi on mi.movie = m " +
"left outer join Review r on r.movie = m " +
"group by m.mno, m.title, m.regDate, m.modDate, mi.movie, r.movie")
Page<Object[]> getListPage(Pageable pageable);
}
2. 영화 조회 화면에서 보여줄 영화의 모든 이미지들, 리뷰의 평균점수, 리뷰개수를 출력하기 위한 쿼리는 다음과 같다.
// 특정 영화의 모든 이미지와 리뷰를 가져오는 쿼리
@Query("select m, mi, avg(coalesce(r.grade,0)), count(r) " +
"from Movie m " +
"left outer join MovieImage mi on mi.movie = m " +
"left outer join Review r on r.movie = m " +
"where m.mno =:mno " +
"group by mi")
List<Object[]> getMovieWithAll(Long mno);
@Test
public void testGetMovieWithAll() {
List<Object[]> result = movieRepository.getMovieWithAll(7L);
System.out.println(result);
for (Object[] arr : result) {
System.out.println(Arrays.toString(arr));
}
}
3. 특정 영화의 모든 리뷰, 작성한 회원의 닉네임을 출력하기 위한 쿼리는 다음과 같다.
ReviewRepository에 findByMovie 메소드를 추가해준 후, 테스트 코드를 작성한다.
package com.example.moviereview.repository;
import com.example.moviereview.entity.Movie;
import com.example.moviereview.entity.Review;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ReviewRepository extends JpaRepository<Review, Long> {
List<Review> findByMovie(Movie movie);
}
//특정 영화의 모든 리뷰와 회원의 닉네임
@Test
public void testGetMovieReviews() {
Movie movie = Movie.builder().mno(7L).build();
List<Review> result = reviewRepository.findByMovie(movie);
result.forEach(movieReview -> {
System.out.println(movieReview.getReviewnum());
System.out.println("\t" + movieReview.getGrade());
System.out.println("\t" + movieReview.getText());
System.out.println("\t" + movieReview.getMember().getEmail());
System.out.println("---------------------------");
});
}
하지만 위와 같이 작성한 후, 테스트 코드를 실행하면 오류가 발생하게 된다.
오류가 발생하는 이유는, Review 클래스의 Member에 대한 fetch 방식을 LAZY로 지정해주었기 때문이다. LAZY 방식이기에 한번에 Review, Member 객체를 조회할 수 없기에 발생하는 것이다. 해당 문제는 @Transactional 어노테이션을 적용하여도 각 Review 객체의 getMember().getEmail()을 처리할 때 마다 Member 객체를 로딩해야하는 문제가 있다.
따라서 해결할 수 있는 방법은 2가지가 존재한다.
- @Query를 이용해서 조인 처리
- @EntityGraph를 이용해서 Review 객체를 가져올 때 Member 객체를 로딩하는 방법
@EntityGraph에 대해 간단히 설명하자면,
@EntityGraph는 엔티티의 특정한 속성을 같이 로딩하도록 표시하는 어노테이션이다. 기본적으로 JPA를 이용하는 경우에는 연관관계의 fetch를 LAZY로 하는 것이 일반적이지만, @EntityGraph는 이러한 상황에서 발생하는 문제를 해결하기 위해 특정 기능을 수행할 때만 EAGER로 로딩할 수 있도록 해주는 것이다.
- attributePaths : 로딩 설정을 변경하고 싶은 속성의 이름을 배열로 명시
- type : @EntityGraph를 어떤 방식으로 적용할 것인지를 설정
- FETCH 속성값은 attributesPaths에 명시한 속성은 EAGER로 처리하고, 나머지는 LAZY로 처리
- LOAD 속성값은 attributePahts에 명시한 속성은 EAGER로 처리하고, 나머지는 엔티티 클래스에 명시되거나 기본방식으로 처리
따라서 오류를 해결해주기 위해서 Review를 처리할 때 @EntityGraph를 적용해서 Mebmer도 같이 로딩할 수 있도록 변경이 필요하다.
package com.example.moviereview.repository;
import com.example.moviereview.entity.Movie;
import com.example.moviereview.entity.Review;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ReviewRepository extends JpaRepository<Review, Long> {
@EntityGraph(attributePaths = {"member"}, type = EntityGraph.EntityGraphType.FETCH)
List<Review> findByMovie(Movie movie);
}
코드를 수정한 후 실행해보면 정상적으로 테스트 코드가 실행되는 것을 확인할 수 있고, 해당 쿼리를 위해 불필요한 쿼리가 나가지 않는 것도 확인할 수 있다.
4. 회원 삭제 문제와 트랜잭션 처리
다른 공부를 할때도 항상 강조하는 것이 삭제에 대한 문제이다. 특히나 현재 프로젝트에서 설계된 것 처럼 별도의 매핑 테이블을 구성하고, 이를 엔티티로 처리하는 경우 더욱 민감하다. 예를 들어, 현재 회원이 탈퇴를 하게 될 경우 해당 회원이 작성한 모든 리뷰 역시 삭제가 되어야한다. 이런 경우에는 @Transactional을 사용하여 처리해주지 않으면 큰 프로그램의 경우 결함이 발생할 수 있다.
현재 Review에 멤버 1L 가 작성한 리뷰가 2개가 존재한다. 만약 멤버 1L가 삭제된다면, 해당 리뷰 2개도 모두 삭제가 되어야 한다. delete 해줄때의 순서도 참고하고 있는 FK 쪽의 데이터 먼저 삭제한 후, PK 쪽의 데이터를 삭제해줘야 데이터 제약조건에 위배되지 않고 삭제할 수 있다.
public interface ReviewRepository extends JpaRepository<Review, Long> {
@EntityGraph(attributePaths = {"member"}, type = EntityGraph.EntityGraphType.FETCH)
List<Review> findByMovie(Movie movie);
void deleteByMember(Member member);
}
@Commit
@Transactional
@Test
public void testDeleteMember() {
Long mid = 1L;
Member member = Member.builder().mid(mid).build();
reviewRepository.deleteByMember(member);
memberRepository.deleteById(mid);
}