[Spring/Springboot] Fetch Join

2024. 12. 15. 23:07CS/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);

 

  1. 페이징 지원: Page<Review> 타입을 반환하도록 되어 있어, Pageable 매개변수를 사용해 클라이언트가 원하는 페이지 크기와 페이지 번호에 맞는 결과를 쉽게 조회할 수 있습니다. 이는 페이징이 필요한 경우 유용합니다.
  2. 결과 개수 쿼리 추가 필요: 페이징을 구현하기 위해 총 결과 개수를 알아야 하는데, 이 때문에 countReviewsByStoreName이라는 추가 쿼리가 필요합니다. 페이징을 위해 @Query 두 개를 사용해야 하는 번거로움이 있지만, 페이징이 잘 지원됩니다.
  3. 성능 효율성: 페이징과 개수 조회를 따로 수행하므로, 대량의 데이터를 다룰 때 페이징의 장점이 극대화됩니다. 페이징된 데이터만 불러오고 전체 결과 개수만 추가로 조회하므로 메모리 사용량을 줄일 수 있습니다.
  • 두 번째 코드 (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);

 

  1. 페이징 미지원: List<Review> 타입을 반환하기 때문에, 페이징을 지원하지 않습니다. 모든 데이터를 한 번에 조회하게 되므로, 데이터가 많을 경우 성능에 영향을 줄 수 있습니다.
  2. 단일 쿼리로 전체 데이터 조회: 한 번의 @Query로 모든 데이터를 조회할 수 있으므로, 전체 데이터를 한 번에 받아오는 경우에는 편리할 수 있습니다. 하지만, 대량의 데이터에 대해 모두 조회하면 메모리 과부하가 발생할 수 있습니다.
  3. 결과 개수 쿼리 필요 없음: 페이징을 고려하지 않으므로, 전체 개수를 확인하기 위한 추가 쿼리가 필요하지 않습니다. 이 코드는 단순히 주어진 조건에 맞는 모든 리뷰 데이터를 가져올 때 유용합니다.

  • 이 쿼리는 Review를 조회하면서 Store와 Member의 모든 필요한 정보를 join fetch를 통해 가져오기 때문에 추가 쿼리가 발생하지 않습니다. fetch join이 Lazy Loading을 방지하고, 한 번의 쿼리로 모든 데이터를 가져오므로 N+1 문제가 발생하지 않는 것이 확인됩니다.
    1. 첫 번째 쿼리: Review와 연관된 Store, Member 엔티티가 함께 조회되므로 추가적인 쿼리가 발생하지 않습니다.
    2. 두 번째 쿼리: 데이터의 총 개수를 구하기 위한 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의 장점과 효과

  1. 성능 최적화: Fetch Join을 사용하면 여러 연관 엔티티를 한 번에 조회할 수 있으므로, 데이터베이스와의 불필요한 왕복을 줄여줍니다. 이는 특히 연관 데이터가 많은 상황에서 성능을 크게 향상시킵니다.
  2. 코드 간결화: Fetch Join을 사용하면 각 연관 데이터를 개별 쿼리로 조회할 필요가 없어 코드가 더 간단해지고 직관적입니다.
  3. 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 문제가 해결