| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 31 |
- New Architecture
- React Native
- 일본어공부
- java
- jpa
- 코테
- 마이라이트
- 자바스크립트
- 자료구조
- 에러해결
- 삼성
- 생활정보
- SWEA
- 코딩
- 일본어독학
- 인프런
- 프로그래머스
- js
- springboot
- 삼성소프트웨어아카데미
- 코딩테스트
- 알고리즘
- 일본어학습지
- 가벼운학습지후기
- 자바
- 웹보안
- 성인학습지
- 백준
- javascript
- 가벼운학습지
- Today
- Total
개발에 AtoZ까지
[에러] MultipleBagFetchException: cannot simultaneously fetch multiple bags — 컬렉션 두 개를 한 번에 fetch join 했을 때 본문
[에러] MultipleBagFetchException: cannot simultaneously fetch multiple bags — 컬렉션 두 개를 한 번에 fetch join 했을 때
AtoZ 개발자 2026. 8. 9. 19:30지연 로딩 때문에 터지던 LazyInitializationException을 잡으려고 조회 쿼리에 join fetch를 넣습니다. 여기까지는 잘 됩니다. 그런데 컬렉션이 하나 더 필요해서 join fetch를 하나 더 붙이는 순간, 애플리케이션이 아예 뜨지 않습니다.
org.hibernate.loader.MultipleBagFetchException:
cannot simultaneously fetch multiple bags:
[com.example.order.Order.items, com.example.order.Order.coupons]
Spring Data JPA를 쓰신다면 이 예외를 조회할 때가 아니라 부팅할 때 보시는 경우가 많습니다. 리포지토리 메서드의 쿼리를 시작 시점에 검증하기 때문이죠. 그래서 "어제까지 잘 뜨던 서버가 오늘 안 뜬다"로 체감됩니다.
이 글을 다 읽으면 세 가지가 정리됩니다. 첫째, Hibernate가 왜 이걸 굳이 막는지 이해할 수 있습니다. 둘째, 네 가지 해결책 중 내 상황에 맞는 것을 고를 수 있습니다. 셋째, 이 예외를 피한 뒤에 바로 만나게 되는 페이징 함정을 미리 피할 수 있습니다.
이 글은 Hibernate 공식 문서와 Javadoc을 기준으로 정리했습니다. 버전에 따라 동작이 다를 수 있으니 본인 프로젝트의 Hibernate 버전으로 확인해 주세요.

컬렉션 두 개를 한 번에 조인하면 행이 곱으로 늘어나고, 순서 정보가 없는 bag은 어느 행이 중복인지 판단할 수 없습니다
'bag'이라는 낯선 단어부터
에러 메시지의 bag은 Hibernate 용어입니다. 순서 정보가 없는 컬렉션을 뜻합니다.
자바에서 List를 쓰면 우리는 순서가 있다고 생각하지만, JPA 매핑에서 @OrderColumn이나 @OrderBy 없이 List를 선언하면 Hibernate는 그걸 순서를 보장하지 않는 bag으로 취급합니다. 인덱스 컬럼이 DB에 없으니 몇 번째 요소인지 알 방법이 없기 때문이죠.
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
@OneToMany(mappedBy = "order")
private List<OrderItem> items = new ArrayList<>(); // bag
@OneToMany(mappedBy = "order")
private List<Coupon> coupons = new ArrayList<>(); // bag
}
Set으로 선언하면 bag이 아닙니다. List + @OrderColumn도 bag이 아니라 인덱스가 있는 진짜 리스트입니다. 예외 메시지에 나오는 두 이름은 이렇게 bag으로 잡힌 컬렉션들입니다.
Hibernate Javadoc은 MultipleBagFetchException을 "쿼리가 여러 bag을 동시에 가져오려 할 때 사용하는 예외"로 설명합니다. 패키지는 org.hibernate.loader이고, HibernateException을 상속합니다.

