현재 프로젝트의 가게 정보를 보여 줄 때, 가게의 리뷰에 대한 평균 평점을 함께 보여줄려고 한다. 아래와 같이 일대다 양방향 관계로 맵핑이 되어있고, 지연로딩을 사용했다. 문제는 가게를 조회할 때 리뷰의 평점까지 함께 조회하면, 가게의 개수 만큼 리뷰(n개)의 쿼리가 발생한다. -> N + 1 문제


그렇다면 원인은 뭘까?
현재 findAll() 메서드는 Spring Data JPA에서 제공하는 쿼리 메서드 기능을 사용했다. 그렇다면 Spring Data JPA의 쿼리 메서드 작동부터 차근차근 생각해보자
select * from Store;
select r from Review r where r.review = 'id'

위에서 말했다시피 쿼리 메서드를 JPQL로 변환하는 과정에서의 문제이기 때문에, 쿼리 메서드를 사용하지 않고 두 테이블을 join하는 쿼리를 JPQL로 직접 정의하면 되지 않을까?
단순 join
실제 join을 사용하는 JPQL을 아래와 같이 작성하고, Test를 진행하면,

@Test
void getAllStoresByJoin() {
List<Store> testStoreListUsingJoin = storeService.findAllWithReviewUsingJoin();
System.out.println(testStoreListUsingJoin.get(0).getReviews());
}
아래와 같이 inner join 쿼리는 잘 작동되지만, 다음과 같은 에러가 발생한다.

각 Store의 Lazy Entity인 Reviews가 아직 초기화되지 않았다는 것을 의미하는데, 그 이유는 JPQL은 결과를 반환 할 때 연관관계를 고려하지 않고, 단지 select 절에 지정한 entity만 조회하기 때문이다. Join은 store에 대한 엔터티만 조회하고, 연관된 review 엔터티는 조회하지 않는다는 것을 알 수 있다.
Fetch Join
위의 문제를 해결하기 위해 JPQL은 Fetch Join을 제공하는데, Fetch join은 연관된 엔터티를 한 번에 같이 조회한다.
아래와 같이 작성하고, 테스트를 진행하면,

@Test
void getAllStoresByFetchJoin() {
List<Store> testStoreListUsingJoin = storeService.findAllWithReviewUsingFetchJoin();
for(Store store : testStoreListUsingJoin){
System.out.println(store.getName());
int tmp = 0;
for(Review review : store.getReviews()){
tmp += review.getScore();
System.out.println(tmp);
}
}
System.out.println(testStoreListUsingJoin.size());
}
아래와 같이 에러 없이 결과값이 나오는 것을 알 수 있다.

실제로 프로젝트를 실행하고, 요청을 시도하면 아래와 같이 원하는 결과값을 얻을 수 있다.

하이버네이트가 5.x 이전 버전을 사용하는 경우 DISTINCT를 사용해서, 중복을 제거해야한다.
@EntityGraph
Fetch Join으로 N+1 문제를 해결하는 것을 확인했다. 그럼 다른 방법은 없을까? 대표적으로는 @EntityGraph가 있다.
실제 @EntityGraph를 사용해서 테스트를 진행해보자. 아래와 같이 코드를 작성하고 위와 같은 똑같은 테스트를 진행했다.

실제 결과를 보면, 아래와 같이 다른 결과를 볼 수 있다. EntityGraph를 사용할 경우 left join을 기본적으로 적용되는 것을 볼 수 있다.

만약 반환 타입이 List가 아닌 Page일 경우에는 어떻게 해야할까?
먼저 Fetch Join으로 한 번 접근해보자.






여러 검색 조건이 붙는 경우와 각 조건을 재사용성을 높이는 방법은 무엇일까? 예를 들면 isDeleted= boolean은 조회 시에, 무조건 적용되는 조건이다. 이러한 설정들을 함수화하여 재사용성을 높일 수는 없을까? 이러한 문제점을 해결하기 위해 내가 채택한 방식은 QueryDSL이다.
Batch Size를 사용하는 경우에 Batch Size 설정에 따라 쿼리의 개수가 결정되지만, QueryDsl을 이용하면 설정과 상관없이 해결 할 수 있다. 아래는 내가 최종적으로 구현한 store 조회 기능이다.

결론적으로 QueryDsl을 사용하여, N + 1 문제 뿐만 아니라 반환되는 평균값도 쿼리를 통해 해결하도록했고, where문에 있는 조건들도 메서드화 하여, 재사용성을 높였다.