org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags
n+1 문제로 인한 성능 저하 현상을 방지하기 위해 엔티티의 연관관계를 Lazy로 전부 발라놓은 뒤 필요한 상황에 fetch join을 사용하여 객체 그래프로 묶어서 가져오려고 했으나 오류가 발생 하였습니다. fetch join을 만능으로 생각하고 있었던지라 몹시 당황스러웠습니다.
오류의 원인
저는 다음과 같이 ToMany관계에 여러개의 fetchJoin을 적용하고 있어서 오류가 발생하였습니다.

기본적으로 JPA에서 Fetch Join의 조건은 다음과 같다고 합니다.
- ToOne은 여러개 사용 가능
- ToMany는 1개만 사용 가능
Querydsl 역시 JPQL로 변환 작업에서 동일한 문제가 발생합니다.

해결 방법
그렇다면 객체 그래프에서 OneToMany관계가 여러개 이고 더 깊게 뻗어 나가는 상황에서는 어떤식으로 성능을 최적화 할지 고민했습니다.
여러 포스팅을 찾아보다가 명쾌한 해답을 발견하고 바로 적용해보았습니다.
방법은 다음과 같습니다.
- default_batch_fetch_size 글로벌 설정을 통해 N+1 문제를 최대한 보장
- @OneToOne, @ManyToOne과 같은 ToOne 관계의 자식 엔티티는 모두 Fetch Join을 적용하여 한방 쿼리를 수행
- @OneToMany, @ManyToMany와 같은 ToMany 관계의 자식 엔티티에 관해서는 가장 데이터가 많은 자식 쪽에 Fetch Join을 사용
□ default_batch_fetch_size 설정
default_batch_fetch_size 설정 전 주문 상품을 조회하는 쿼리중 상품의 옵션을 조회하는 쿼리의 일부분입니다.
조회한 상품의 옵션은 2개로 설정되어 있어 옵션 관련 쿼리가 2번씩 나가고 있습니다. 만약 옵션이 마라탕 같이 많은 상품일 경우 쿼리가 30번 상세 옵션까지 포함하여 60번 나가게 되는 상황이 나올 것 같습니다.
org.hibernate.SQL : # 옵션 1
select
orderoptio0_.order_item_id as order_it3_8_0_,
orderoptio0_.order_option_id as order_op1_8_0_,
orderoptio0_.order_option_id as order_op1_8_1_,
orderoptio0_.option_detail_id as option_d2_8_1_,
orderoptio0_.order_item_id as order_it3_8_1_
from
order_option orderoptio0_
where
orderoptio0_.order_item_id=?
org.hibernate.SQL : # 상세 옵션 1
select
optiondeta0_.option_detail_id as option_d1_6_0_,
optiondeta0_.name as name2_6_0_,
optiondeta0_.option_id as option_i5_6_0_,
optiondeta0_.orders as orders3_6_0_,
optiondeta0_.price as price4_6_0_
from
option_detail optiondeta0_
where
optiondeta0_.option_detail_id=?
org.hibernate.SQL : # 옵션 2
select
option0_.option_id as option_i1_5_0_,
option0_.create_dt as create_d2_5_0_,
option0_.update_dt as update_d3_5_0_,
option0_.item_id as item_id9_5_0_,
option0_.max_select_count as max_sele4_5_0_,
option0_.min_select_count as min_sele5_5_0_,
option0_.name as name6_5_0_,
option0_.orders as orders7_5_0_,
option0_.required as required8_5_0_
from
option option0_
where
option0_.option_id=?
org.hibernate.SQL : # 상세 옵션 2
select
optiondeta0_.option_detail_id as option_d1_6_0_,
optiondeta0_.name as name2_6_0_,
optiondeta0_.option_id as option_i5_6_0_,
optiondeta0_.orders as orders3_6_0_,
optiondeta0_.price as price4_6_0_
from
option_detail optiondeta0_
where
optiondeta0_.option_detail_id=?
default_batch_fetch_size 글로벌 설정에 지정된 수 만큼 in절에 부모 key를 사용하게 해주어 쿼리 수행수가 1/100으로 줄여줍니다.
만약 상품 하나에 옵션이 20개라면 20번 수행될 쿼리를 한번 수행하게됩니다.

