데이터베이스

EntityGraph & MultipleBagFetchException Error

으엉어엉 2025. 11. 24. 10:36
728x90

JPA를 사용하다 보면 피할 수 없는 숙명, N+1 문제를 해결하기 위해 우리는 주로 Fetch Join이나 @EntityGraph를 사용합니다.

하지만 의욕적으로 연관된 엔티티를 모두 가져오려다 보면 MultipleBagFetchException이라는 낯선 에러를 마주하게 됩니다. 오늘은 @EntityGraph의 원리부터, 이 에러가 발생하는 이유, 그리고 실무에서 가장 권장하는 해결책인 Batch Size까지 한 번에 정리해 보겠습니다.


1. @EntityGraph란 무엇인가?

Fetch Join은 JPQL을 직접 작성해야 하는 번거로움이 있습니다. 이를 해결하기 위해 Spring Data JPA는 어노테이션만으로 Fetch Join 기능을 수행할 수 있는 @EntityGraph 기능을 제공합니다.

사용법

// Member를 조회할 때 Team도 같이 가져와! (지연 로딩 무시) 만약 2개시 team, member 이런식..
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();

작동 원리 (핵심)

@EntityGraph를 사용하면 내부적으로 Left Outer Join이 실행됩니다.

  • Fetch Join (JPQL): 기본적으로 Inner Join (데이터가 있는 교집합만 조회)
  • @EntityGraph: 기본적으로 Left Outer Join (연관 데이터가 없어도 원본 데이터는 조회) -> 카테시안 곱이 발생.. 데이터 과부화

장단점 비교

구분 장점 단점
장점 1. 간편함: JPQL 없이 어노테이션 하나로 N+1 해결.

2. 안전함: Left Outer Join이라 데이터 누락 걱정이 없음.
 
단점 1. 제어 불가: 무조건 Outer Join이라 Inner Join이 필요한 성능 최적화엔 불리.

2. 복잡도: 관계가 복잡해지면 어노테이션이 지저분해짐.
 

 

 

 

2. 문제 발생: MultipleBagFetchException

실무에서 욕심을 부려 "Member를 조회할 때, 주문 목록(Orders)도 가져오고, 쿠폰 목록(Coupons)도 한 번에 가져와야지!" 라고 코드를 짜면 에러가 터집니다.

@EntityGraph(attributePaths = {"orders", "coupons"}) // List 두 개 동시 조회
List<Member> findAll();

 

Error: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags

왜 발생하는가?

이유는 "Cartesian Product (카테시안 곱)" 때문입니다.

1:N 관계인 컬렉션을 두 개 이상 Join 하게 되면, 데이터가 N × M으로 폭발적으로 늘어납니다.

(예: 회원 1명 * 주문 100개 * 쿠폰 100개 = 10,000줄의 데이터 생성)

자바의 List는 순서가 있고 중복을 허용하는 자료구조(Bag)입니다. 하이버네이트 입장에서는 이렇게 뻥튀기된 데이터 중에서 "어떤 게 진짜 중복이고, 어떤 게 유효한 데이터인지" 식별할 수 없어 아예 예외를 던지고 실행을 막아버립니다.

 

 

 

3. 첫 번째 해결책: List를 Set으로 변경

가장 널리 알려진(하지만 위험한) 해결책은 자료형을 List에서 Set으로 바꾸는 것입니다.

// List 대신 Set 사용
private Set<Order> orders = new HashSet<>();
private Set<Coupon> coupons = new HashSet<>();

왜 해결되는가?

Set은 중복을 허용하지 않는 자료구조입니다. 하이버네이트는 "Set이니까 중복 데이터가 들어와도 알아서 걸러지겠군"이라고 판단하고 Fetch Join을 허용해 줍니다.

치명적인 단점 (함정)

  1. 성능 문제는 그대로: 에러만 안 날 뿐, DB 내부적으로는 여전히 N × M개의 엄청난 데이터를 조인해서 가져옵니다. (메모리 낭비)
  2. 순서 보장 불가: Set은 순서가 뒤죽박죽입니다. "최신순 정렬" 같은 비즈니스 로직이 다 깨집니다. LinkedHashSet을 써도 근본적인 데이터 뻥튀기는 막을 수 없습니다.

 

 

4. 실무 권장 해결책: default_batch_fetch_size

가장 깔끔하고 성능상 이점이 많은 방법은 **"억지로 Join 하지 말고, 똑똑하게 끊어서 가져오는 것"**입니다. 이를 위해 Batch Size 옵션을 사용합니다.

적용 방법 (application.yml)

컬렉션들은 Lazy Loading 상태로 놔두고, 아래 옵션을 추가합니다.

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 1000  # 보통 1000 권장

 

동작 원리 (IN 절 쿼리)

이 옵션을 켜면, JPA는 데이터를 한 번에 Join 해서 가져오는 대신, SQL의 IN 절을 사용하여 필요한 데이터를 한 번에 당겨옵니다.

  1. SELECT * FROM Member (회원 100명 조회) -> 쿼리 1번
  2. (회원의 주문 목록을 사용할 때)
  3. SELECT * FROM Orders WHERE member_id IN (1, 2, ... 100) -> 쿼리 1번

장점

  1. 데이터 뻥튀기 없음: 필요한 데이터만 정확하게 가져옵니다. (100+100=200개. 아까처럼 10,000개가 아님)
  2. N+1 문제 해결: 100번 나갈 쿼리가 단 1번의 IN 쿼리로 줄어듭니다.
  3. 페이징 가능: Fetch Join을 쓰면 페이징이 불가능(메모리 페이징)하지만, Batch Size는 페이징 쿼리가 정상적으로 나갑니다.

단점

  • 쿼리 개수: Fetch Join(1방)보다는 쿼리가 더 나갑니다(N+1이 아니라, 1+1). 하지만 데이터 전송량을 고려하면 오히려 더 빠를 때가 많습니다.

 

5. 결론 및 요약

상황에 따라 아래와 같이 전략을 선택하세요.

  1. OneToOne, ManyToOne 관계:
    • 그냥 @EntityGraph나 Fetch Join을 사용해서 한 방 쿼리로 가져오세요.
  2. OneToMany (컬렉션) 관계가 1개일 때:
    • Fetch Join을 써도 됩니다.
  3. OneToMany (컬렉션) 관계가 2개 이상일 때:
    • 절대 Set으로 바꿔서 억지로 조인하지 마세요.
    • 그냥 Lazy Loading으로 두고 default_batch_fetch_size를 사용한다.

Fetch Join은 "수동 기어(정교함)"이고, @EntityGraph는 "오토 기어(편리함)"

728x90

'데이터베이스' 카테고리의 다른 글

B+, B - tree Index  (3) 2024.06.08
INDEXING  (0) 2024.06.08
SQL 기초  (0) 2024.06.08