[JPA x QueryDSL] MultipleBagFetchException 해결 방법
2022.07.09 - [공부 기록] - [Rest Assured] Rest Assured로 Mock과 함께 Controller Unit Test 하기
이미 *toMany 대상을 fetchJoin하는 쿼리에 또 다른 *toMany 관계를 fetchJoin하려 할때 MultipleBagFetchException 가 발생한다.
테스트하기 위해 Member와 Team, TeamInfo 엔티티를 만들고 Team에는 Member와 TeamInfo가 oneToMay로 연결 되어 있다고 상황을 설정했다..
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {
@Id
@GeneratedValue
private Long id;
@OneToMany(fetch = FetchType.LAZY, mappedBy = "team")
private List<Member> members = new ArrayList<>();
@OneToMany(fetch = FetchType.LAZY, mappedBy = "team")
private List<TeamInfo> teamInfos = new ArrayList<>();
}
이 상황에서 쿼리를 아래와 같이 작성 할 때 예외가 발생한다.
private List<Team> fetchAll() {
QTeam team = QTeam.team;
return queryFactory
.selectFrom(team )
.leftJoin(team.members).fetchJoin()
.leftJoin(team.teamInfos).fetchJoin()
.fetch();
}
검색해보면 이를 해결하기 위한 여러 방법들이 있다.
- 데이터가 많은 쪽을 fetch join 하고 다른 쪽을 lazy loading한 뒤 default_batch_fetch_size 설정으로 성능 개선 [참고]
- 데이터 타입을 Set으로 변경 [참고]
- fetch join 쿼리를 2회로 나누어 실행 [참고]
- mainQuery와 subQuery를 별도 서비스에서 조회한 뒤 key를 기준으로 조립하기
기존 로직을 최대한 건들고 싶지 않아서 4번 방식을 이용해 Facade에서 조립하려 했지만 N * M 번의 순회를 매번 해야 한다는 것이 아쉬웠다.
2번을 제외하고 1번과 3번 중 고민하였고, 1번은 단순하기 때문에 3번에 대해 테스트 해보겠다.
레퍼런스에 나온 대로 쿼리를 나눠 사용하려면 아래와 같은 쿼리를 작성할 수 있다.
중복되는 쿼리를 mainQuery라는 변수로 뽑아서 각 조건을 추가해봤더니 동일하게MultipleBagFetchException이 발생하여 각각 작성 했다.
private List<Team> fetchAll() {
QTeam team = QTeam.team;
final JPAQuery<Team> teamWithFetchMembers = queryFactory.selectFrom(team)
.leftJoin(team.members).fetchJoin();
teamWithFetchMembers.fetch();
final JPAQuery<Team> teamWithFetchTeamInfos = queryFactory.selectFrom(team)
.leftJoin(team.teamInfos).fetchJoin();
return teamWithFetchTeamInfos.fetch();
}
발생한 쿼리는 다음과 같다.
1번 쿼리
select
team0_.id as id1_2_0_,
members1_.member_id as member_i1_1_1_,
team0_.name as name2_2_0_,
members1_.age as age2_1_1_,
members1_.team_id as team_id4_1_1_,
members1_.username as username3_1_1_,
members1_.team_id as team_id4_1_0__,
members1_.member_id as member_i1_1_0__
from
team team0_
left outer join
member members1_
on team0_.id=members1_.team_id
2번 쿼리
select
team0_.id as id1_2_0_,
teaminfos1_.id as id1_3_1_,
team0_.name as name2_2_0_,
teaminfos1_.name as name2_3_1_,
teaminfos1_.team_id as team_id3_3_1_,
teaminfos1_.team_id as team_id3_3_0__,
teaminfos1_.id as id1_3_0__
from
team team0_
left outer join
team_info teaminfos1_
on team0_.id=teaminfos1_.team_id
이렇게 2번의 fetch 쿼리가 실행되며 이에 대한 조회 결과는 아래의 초기화 테스트를 통과한다.
List<Team> result = repository.fetchAll();
Assertions.assertEquals(2, result.size());
for (Team team : result) {
Assertions.assertTrue(Hibernate.isInitialized(team.getMembers()));
Assertions.assertTrue(Hibernate.isInitialized(team.getTeamInfos()));
}
fetch Join을 고민한다는 것은 조회 할 때마다 매번 함께 조회해야 하는 데이터일 확률이 높다.
때문에 따로 조회한 뒤 조립하기보다는 쿼리를 한번 더 실행하더라도 한번에 받아오는 것이 더 좋다고 결정했다.
또, lazy loading을 하고 default_batch_fetch_size 를 설정해도 좋겠지만, 이러한 설정의 도움 없이 발생하는 쿼리를 더 주의 깊게 보며 조심히 다루는 것도 좋은 연습이 된다고 생각한다.