발생 문제 : JPA를 통해 아래와 같은 연관관계를 갖는 엔티티를 조회 할 때, fetch join과 Paging을 함께 사용하여 조회 시
fetch join이 적용되지 않고 DB를 Full Scan하여 페이징을 적용하는 상황이 발생한다.
다음과 같이 하나의 Review에 대해 N개의 Image를 가지는 1:N 연관관계로 구성되어 있다.
Paging을 사용하여 10개의 Review를 연관된 Image Entity와 함께 fetch join으로 가져오고자 다음과 같은 쿼리를 작성하였다.
// ReviewRepository.class
@Query("select r from Review r " +
"join r.company c " +
"fetch join r.images i " + // LAZY fetch에 대한 fetch join
"where c.id = :companyId " +
"order by r.createdDate desc"
)
Page<Review> findByCompanyId(Long companyId, Pageable pageable); // 페이징(limit)
그런데 해당 메서드를 수행하면 아래와 같은 경고 로그를 뿜는다.
HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
해당 로그는 쿼리 수행에 대한 결과를 Application Memory에 적재한 후 페이징을 수행했다는 의미로, OOM을 유발할 수 있는 치명적인 문제이다.
또한, 실제 수행된 쿼리 로그를 보아도 Paging에 대한 limit 키워드 없이 DB 내에 존재하는 Review Entity를 모두 가져오게 되어 Paging을 통한 성능상 이점을 가져갈 수 없게 되었다.
발생 이유 : JPA에서 @ToMany 관계에 대해 Paging + fetch join을 수행할 때,
One Entity 기준으로 Many Entity에 대한 데이터를 join하게 되어 데이터의 수가 변한다.
ex) Review 2개, Image 3개일 때 row는 총 6개가 생김. (2 != 6)
따라서 JPA는 어떤 데이터를 기준으로 Paging을 수행해야 하는 지 알 수 없게 된다.
해결 방법 : fetch join을 제거하고, application.properties에 다음 설정을 추가하였다.
// ReviewRepository.class
@Query("select r from Review r " +
"join r.company c " +
"where c.id = :companyId " +
"order by r.createdDate desc"
)
Page<Review> findByCompanyId(Long companyId, Pageable pageable); // 페이징(limit)
// application.properties
spring.jpa.properties.hibernate.default_batch_fetch_size=10
default_batch_fetch_size 옵션은 지연로딩으로 발생하는 쿼리를 IN 절을 통해 한번에 모아서 하나의 쿼리로 보낸다.
이 때, 설정한 값에 해당하는 개수만큼 데이터를 IN절로 모아 한번에 처리한다.
만약 100개의 Review에 대해 연관된 Image 엔티티를 가져오는데 Lazy Loading에 의해 N+1 문제가 발생한다고 가정했을 때,
default_batch_fetch_size를 10으로 설정하면 쿼리 횟수를 아래와 같이 단순하게 약 1/10로 줄일 수 있다.
default_batch_fetch_size 미설정 : 쿼리 1 + 100회 수행 (Review 조회 1 + 각 Review의 Image 목록 조회 100)
default_batch_fetch_size=10: 쿼리 1 + 10회 수행 (Review 조회 1 + 각 Review의 Image 목록 조회 100 / 10)
맞닥뜨린 문제에서는 Paging을 통해 가져오고자 하는 Review의 개수가 10개로 고정되어 있으므로, default_batch_fetch_size를 10으로 지정해 연관관계로 묶인 image를 가져오는데 fetch join을 사용하지 않고 한 번의 쿼리만을 추가로 수행하였다.
* Review 조회 1 + 10개의 Review에 대한 각 image 조회 1 (10 / 10)