다음은 글로벌 설정을 한 후의 쿼리입니다.
총 4번 수행되던 쿼리가 총 2번 수행으로 개선되었습니다.
org.hibernate.SQL : # 옵션 1
select
optiondeta0_.option_detail_id as option_d1_6_0_,
optiondeta0_.name as name2_6_0_,
optiondeta0_.option_id as option_i5_6_0_,
optiondeta0_.orders as orders3_6_0_,
optiondeta0_.price as price4_6_0_
from
option_detail optiondeta0_
where
optiondeta0_.option_detail_id in (
?, ?
)
org.hibernate.SQL : # 상세 옵션 1
select
option0_.option_id as option_i1_5_0_,
option0_.create_dt as create_d2_5_0_,
option0_.update_dt as update_d3_5_0_,
option0_.item_id as item_id9_5_0_,
option0_.max_select_count as max_sele4_5_0_,
option0_.min_select_count as min_sele5_5_0_,
option0_.name as name6_5_0_,
option0_.orders as orders7_5_0_,
option0_.required as required8_5_0_
from
option option0_
where
option0_.option_id in (
?, ?
)
□ Fetch Join 적용
이제 주문과 주문상품, 상품 엔티티에 대해서 Fetch Join을 적용하여 한방 쿼리로 가져와야합니다.
Fetch Join 적용 전에는 3번의 쿼리가 수행됩니다.(default_batch_fetch_size 설정으로 상품 부분은 1/3쿼리를 수행했습니다.)
org.hibernate.SQL : # 주문
select
distinct order0_.order_id as order_id1_9_,
order0_.member_id as member_i4_9_,
order0_.order_date as order_da2_9_,
order0_.status as status3_9_
from
orders order0_
where
order0_.member_id=?
org.hibernate.SQL : # 주문상품
select
orderiteml0_.order_id as order_id5_7_1_,
orderiteml0_.order_item_id as order_it1_7_1_,
orderiteml0_.order_item_id as order_it1_7_0_,
orderiteml0_.count as count2_7_0_,
orderiteml0_.item_id as item_id4_7_0_,
orderiteml0_.order_id as order_id5_7_0_,
orderiteml0_.order_price as order_pr3_7_0_
from
order_item orderiteml0_
where
orderiteml0_.order_id=?
org.hibernate.SQL : # 상품
select
item0_.item_id as item_id1_3_0_,
item0_.create_dt as create_d2_3_0_,
item0_.update_dt as update_d3_3_0_,
item0_.category as category4_3_0_,
item0_.file_id as file_id5_3_0_,
item0_.item_name as item_nam6_3_0_,
item0_.price as price7_3_0_,
item0_.rating_status as rating_s8_3_0_,
item0_.status as status9_3_0_,
item0_.store_id as store_i10_3_0_
from
item item0_
where
item0_.item_id in (
?, ?, ?
)
기존에 2개의 ToMany관계를 Fetch Join하여 오류가 났으니 ToMany의 관계에는 필요한 부분만 Fetch Join하고 ToOne관계는 모두 한방 쿼리 가져옵니다.

테스트를 해보면 3번 수행하던 쿼리가 1번으로 개선되었습니다.
org.hibernate.SQL :
select
distinct order0_.order_id as order_id1_9_0_,
orderiteml1_.order_item_id as order_it1_7_1_,
item2_.item_id as item_id1_3_2_,
order0_.member_id as member_i4_9_0_,
order0_.order_date as order_da2_9_0_,
order0_.status as status3_9_0_,
orderiteml1_.count as count2_7_1_,
orderiteml1_.item_id as item_id4_7_1_,
orderiteml1_.order_id as order_id5_7_1_,
orderiteml1_.order_price as order_pr3_7_1_,
orderiteml1_.order_id as order_id5_7_0__,
orderiteml1_.order_item_id as order_it1_7_0__,
item2_.create_dt as create_d2_3_2_,
item2_.update_dt as update_d3_3_2_,
item2_.category as category4_3_2_,
item2_.file_id as file_id5_3_2_,
item2_.item_name as item_nam6_3_2_,
item2_.price as price7_3_2_,
item2_.rating_status as rating_s8_3_2_,
item2_.status as status9_3_2_,
item2_.store_id as store_i10_3_2_
from
orders order0_
inner join
order_item orderiteml1_
on order0_.order_id=orderiteml1_.order_id
inner join
item item2_
on orderiteml1_.item_id=item2_.item_id
where
order0_.member_id=?
마치며
여러개의 ToMany관계의 FetchJoin 기본적으로 JPA에서 안되고 Querydsl도 당연하게도 안된다는 사실을 알게 되었습니다.
실무에서 JPA를 사용한다면 default_batch_fetch_size 옵션으로 최소한의 성능을 보장하고 적절한 N관계에 Fetch Join을 걸어줘서 성능상 이점을 가져가야 될 것 같습니다.
REFERENCE
'Framework & Library > JPA' 카테고리의 다른 글
| [JPA] beans.factory.UnsatisfiedDependencyException (0) | 2023.07.31 |
|---|---|
| JPA Auditing을 활용한 생성시간/수정시간 자동화하기 (0) | 2023.06.07 |
| [JPA] 영속성 전이(Cascade)로 엔티티 생명주기를 쉽게 관리하자 (0) | 2022.07.28 |