본문 바로가기
Server/🌱 Spring Boot (java)

[Spring Boot jpa] 다중 LEFT JOIN 시 MultipleBagFetchException 발생 문제

by 코딩하는 동현😎 2025. 1. 25.

문제

테스트 중, 다음과 같은 쿼리에서 MultipleBagFetchException이 발생함:

@Query("SELECT h FROM House h " +
       "LEFT JOIN FETCH h.rooms r " +
       "LEFT JOIN FETCH r.roommates " +
       "LEFT JOIN FETCH h.pins " +
       "WHERE h.id = :houseId")
Optional<House> findHouseDetailsById(@Param("houseId") Long houseId);
  • MultipleBagFetchException은 Hibernate에서 여러 개의 @OneToMany 관계를 fetch join할 경우 발생하는 문제로, 한 번의 쿼리로 여러 개의 컬렉션(List)을 동시에 로드하려고 시도하면 발생함.

House에는 rooms, pins, recentlyViewedHouses 세 가지 @OneToMany 관계가 있었는데, 위의 쿼리는 rooms, pins와 rooms 내의 roommates를 모두 fetch join하여 한 번의 쿼리로 데이터를 가져오려고 시도하였기 때문에 오류가 발생하였다.

Hibernate는 SQL에서 결과를 하나의 Cartesian Product로 생성하고, 이를 Java 컬렉션으로 매핑하는 방식으로 작동한다. 하지만 여러 개의 컬렉션을 동시에 fetch join할 경우, 이 결과를 적절히 분해하지 못해 문제가 발생하는 것이다.

 

Cartesian Product는 두 테이블 간의 모든 가능한 조합을 반환하는 방식이다. 어떤 조인 조건도 사용하지 않고, 두 테이블의 모든 행과 열을 조합하여 결과를 생성한다. 결과는 두 테이블의 행 수를 곱한 만큼의 행을 가지게 된다. (만약 A와 B 테이블의 행이 10개씩 있다면 카티시안 곱의 결과는 100행)행이 모든 경우의 수가 들어가 필요없는 행까지 포함되므로, 조건절을 지정하여 사용할 수 있다. 일반적으로는 카티시안 곱 보다는 Join문들을 많이 사용한다.

 

  1. Hibernate 제약:
    • 한 번의 쿼리로 여러 개의 @OneToMany 컬렉션을 fetch join하면, 결과 집합이 중복되거나 불필요한 Cartesian Product가 생성됨. Hibernate는 이를 적절히 처리하지 못해 MultipleBagFetchException을 던짐.
  2. 쿼리의 과도한 데이터 로드:
    • 필요한 데이터만 로드하면 되지만, 여러 관계를 한 번에 가져오려다 보니 성능상 비효율적이고 복잡한 쿼리가 생성됨.
    • 이는 네트워크 부하 및 쿼리 성능 문제로 이어질 가능성도 있었음.

해결

모든 관계를 한 번에 fetch join하지 않도록 쿼리를 분리

  • rooms, roommates, pins 데이터를 한 쿼리에서 동시에 가져오지 않고, 필요한 관계만 분리된 쿼리에서 fetch join하도록 수정함.
  • 다음과 같이 쿼리를 나누어 정의:
@Query("SELECT h FROM House h " +
       "LEFT JOIN FETCH h.rooms r " +
       "WHERE h.id = :houseId")
Optional<House> findHouseWithRoomsById(@Param("houseId") Long houseId);

@Query("SELECT r FROM Room r " +
       "LEFT JOIN FETCH r.roommates " +
       "WHERE r.house.id = :houseId")
List<Room> findRoomsAndRoommatesByHouseId(@Param("houseId") Long houseId);

@Query("SELECT h FROM House h " +
       "LEFT JOIN FETCH h.pins p " +
       "WHERE h.id = :houseId")
Optional<House> findHouseWithPinsById(@Param("houseId") Long houseId);

Fetch Join / Left Join 설명

1. Fetch Join

Fetch Join은 Hibernate와 같은 ORM에서 엔티티와 연관된 컬렉션이나 엔티티를 즉시 로딩(Eager Loading) 방식으로 가져오기 위해 사용하는 기법이다.

  • Fetch Join의 특징:
    • 연관된 엔티티나 컬렉션을 한 번의 쿼리로 함께 조회한다.
    • SQL의 JOIN과는 다르게, 엔티티 그래프(Entity Graph)를 로드하는 데 사용된다.
    • JPQL에서 JOIN FETCH 키워드를 사용하여 명시적으로 설정할 수 있다.
  • 장점:
    • N+1 문제를 방지할 수 있다.
    • 필요한 데이터를 한 번의 쿼리로 가져오기 때문에 성능상 유리하다.
  • 단점:
    • 하나의 쿼리에서 여러 컬렉션을 fetch join하면 MultipleBagFetchException이 발생할 수 있다.
    • 불필요한 데이터까지 로드하게 되어 성능 저하를 초래할 수 있다.

2. Left Join

Left Join은 SQL에서 사용하는 조인 방식으로, 기준 테이블의 모든 데이터와 조인된 테이블의 일치하는 데이터를 결합하여 결과를 반환한다.

  • Left Join의 특징:
    • 기준 테이블에 조인된 테이블의 데이터가 없더라도, 기준 테이블의 모든 데이터가 포함된다.
    • 조인된 테이블에 데이터가 없을 경우 NULL 값이 들어간다.
  • 장점:
    • 조인된 테이블의 데이터가 없더라도 기준 테이블의 데이터를 유지할 수 있다.
    • 조인된 테이블 데이터의 존재 여부에 따라 유연하게 쿼리 결과를 처리할 수 있다.
  • 단점:
    • 불필요한 NULL 값을 포함한 데이터가 반환될 수 있어, 클라이언트에서 이를 처리해야 한다.

Fetch Join과 Left Join의 차이

목적 엔티티와 연관된 데이터를 한 번의 쿼리로 즉시 로드 SQL 방식으로 테이블을 조인
결과 반환 엔티티 그래프로 반환 테이블 데이터를 조합하여 결과를 반환
사용 위치 ORM(JPA, Hibernate) SQL 및 JPQL
Lazy Loading 영향 Lazy Loading 설정을 무시하고 즉시 로드 Lazy Loading 설정을 따르며, 조인된 데이터를 지연 로딩할 수 있음
N+1 문제 해결 해결 가능 해결 불가능(N+1 발생 가능)
반응형

댓글