개발에 AtoZ까지

[에러] MultipleBagFetchException: cannot simultaneously fetch multiple bags — 컬렉션 두 개를 한 번에 fetch join 했을 때 본문

백엔드/Spring

[에러] 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)
반응형
Comments