오늘은 머플러 앱 개발 중 맞닥뜨린 다중 패치 조인을 해야 하는 상황에 대한 포스팅을 시작하겠습니다.
🚨 문제 상황
"목표 생성" 기능을 개발 중
기존 회원의 이미 등록한 목표들과 회원의 카테고리들이 필요했다.
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로 매핑되어있는 goals와 categories 컬렉션 두 개가 필요했다.
기존 코드
/* 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);
// ... (생략)
}
findByIdAndFetchGoals으로 goals를 패치 조인해 오고,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를 조회해야 한다 점에서 코드 상 깔끔하지 못하다는 인상이 들기는 했다..
일단 지금 찾은 최선의 방법은 이와 같았는데, 실무에서도 이렇게 사용하는지는 기회가 된다면 알아보고 싶다!