Hibernate 공식 Javadoc의 MultipleBagFetchException 설명 (출처: docs.hibernate.org, 확인일 2026-08-08)
왜 굳이 막는 걸까요
주문 1건에 상품이 3개, 쿠폰이 2개 붙어 있다고 해 보겠습니다. 두 컬렉션을 한 번에 조인하면 SQL 결과는 3 × 2 = 6행이 됩니다. 카테시안 곱이죠.
Hibernate는 이 6행을 되돌려서 "상품 3개, 쿠폰 2개"로 복원해야 합니다. 컬렉션에 인덱스가 있으면 몇 번째 요소인지 보고 중복을 걷어낼 수 있습니다. 그런데 bag은 인덱스가 없습니다.
그러면 진짜 중복 행(조인 때문에 늘어난 것)과 원래부터 값이 같은 두 요소를 구별할 방법이 없습니다. 결과가 조용히 틀어지느니 예외를 던지는 쪽을 택한 겁니다. 막는 게 아니라 데이터가 깨지는 걸 미리 알려 주는 쪽에 가깝습니다.
그래서 Set은 허용됩니다. 중복이라는 개념 자체가 없으니 같은 행이 여러 번 와도 결과가 하나로 모입니다.
네 가지 해결책과 고르는 기준
1. List를 Set으로 바꾼다
가장 빠른 수정입니다. 매핑만 바꾸면 예외가 사라집니다.
@OneToMany(mappedBy = "order")
private Set<OrderItem> items = new LinkedHashSet<>();
@OneToMany(mappedBy = "order")
private Set<Coupon> coupons = new LinkedHashSet<>();
다만 공짜는 아닙니다. SQL이 만들어 내는 행 수는 그대로 곱입니다. 상품 50개 × 쿠폰 20개면 1,000행을 읽어 와서 메모리에서 합칩니다. 컬렉션이 작을 때만 편안한 선택입니다.
equals/hashCode도 확인하셔야 합니다. 영속 상태 엔티티는 동일성으로 처리되지만, 준영속 상태로 나가는 DTO 변환 과정에서 중복 판정이 달라질 수 있습니다.
2. 한쪽만 fetch join하고 나머지는 배치로 가져온다
실무에서 가장 무난한 선택입니다. 컬렉션 하나만 조인하고, 나머지는 Hibernate의 배치 페치에 맡깁니다.
# application.properties
spring.jpa.properties.hibernate.default_batch_fetch_size=100
@Query("select distinct o from Order o join fetch o.items where o.status = :status")
List<Order> findWithItems(@Param("status") OrderStatus status);
이렇게 하면 쿠폰은 나중에 접근할 때 where order_id in (?, ?, ?, ...) 형태로 한 번에 묶여 조회됩니다. N+1이 1+1로 줄어드는 셈이죠.
Hibernate 공식 설정 문서는 hibernate.default_batch_fetch_size를 "배치 페칭의 기본값을 지정한다"고 설명하면서, 기본 상태에서는 @BatchSize가 명시된 엔티티·컬렉션에만 배치 페치가 적용된다고 밝히고 있습니다. 전역 설정 대신 특정 컬렉션만 지정하고 싶다면 이렇게 씁니다.
@BatchSize(size = 100)
@OneToMany(mappedBy = "order")
private List<Coupon> coupons = new ArrayList<>();
3. 쿼리를 두 번 나눈다
같은 트랜잭션 안에서 각각 조회하면 Hibernate가 1차 캐시에서 동일한 엔티티에 컬렉션을 채워 넣습니다.
List<Order> orders = orderRepository.findWithItems(status);
orderRepository.findWithCoupons(orders); // 같은 영속성 컨텍스트
쿼리 수가 늘어나는 대신 각 쿼리의 결과 행이 작아집니다. 컬렉션 두 개가 모두 큰 경우에 유리합니다.
4. List를 유지해야 한다면 @OrderColumn
순서가 업무 규칙인 경우(예: 사용자가 정렬한 순서 그대로 보여 줘야 하는 경우)에는 인덱스 컬럼을 둡니다.
@OneToMany(mappedBy = "order")
@OrderColumn(name = "item_idx")
private List<OrderItem> items = new ArrayList<>();
DB에 인덱스 컬럼이 추가되고, 중간 삽입·삭제 시 인덱스 재정렬 쿼리가 나갑니다. 순서 보존이 정말 필요할 때만 선택하시는 편이 좋습니다.

