일대다 컬렉션에 대한 페치 조인과 페이지네이션을 함께 사용하는 경우 다음과 같은 로그를 확인할 수 있습니다.
일대다 조인을 수행하는 경우 다 쪽의 데이터만큼 결과 row가 증가하기 때문에 DB에서 페이지네이션을 수행할 수 없습니다. 하이버네이트는 메모리 상에서 페이징을 시도하게 되면서 applying in memory
라는 경고 로그를 남깁니다.
이러한 페이지네이션을 개선하기 위해서는 다음 2가지 방법을 사용할 수 있습니다.
- BatchSize
- 프로젝션 + IN절 쿼리 -> 애플리케이션 상에서 조인 수행
BatchSize
BatchSize를 사용하기 위해서는 hibernate.default_batch_size
를 이용해 글로벌로 설정하거나 @BatchSize
를 일대다 컬렉션에 추가하여 개별적으로 설정할 수 있습니다. 만일 사이즈를 5로 설정한 경우 즉시 로딩일 때는 조회 시점에 즉시 5건의 엔티티를 조회하고 6번째 엔티티를 사용하는 시점에 다시 5건의 엔티티를 조회해옵니다. 지연 로딩일 때는 사용하는 시점에 5건의 엔티티를 조회해옵니다.
저는 페이지네이션 조회 시 함께 사용되는 컬렉션 필드에 @BatchSize
를 사용하여 문제를 해결하겠습니다.
@Entity
public class Review {
...
@BatchSize(size = 100)
@OneToMany(mappedBy = "review", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
private List<ReviewImage> images = new ArrayList<>();
...
}
이 때, 기존의 페이지네이션 쿼리에서 일대다 페치 조인을 수행하고 있다면 이를 제거해주어야 BatchSize 설정이 정상 적용됩니다.
컬렉션 페치 조인을 제거하고 다시 쿼리를 날려보면 다음과 같이 IN절을 사용하는 쿼리가 날아가는 것을 확인할 수 있습니다.
@Query("select r from Review r"
+ " join fetch r.applicant a"
+ " join fetch a.volunteer v"
+ " left join fetch v.image"
+ " where r.applicant.recruitment.shelter = :shelter")
Page<Review> findShelterReviewsByShelter(@Param("shelter") Shelter shelter,
Pageable pageable);
Hibernate:
select
r1_0.review_id,
a1_0.applicant_id,
a1_0.created_at,
a1_0.recruitment_id,
a1_0.status,
v1_0.volunteer_id,
v1_0.birth_date,
v1_0.created_at,
v1_0.device_token,
v1_0.email,
v1_0.gender,
i1_0.volunteer_image_id,
i1_0.created_at,
i1_0.image_url,
v1_0.name,
v1_0.password,
v1_0.phone_number,
v1_0.temperature,
v1_0.review_count,
r1_0.content,
r1_0.created_at
from
review r1_0
join
applicant a1_0
on a1_0.applicant_id=r1_0.applicant_id
join
volunteer v1_0
on v1_0.volunteer_id=a1_0.volunteer_id
left join
volunteer_image i1_0
on v1_0.volunteer_id=i1_0.volunteer_id
join
recruitment r2_0
on r2_0.recruitment_id=a1_0.recruitment_id
where
r2_0.shelter_id=?
offset
? rows
fetch
first ? rows only
Hibernate:
select
i1_0.review_id,
i1_0.review_image_id,
i1_0.created_at,
i1_0.image_url
from
review_image i1_0
where
i1_0.review_id in (?, ..., ?)
프로젝션 + IN절
위의 조회 쿼리를 살펴보면 한 번 조회시 20개의 컬럼을 가져오고 있습니다. 이 중 애플리케이션에서 실제로 사용하는 컬럼은 8개로 불필요한 데이터를 다수 조회하고 있습니다.
이를 조금 더 개선하기 위해서는 프로젝션을 사용해야 합니다. 프로젝션을 사용하기 위해서는 조회해오고 싶은 필드를 생성자로 가지는 DTO를 만들어주면 됩니다. 단, 프로젝션을 사용하는 경우 엔티티를 조회해 올 수 없으므로 컬렉션에 대해서는 직접 IN 절을 사용하여 조회해와야 합니다.
다음은 프로젝션과 IN절을 사용하는 쿼리입니다. QueryDSL로 깔끔하게 해결하고 싶었지만 처음 참조 포인트로부터 2depth 이상 참조하는 경우 @QueryInit
이라는 어노테이션을 통해 별도로 초기화를 수행해야 합니다. 현재 엔티티에 해당 어노테이션을 사용하지 않았기 때문에 JPQL과 QueryDSL이 섞여 있습니다.
@Override
public Page<FindShelterReviewByShelterResult> findShelterReviewsByShelter(Shelter shelter,
Pageable pageable) {
List<FindShelterReviewByShelterResult> content
= getShelterReviewsByShelter(shelter, pageable);
...
List<Long> reviewIds = getReviewIds(content);
Map<Long, List<ReviewImage>> imageGroup = getReviewImagesIn(reviewIds);
content.forEach(result -> result.setReviewImageUrls(
imageGroup.get(result.getReviewId()).stream()
.map(ReviewImage::getImageUrl)
.toList()));
return new PageImpl<>(content, pageable, count);
}
private List<FindShelterReviewByShelterResult> getShelterReviewsByShelter(
Shelter shelter,
Pageable pageable) {
return em.createQuery(
"select new com.clova.anifriends.domain.review.repository.response"
+ ".FindShelterReviewByShelterResult(r.reviewId,"
+ " r.createdAt,"
+ " r.content.content,"
+ " v.volunteerId,"
+ " v.name.name,"
+ " v.temperature.temperature,"
+ " vi.imageUrl,"
+ " v.volunteerReviewCount.reviewCount)"
+ " from Review r"
+ " join r.applicant a"
+ " join a.volunteer v"
+ " left join v.image vi"
+ " where a.recruitment.shelter = :shelter",
FindShelterReviewByShelterResult.class)
.setParameter("shelter", shelter)
.setFirstResult((int) pageable.getOffset())
.setMaxResults(pageable.getPageSize())
.getResultList();
}
private List<Long> getReviewIds(List<FindShelterReviewByShelterResult> content) {
return content.stream()
.map(FindShelterReviewByShelterResult::getReviewId)
.toList();
}
private Map<Long, List<ReviewImage>> getReviewImagesIn(List<Long> reviewIds) {
List<ReviewImage> reviewImages = query
.selectFrom(reviewImage)
.where(reviewImage.review.reviewId.in(reviewIds))
.fetch();
return reviewImages.stream().collect(Collectors.groupingBy(ReviewImage::getReviewId));
}
이제 다음과 같이 필요한 컬럼만 조회해오는 최적화된 쿼리 로그를 확인할 수 있습니다.
Hibernate:
select
r1_0.review_id,
r1_0.created_at,
r1_0.content,
a1_0.volunteer_id,
v1_0.name,
v1_0.temperature,
i1_0.image_url,
v1_0.review_count
from
review r1_0
join
applicant a1_0
on a1_0.applicant_id=r1_0.applicant_id
join
volunteer v1_0
on v1_0.volunteer_id=a1_0.volunteer_id
left join
volunteer_image i1_0
on v1_0.volunteer_id=i1_0.volunteer_id
join
recruitment r2_0
on r2_0.recruitment_id=a1_0.recruitment_id
where
r2_0.shelter_id=?
offset
? rows
fetch
first ? rows only
Hibernate:
select
r1_0.review_image_id,
r1_0.created_at,
r1_0.image_url,
r1_0.review_id
from
review_image r1_0
where
r1_0.review_id in (?,?)
실제 해당 쿼리를 사용하는 api를 호출하고 응답 시간을 확인했을 때 평균적으로 약 40ms 정도 차이가 발생하는 것을 확인할 수 있었습니다.
적은 차이이지만 이보다 불필요한 컬럼이 많이 포함되어 있거나 다량의 조회가 발생하는 api인 경우 더 의미있는 차이를 확인할 수 있을 것입니다.
참고
'JPA' 카테고리의 다른 글
엔티티 매니저와 영속성 컨텍스트 (0) | 2023.07.30 |
---|---|
JdbcTemplate batchUpdate()를 이용한 Batch insert (0) | 2023.04.24 |
[querydsl] in절 동적 쿼리 작성 주의점(1=2) (0) | 2023.04.03 |
JPA에서 네이티브 SQL 사용하기 (0) | 2023.03.06 |