[성능 개선] 다중 패치 조인 (Fetch Join) 고민 : MultipleBagFetchException

임하정·2024년 4월 2일
post-thumbnail

오늘은 머플러 앱 개발 중 맞닥뜨린 다중 패치 조인을 해야 하는 상황에 대한 포스팅을 시작하겠습니다.



🚨 문제 상황

"목표 생성" 기능을 개발 중
기존 회원의 이미 등록한 목표들회원의 카테고리들이 필요했다.

Member 엔티티는 다음과 같이 구성되어 있었다.

public class Member extends BaseTimeEntity implements Persistable<String>, UserDetails {

    @Id
    private String id;

    @Column(length = 20)
    private String name;

    private String profileImg;

    @Enumerated(EnumType.STRING)
    private SocialType socialType;

    @Enumerated(EnumType.STRING)
    private Role role;

    @Column
    private String refreshToken;

    @Builder.Default
    @Enumerated(EnumType.STRING)
    private Status status = ACTIVE;

    @Builder.Default
    @OneToMany(mappedBy = "member")
    private List<Goal> goals = new ArrayList<>();
  
    @OneToMany(mappedBy = "member", cascade = CascadeType.ALL)
    @Builder.Default
    private List<Category> categories = new ArrayList<>();

여기서 @OneToMany로 매핑되어있는 goalscategories 컬렉션 두 개가 필요했다.


기존 코드

/* GoalService */
public void create(GoalCreateRequest request, String memberId) {
        Member member = memberRepository.findByIdAndFetchGoals(memberId)
                .orElseThrow(() -> new MemberException(MEMBER_NOT_FOUND));
        validateGoalInput(request, member);
        // ... (생략)
}
/* MemberRepository */
@Query("SELECT m FROM Member m LEFT JOIN FETCH m.goals WHERE m.id = :memberId")
Optional<Member> findByIdAndFetchGoals(String memberId);

기존에는 해당 member의 Goals만 패치 조인을 통해 같이 불러왔다.

2024-04-02 16:48:20.989 DEBUG 53931 --- [nio-8080-exec-3] org.hibernate.SQL                        : 
    /* SELECT
        m 
    FROM
        Member m 
    LEFT JOIN
        FETCH m.goals 
    WHERE
        m.id = :memberId */ select
            member0_.id as id1_9_0_,
            goals1_.id as id1_6_1_,
            member0_.created_at as created_2_9_0_,
            member0_.deleted_at as deleted_3_9_0_,
            member0_.last_modified_at as last_mod4_9_0_,
            member0_.member_alarm_id as member_11_9_0_,
            member0_.name as name5_9_0_,
            member0_.profile_img as profile_6_9_0_,
            member0_.refresh_token as refresh_7_9_0_,
            member0_.role as role8_9_0_,
            member0_.social_type as social_t9_9_0_,
            member0_.status as status10_9_0_,
            goals1_.created_at as created_2_6_1_,
            goals1_.deleted_at as deleted_3_6_1_,
            goals1_.last_modified_at as last_mod4_6_1_,
            goals1_.end_date as end_date5_6_1_,
            goals1_.icon as icon6_6_1_,
            goals1_.member_id as member_10_6_1_,
            goals1_.start_date as start_da7_6_1_,
            goals1_.title as title8_6_1_,
            goals1_.total_budget as total_bu9_6_1_,
            goals1_.member_id as member_10_6_0__,
            goals1_.id as id1_6_0__ 
        from
            members member0_ 
        left outer join
            goal goals1_ 
                on member0_.id=goals1_.member_id 
        where
            member0_.id=?

2024-04-02 16:48:21.123 DEBUG 53931 --- [nio-8080-exec-3] org.hibernate.SQL                        : 
    select
        categories0_.member_id as member_11_1_0_,
        categories0_.id as id1_1_0_,
        categories0_.id as id1_1_1_,
        categories0_.created_at as created_2_1_1_,
        categories0_.deleted_at as deleted_3_1_1_,
        categories0_.last_modified_at as last_mod4_1_1_,
        categories0_.icon as icon5_1_1_,
        categories0_.is_visible as is_visib6_1_1_,
        categories0_.member_id as member_11_1_1_,
        categories0_.name as name7_1_1_,
        categories0_.priority as priority8_1_1_,
        categories0_.status as status9_1_1_,
        categories0_.type as type10_1_1_ 
    from
        category categories0_ 
    where
        categories0_.member_id=?

실행 결과 위와 같이 goals에 대해 LEFT JOIN FETCH로 불러 온 후,
해당 member의 categories를 조회하는 쿼리가 나갔다.

기존실행시간

실행 시간은 233ms 이었다.


💡 따라서 goals와 categories 두 개의 Fetch Join을 해야겠다고 생각했다.
하지만 무작정 Fetch Join 두 개를 연달아 써도 괜찮나 싶은 의문이 들었다.



☄️ TroubleShooting 1 - 예외 발생

일단 첫 번째 방법으로
두 개의 Fetch Join을 사용하는 쿼리를 만들어서 실행해보려 했다.

다중패치쿼리

그 결과 빌드 과정에서 MultipleBagFetchException 예외가 발생했다!

예외발생

이는 Hibernate에서 발생시킨 예외로,
한 쿼리에서 여러 컬렉션을 FETCH JOIN하는 것을 허용하지 않기 때문이다.
카테시안 곱(Cartesian product)의 발생으로 인한 성능 저하를 막기 위해서이다.



💫 TroubleShooting 2 - 개별 쿼리 실행

마침, Baeldung에 해당 예외를 다룬 글(A Guide to MultipleBagFetchException in Hibernate)이 있었다.
이 글의 Ideal Solution은 Using Multiple Queries였다.

참고하여,
Fetch Join을 개별적으로 두 번 시행해 보았다.

/* MemberService */
public void create(GoalCreateRequest request, String memberId) {
    Member member = memberRepository.findByIdAndFetchGoals(memberId)
            .orElseThrow(() -> new MemberException(MEMBER_NOT_FOUND));
    memberRepository.findByIdAndFetchCategories(memberId);
    validateGoalInput(request, member);
    // ... (생략)
}
  1. findByIdAndFetchGoals으로 goals를 패치 조인해 오고,
  2. findByIdAndFetchCategories으로 categories를 패치 조인했다.

같은 트랜잭션 내에서 동일한 Member 엔티티를 요청하기 때문에,
두 번째 쿼리 실행 시에는 이미 로드된 Member 인스턴스에 categories 컬렉션을 추가하기만 한다.

실행 결과 goals와 categories에 대해 두 번의 패치 조인 쿼리가 나가는 것을 볼 수 있었다.

2024-04-02 17:01:57.812 DEBUG 53968 --- [nio-8080-exec-3] org.hibernate.SQL                        : 
    /* SELECT
        m 
    FROM
        Member m 
    LEFT JOIN
        FETCH m.goals 
    WHERE
        m.id = :memberId */ select
            member0_.id as id1_9_0_,
            goals1_.id as id1_6_1_,
            member0_.created_at as created_2_9_0_,
            member0_.deleted_at as deleted_3_9_0_,
            member0_.last_modified_at as last_mod4_9_0_,
            member0_.member_alarm_id as member_11_9_0_,
            member0_.name as name5_9_0_,
            member0_.profile_img as profile_6_9_0_,
            member0_.refresh_token as refresh_7_9_0_,
            member0_.role as role8_9_0_,
            member0_.social_type as social_t9_9_0_,
            member0_.status as status10_9_0_,
            goals1_.created_at as created_2_6_1_,
            goals1_.deleted_at as deleted_3_6_1_,
            goals1_.last_modified_at as last_mod4_6_1_,
            goals1_.end_date as end_date5_6_1_,
            goals1_.icon as icon6_6_1_,
            goals1_.member_id as member_10_6_1_,
            goals1_.start_date as start_da7_6_1_,
            goals1_.title as title8_6_1_,
            goals1_.total_budget as total_bu9_6_1_,
            goals1_.member_id as member_10_6_0__,
            goals1_.id as id1_6_0__ 
        from
            members member0_ 
        left outer join
            goal goals1_ 
                on member0_.id=goals1_.member_id 
        where
            member0_.id=?
2024-04-02 17:01:57.821 DEBUG 53968 --- [nio-8080-exec-3] org.hibernate.SQL                        : 
    /* SELECT
        m 
    FROM
        Member m 
    LEFT JOIN
        FETCH m.categories 
    WHERE
        m.id = :memberId */ select
            member0_.id as id1_9_0_,
            categories1_.id as id1_1_1_,
            member0_.created_at as created_2_9_0_,
            member0_.deleted_at as deleted_3_9_0_,
            member0_.last_modified_at as last_mod4_9_0_,
            member0_.member_alarm_id as member_11_9_0_,
            member0_.name as name5_9_0_,
            member0_.profile_img as profile_6_9_0_,
            member0_.refresh_token as refresh_7_9_0_,
            member0_.role as role8_9_0_,
            member0_.social_type as social_t9_9_0_,
            member0_.status as status10_9_0_,
            categories1_.created_at as created_2_1_1_,
            categories1_.deleted_at as deleted_3_1_1_,
            categories1_.last_modified_at as last_mod4_1_1_,
            categories1_.icon as icon5_1_1_,
            categories1_.is_visible as is_visib6_1_1_,
            categories1_.member_id as member_11_1_1_,
            categories1_.name as name7_1_1_,
            categories1_.priority as priority8_1_1_,
            categories1_.status as status9_1_1_,
            categories1_.type as type10_1_1_,
            categories1_.member_id as member_11_1_0__,
            categories1_.id as id1_1_0__ 
        from
            members member0_ 
        left outer join
            category categories1_ 
                on member0_.id=categories1_.member_id 
        where
            member0_.id=?

다중쿼리실행시간

실행 시간은 146ms으로 약 37% 실행 시간을 감소시킬 수 있었다!
그러나 이 방법은 똑같은 member를 조회해야 한다 점에서 코드 상 깔끔하지 못하다는 인상이 들기는 했다..
일단 지금 찾은 최선의 방법은 이와 같았는데, 실무에서도 이렇게 사용하는지는 기회가 된다면 알아보고 싶다!

0개의 댓글