컬렉션 크기와 순서 요구사항에 따라 선택이 달라집니다
고르는 기준을 한 줄로
| 상황 | 선택 |
|---|---|
| 두 컬렉션 모두 요소가 수십 개 이하 | Set으로 변경 |
| 한쪽만 크다 | 큰 쪽만 fetch join + 나머지 배치 페치 |
| 둘 다 크다 | 쿼리 분리 |
| 순서가 업무 규칙이다 | @OrderColumn |
| 페이징이 필요하다 | 아래 항목을 먼저 읽어 주세요 |
예외를 피했더니 다음은 페이징입니다
Set으로 바꿔서 예외를 없앤 뒤, 목록 화면에 페이징을 붙이면 새로운 문제가 나타납니다. 컬렉션을 fetch join한 쿼리에 setFirstResult/setMaxResults를 걸면 Hibernate가 DB가 아니라 메모리에서 잘라 냅니다.
이유는 단순합니다. SQL 단계에서 20행만 잘라 버리면 어떤 주문의 상품 목록이 중간에서 끊긴 채로 올라옵니다. Hibernate는 데이터 정합성을 택해 전체를 읽은 뒤 잘라 냅니다. 데이터가 커지면 그대로 장애가 되죠.
Hibernate 공식 설정 문서의 hibernate.query.fail_on_pagination_over_collection_fetch 설명이 이걸 그대로 말합니다. "제한이 DB가 아니라 메모리에서 적용되어야 하는 경우 예외를 던지도록 지정한다"고 돼 있고, 기본값은 false 입니다. 켜 두면 조용한 성능 사고 대신 즉시 예외로 알려 줍니다.
spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true

Hibernate 공식 Javadoc의 fail_on_pagination_over_collection_fetch 설명 — 기본값은 비활성 (출처: docs.hibernate.org, 확인일 2026-08-08)
페이징이 필요하다면 두 단계로 나누는 방식이 정석입니다.
// 1단계: 컬렉션 없이 ID만 페이징
@Query("select o.id from Order o where o.status = :status")
Page<Long> findIds(@Param("status") OrderStatus status, Pageable pageable);
// 2단계: 그 ID들에 대해서만 컬렉션을 채운다
@Query("select distinct o from Order o join fetch o.items where o.id in :ids")
List<Order> findWithItems(@Param("ids") List<Long> ids);
1단계는 컬렉션이 없으니 DB에서 정확히 잘립니다. 2단계는 대상이 한 페이지분으로 좁혀져 있어 행이 폭발하지 않습니다.
확인 순서
예외를 만났을 때 아래 순서로 보시면 원인이 빨리 잡힙니다.
- [ ] 예외 메시지 대괄호 안의 컬렉션 이름 두 개를 확인한다
- [ ] 두 필드의 타입이
List인지,@OrderColumn이 없는지 확인한다 - [ ] 두 컬렉션의 예상 최대 요소 수를 각각 센다(곱이 실제 부담이다)
- [ ] 큰 쪽만 fetch join으로 남기고 나머지는 배치 페치로 옮긴다
- [ ]
default_batch_fetch_size를 설정하고 실행되는 SQL 개수를 로그로 확인한다 - [ ] 이 쿼리에 페이징이 붙을 예정인지 확인한다(붙는다면 ID 2단계 조회로 바꾼다)
SQL을 눈으로 보면서 판단하시려면 이 설정이 편합니다.
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.orm.jdbc.bind=trace
다음에 볼 글
이 예외는 대개 LazyInitializationException을 해결하는 과정에서 만나게 됩니다. 지연 로딩과 영속성 컨텍스트 범위를 먼저 정리하고 오시면 왜 fetch join을 쓰게 됐는지가 더 선명해집니다.
에러 응답을 어떻게 내보낼지 고민 중이시라면 ProblemDetail로 에러 응답 표준화도 같이 보시면 좋습니다.
잘못된 내용이나 보충이 필요한 부분이 있으면 댓글로 알려 주시면 반영하겠습니다.
참고한 자료
- Hibernate ORM Javadoc —
org.hibernate.loader.MultipleBagFetchException(확인일 2026-08-08) - Hibernate ORM Javadoc —
FetchSettings.DEFAULT_BATCH_FETCH_SIZE(확인일 2026-08-08) - Hibernate ORM Javadoc —
QuerySettings.FAIL_ON_PAGINATION_OVER_COLLECTION_FETCH(확인일 2026-08-08) - Jakarta Persistence —
@OrderColumn,@OneToMany매핑 (확인일 2026-08-08)
