[Spring/Springboot] Fetch Join
2024. 12. 15. 23:07ㆍCS/Spring
Fetch Join 사용하기 이전 쿼리
@Override
public Page<Review> dynamicQueryWithBooleanBuilder(String storeName, Pageable pageable) {
BooleanBuilder predicate = new BooleanBuilder();
if (storeName != null) {
predicate.and(review.store.name.eq(storeName));
}
// 리뷰와 스토어 조인을 통해 페이징 쿼리 작성
List<Review> reviews = jpqlQueryFactory
.selectFrom(review)
.join(review.store, store) // 리뷰와 스토어를 조인
.where(predicate)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// 전체 데이터 수 조회
long total = jpqlQueryFactory
.selectFrom(review)
.join(review.store, store)
.where(predicate)
.fetchCount();
return new PageImpl<>(reviews, pageable, total);
}
- 이 쿼리는 N+1 문제가 발생한 쿼리입니다.
- N+1 문제는 특정 엔티티를 조회할 때, 연관된 엔티티를 지연 로딩(Lazy Loading) 방식으로 가져오면서 발생하는 문제입니다.
- 이 경우, 하나의 store에 대한 리뷰 데이터를 가져오기 위해 review 엔티티를 조회하는 쿼리를 수행하고,
- 이후 연관된 member와 store 엔티티의 데이터를 각각 별도로 조회하는 추가 쿼리가 실행됩니다.
- 이 예제에서는 Review 엔티티를 조회하면서 연관된 Member와 Store 엔티티를 각각 Lazy Loading으로 가져오기 때문에, 각 리뷰마다 member와 store 데이터를 조회하는 쿼리가 발생하고 있습니다.
- 따라서 리뷰의 수(N)에 따라 추가적인 쿼리가 발생하게 되어, 이것이 N+1 문제를 야기합니다
Executing findReviewByStoreName with parameters:
storeName: 요아정
Hibernate:
/* select
review
from
Review review
inner join
review.store as store
where
review.store.name = ?1 */ select
r1_0.id,
r1_0.body,
r1_0.created_at,
r1_0.member_id,
r1_0.score,
r1_0.store_id,
r1_0.updated_at
from
review r1_0
join
store s1_0
on s1_0.id=r1_0.store_id
where
s1_0.name=? limit ?,?
Hibernate:
/* select
count(review)
from
Review review
inner join
review.store as store
where
review.store.name = ?1 */ select
count(r1_0.id)
from
review r1_0
join
store s1_0
on s1_0.id=r1_0.store_id
where
s1_0.name=?
Hibernate:
select
m1_0.id,
m1_0.address,
m1_0.created_at,
m1_0.email,
m1_0.gender,
m1_0.inactive_date,
m1_0.member_status,
m1_0.name,
m1_0.point,
m1_0.social_type,
m1_0.spec_address,
m1_0.updated_at
from
member m1_0
where
m1_0.id=?
Hibernate:
select
s1_0.id,
s1_0.address,
s1_0.created_at,
s1_0.name,
s1_0.region_id,
s1_0.score,
s1_0.updated_at
from
store s1_0
where
s1_0.id=?
Review{id=4, member=이다혜, store=요아정, body='음식이 맛있고 사장님이 친절해요', score=4.5}
Review{id=4, member=이다혜, store=요아정, body='음식이 맛있고 사장님이 친절해요', score=4.5}
Fetch Join 사용한 후 쿼리
1. QueryDSL
@Override
public Page<Review> dynamicQueryWithBooleanBuilder(String storeName, Pageable pageable) {
BooleanBuilder predicate = new BooleanBuilder();
if (storeName != null) {
predicate.and(review.store.name.eq(storeName));
}
// Fetch Join으로 리뷰와 스토어, 멤버를 함께 로딩
List<Review> reviews = jpqlQueryFactory
.selectFrom(review)
.join(review.store, store).fetchJoin() // 스토어를 Fetch Join
.join(review.member, member).fetchJoin() // 멤버를 Fetch Join
.where(predicate)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// 전체 데이터 수 조회 (이 때는 Fetch Join을 사용하지 않아도 됨)
long total = jpqlQueryFactory
.selectFrom(review)
.join(review.store, store)
.where(predicate)
.fetchCount();
return new PageImpl<>(reviews, pageable, total);
}
2. JPQL
- 첫 번째 코드 (Page<Review>로 반환, 페이징 지원)
@Query(
"SELECT r FROM Review r " +
"JOIN FETCH r.store s " +
"WHERE (:storeName IS NULL OR s.name = :storeName)"
)
Page<Review> findReviewsByStoreName(
@Param("storeName") String storeName,
Pageable pageable
);
@Query(
"SELECT COUNT(r) FROM Review r " +
"JOIN r.store s " +
"WHERE (:storeName IS NULL OR s.name = :storeName)"
)
long countReviewsByStoreName(@Param("storeName") String storeName);
- 페이징 지원: Page<Review> 타입을 반환하도록 되어 있어, Pageable 매개변수를 사용해 클라이언트가 원하는 페이지 크기와 페이지 번호에 맞는 결과를 쉽게 조회할 수 있습니다. 이는 페이징이 필요한 경우 유용합니다.
- 결과 개수 쿼리 추가 필요: 페이징을 구현하기 위해 총 결과 개수를 알아야 하는데, 이 때문에 countReviewsByStoreName이라는 추가 쿼리가 필요합니다. 페이징을 위해 @Query 두 개를 사용해야 하는 번거로움이 있지만, 페이징이 잘 지원됩니다.
- 성능 효율성: 페이징과 개수 조회를 따로 수행하므로, 대량의 데이터를 다룰 때 페이징의 장점이 극대화됩니다. 페이징된 데이터만 불러오고 전체 결과 개수만 추가로 조회하므로 메모리 사용량을 줄일 수 있습니다.
- 두 번째 코드 (List<Review>로 반환, 페이징 미지원)
@Query("SELECT r FROM Review r JOIN FETCH r.member JOIN FETCH r.store WHERE r.store.name = :storeName")
List<Review> findReviewByStoreName(@Param("storeName") String storeName);
- 페이징 미지원: List<Review> 타입을 반환하기 때문에, 페이징을 지원하지 않습니다. 모든 데이터를 한 번에 조회하게 되므로, 데이터가 많을 경우 성능에 영향을 줄 수 있습니다.
- 단일 쿼리로 전체 데이터 조회: 한 번의 @Query로 모든 데이터를 조회할 수 있으므로, 전체 데이터를 한 번에 받아오는 경우에는 편리할 수 있습니다. 하지만, 대량의 데이터에 대해 모두 조회하면 메모리 과부하가 발생할 수 있습니다.
- 결과 개수 쿼리 필요 없음: 페이징을 고려하지 않으므로, 전체 개수를 확인하기 위한 추가 쿼리가 필요하지 않습니다. 이 코드는 단순히 주어진 조건에 맞는 모든 리뷰 데이터를 가져올 때 유용합니다.
- 이 쿼리는 Review를 조회하면서 Store와 Member의 모든 필요한 정보를 join fetch를 통해 가져오기 때문에 추가 쿼리가 발생하지 않습니다. fetch join이 Lazy Loading을 방지하고, 한 번의 쿼리로 모든 데이터를 가져오므로 N+1 문제가 발생하지 않는 것이 확인됩니다.
- 첫 번째 쿼리: Review와 연관된 Store, Member 엔티티가 함께 조회되므로 추가적인 쿼리가 발생하지 않습니다.
- 두 번째 쿼리: 데이터의 총 개수를 구하기 위한 count 쿼리로, fetch join을 사용하지 않고 Review와 Store를 조인하여 개수를 세기 때문에 성능에 문제가 없습니다.
Executing findReviewByStoreName with parameters: storeName: 요아정 Hibernate: /* select review from Review review inner join fetch review.store as store inner join fetch review.member as member1 where review.store.name = ?1 */ select r1_0.id, r1_0.body, r1_0.created_at, m1_0.id, m1_0.address, m1_0.created_at, m1_0.email, m1_0.gender, m1_0.inactive_date, m1_0.member_status, m1_0.name, m1_0.point, m1_0.social_type, m1_0.spec_address, m1_0.updated_at, r1_0.score, s1_0.id, s1_0.address, s1_0.created_at, s1_0.name, s1_0.region_id, s1_0.score, s1_0.updated_at, r1_0.updated_at from review r1_0 join store s1_0 on s1_0.id=r1_0.store_id join member m1_0 on m1_0.id=r1_0.member_id where s1_0.name=? limit ?,? Hibernate: /* select count(review) from Review review inner join review.store as store where review.store.name = ?1 */ select count(r1_0.id) from review r1_0 join store s1_0 on s1_0.id=r1_0.store_id where s1_0.name=? Review{id=4, member=이다혜, store=요아정, body='음식이 맛있고 사장님이 친절해요', score=4.5} Review{id=4, member=이다혜, store=요아정, body='음식이 맛있고 사장님이 친절해요', score=4.5}
Fetch Join이란?
- Fetch Join은 JPA에서 연관된 엔티티를 한 번의 쿼리로 함께 조회할 수 있도록 도와주는 기능 → 데이터베이스와의 불필요한 통신을 줄이고 성능을 향상시킬 수 있음
Lazy Loading(지연 로딩)과 N+1 문제
- JPA에서는 기본적으로 연관된 엔티티를 Lazy Loading으로 로딩
- 즉, 연관된 엔티티는 실제로 필요한 시점이 될 때까지 데이터베이스에서 조회하지 않고, 참조할 때 쿼리가 추가로 발생
@Entity
public class Review {
@ManyToOne(fetch = FetchType.LAZY)
private Store store;
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
}
- 위와 같이 Review가 Store와 Member를 Lazy로 로딩한다면
- Review를 조회하는 쿼리가 실행될 때는 Review만 조회하고 Store와 Member는 조회X
- Store나 Member 필드를 실제로 접근하는 순간에 각 엔티티별로 추가 쿼리가 발생
- 이로 인해 리뷰 10개를 조회하면 Review 조회 쿼리 1개 + 각 Store와 Member를 조회하는 추가 쿼리들이 실행되어 총 21개의 쿼리가 발생 가능 ⇒ N+1 문제
Fetch Join의 역할
- Fetch Join은 이러한 N+1 문제를 해결하기 위해 연관된 엔티티들을 한 번의 쿼리로 가져올 수 있게 해주는 방법
- JPA에서 join fetch를 사용하여, 특정 엔티티를 조회하면서 연관된 엔티티를 즉시 로딩(Eager Loading) 방식으로 함께 조회
- Review와 연관된 Store, Member를 Fetch Join으로 한 번에 가져오는 예시
- 위 쿼리는 Review를 조회하면서 Store와 Member도 한 번에 조회하도록 함
@Query("SELECT r FROM Review r JOIN FETCH r.store JOIN FETCH r.member WHERE r.store.name = :storeName")
List<Review> findReviewByStoreName(@Param("storeName") String storeName);
- JOIN FETCH: JOIN FETCH r.store와 JOIN FETCH r.member를 사용해, Review 엔티티와 연관된 Store와 Member 엔티티를 즉시 로딩으로 함께 가져옴
- 한 번의 쿼리로 데이터 가져오기: Review 엔티티를 조회하면서 Store와 Member 엔티티의 데이터를 모두 불러오기 때문에, 추가적인 쿼리 없이 데이터를 한 번에 조회가능 ⇒ 이로 인해 N+1 문제가 발생X
Fetch Join의 장점과 효과
- 성능 최적화: Fetch Join을 사용하면 여러 연관 엔티티를 한 번에 조회할 수 있으므로, 데이터베이스와의 불필요한 왕복을 줄여줍니다. 이는 특히 연관 데이터가 많은 상황에서 성능을 크게 향상시킵니다.
- 코드 간결화: Fetch Join을 사용하면 각 연관 데이터를 개별 쿼리로 조회할 필요가 없어 코드가 더 간단해지고 직관적입니다.
- N+1 문제 해결: Fetch Join을 사용하여 연관된 데이터를 한 번에 가져오면, 추가적인 쿼리 발생을 방지하여 N+1 문제를 해결할 수 있습니다.
@Query("SELECT r FROM Review r JOIN FETCH r.store JOIN FETCH r.member WHERE r.store.name = :storeName")
List<Review> findReviewByStoreName(@Param("storeName") String storeName);
- Review 엔티티를 조회하면서 연관된 Store와 Member를 함께 가져오는 한 번의 쿼리
- 만약 storeName이 "요아정"인 리뷰가 10개 있다고 가정하면, 이 쿼리는 다음과 같이 동작
SELECT
r.id, r.body, r.score, r.created_at,
s.id, s.name, s.address, s.score,
m.id, m.name, m.email, m.point
FROM
review r
JOIN
store s ON r.store_id = s.id
JOIN
member m ON r.member_id = m.id
WHERE
s.name = '요아정';
- 이 쿼리의 결과로 Review, Store, Member 데이터를 모두 한 번에 조회하므로, 추가 쿼리가 발생하지 않으며 N+1 문제가 해결
'CS > Spring' 카테고리의 다른 글
[Spring/Springboot] QueryDSL이란? (0) | 2024.12.22 |
---|---|
[Spring/Springboot] @EntityGraph (0) | 2024.12.22 |
[Spring/Springboot] JPA 다양한 쿼리 방법 - JPQL/QueryDSL (0) | 2024.12.15 |
[Spring/Springboot] 영속성(Persistence) - 1차 캐시/변경 감지(Dirty Checking)/지연 로딩(Lazy Loading) (0) | 2024.12.15 |
[Spring/Springboot] 도메인/양방향매핑/N+1 문제 (0) | 2024.12.15 |