MultipleBagFetchException 발생 원인과 해결과정

송어·2024년 2월 26일
0

Project

목록 보기
1/2

개요

이번에 토이프로젝트를 진행하면서 Travel, Trip 관련 로직을 맡았다.

상위 엔티티인 Travel은 여러개의 하위 엔티티들과 관계를 맺고 있었는데

@Builder.Default
@OneToMany(mappedBy = "travel", cascade = CascadeType.REMOVE)
private List<Trip> trip = new ArrayList<>();
    
@Builder.Default
@OneToMany(mappedBy = "travel", cascade = CascadeType.REMOVE)
private List<Comment> comment = new ArrayList<>();

@Builder.Default
@OneToMany(mappedBy = "travel", cascade = CascadeType.REMOVE)
private List<UserLike> likes = new ArrayList<>();

trip, comment, like 테이블이 travel과 양방향 연관 관계로 매핑되어 있고 관계 주인을 위같이 설정했다.

자식 엔티티를 함께 조회하는 과정에서 조회된 쿼리의 개수(N개)만큼 연관 쿼리의 추가 쿼리가 발생하는 N+1 문제의 해결 방법으로 주어진 여러가지 방법 중 fetch join을 사용하기로 했다.

문제 발생

select tv
from Travel tv
left join fetch tv.trip t
left join fetch tv.likes l
left join fetch tv.comment c
where tv.state = 'ACTIVE'

left join으로 연관관계에 있는 자식 엔티티를 한꺼번에 가져와 조회하려고 했지만 fetch join이 2개 이상 늘어나면서 문제가 발생했다.

[RuntimeException Occurs] error: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags

처음보는 MultipleBagFetchException이 발생한 것이다. 그래서 해당 예외가 무엇인지부터 확인하기로 했다.

해당 예외는 최상위 RuntimeException 아래의 예외로서 HibernateException의 최하위 예외이다.

Exception used to indicate that a query is attempting to simultaneously fetch multiple bags

쿼리가 동시에 여러개의 bag컬렉션을 가져오려는 시도를 가져오려는 경우 사용되는 예외라고 한다. 나는 List를 사용해 데이터를 받았는데 왜 이런 예외가 발생하건지 궁금했고, bag컬렉션이 무엇인지도 궁금했다.

bag 컬렉션?

그렇다면 내가 조회했다고 나오는 bag컬렉션은 뭘까?

An unordered, un-keyed collection that can contain the same element multiple times. The Java Collections Framework, curiously, has no Bag interface. It is, however, common to use Lists to represent a collection with bag semantics, so Hibernate follows this practice. 출처 : PersistentBag.java

Bag 컬렉션은 Hibernate에서 사용하는 용어로 순서가 없고 키가 없으며, 중복을 허용한다. 하지만 Java Collection에는 Bag이 구현되어있지 않아 List를 사용한다고 한다. 쉽게 말하면 정말 가방안에 들어있는 물건들을 내가 꺼낼 때 순서 보장 없이 물건이 꺼내지듯 Bag 컬렉션도 이와 유사한 자료구조라고 생각했다.

실제로 클래스를 들여다보니 List를 사용한다는 것을 알 수 있었다.

MultipleBagFetchException 발생 원인

Bag에 대해 알아보니 Join을 통해 연관 관계가 있는 여러개의 데이터를 한꺼번에 가져온다면 카테시안 곱이 발생할 수 있다고 했다. 내가 이해한 부분은 Bag컬렉션은 기본적으로 중복을 허용하고 순서가 없기 때문에 fetch join이 2개 이상으로 늘어나는 순간 2개의 컬렉션을 동시에 가져오게 되고, 이 과정에서 순서가 정해지지 않은 무수히 많은 열을 Hibernate가 올바른 엔터티에 매핑할 수 없다고 생각했다.

하지만 여러 관계에 있는 데이터를 가져와야 하는 상황에서 쿼리 분리를 통해 Repository레이어를 수정하기엔 시간이 촉박하고 fetch join을 사용하는 선에서 일부 문제를 해결하고 싶었다.

MultipleBagFetchException 해결 방법

나는 fetch join을 통해 여러 엔티티를 동시에 가져올 때 카테시안 곱으로 인한 무수히 많은 중복이 주요 원인이라고 생각했다. 그래서 카테시안 곱은 해결할 수 없어도 중복은 해결할 수 있을 것이라고 생각했다.

@Builder.Default
@OneToMany(mappedBy = "travel", cascade = CascadeType.REMOVE)
private List<Trip> trip = new ArrayList<>();
    
@Builder.Default
@OneToMany(mappedBy = "travel", cascade = CascadeType.REMOVE)
private Set<UserLike> comment = new HashSet<>(); // set

@Builder.Default
@OneToMany(mappedBy = "travel", cascade = CascadeType.REMOVE)
private Set<UserLike> likes = new HashSet<>(); // set

따라서 자식 엔터티의 자료구조를 List -> Set으로 변경해 중복값을 허용하지 않도록 제어하는 방법을 사용해보았다. 물론 자료구조를 바꾸는 과정에서 오류는 발생하지 않았다.

{
  "isSuccess": true,
  "statusCode": 200,
  "message": "요청에 성공했습니다.",
  "status": "SUCCESS",
  "data": [
    {
      "likeCount": 1,
      "id": 1,
      "travelName": "대구",
      "state": "ACTIVE",
      "departure": "string",
      "arrival": "string",
      "departureTime": "2024-02-27T04:36:47.164",
      "arrivalTime": "2024-02-29T04:36:47.164",
      "trip": [
        {
          "id": 1,
          "location": "string",
          "state": "ACTIVE",
          "postedAt": "2024-02-28T05:17:37.539"
        }
      ],
      "comment": [
        {
          "id": 1,
          "memberId": 1,
          "travelId": 1,
          "content": "string"
        }
      ]
    }
  ]
}

테스트 결과 예외가 멈추고 정상적으로 데이터가 조회되는 것을 확인할 수 있다.
하지만 이는 임시방편인 해결책이고, 다중 fetch join으로 발생하는 카테시안 곱 관련 이슈는 남아있었다. 현재는 단일 데이터를 조회하고 있지만, 차후 대용량 데이터를 다룰 때 해당 문제가 한번 더 발생하면 성능상의 문제는 피할 수 없을 것이라는 생각이 들어서 문제에 대한 해결방안을 더 찾아보았다.

여러 쿼리를 사용한 문제 해결

Set을 사용해 예외를 피했지만 성능상 문제가 생길 수 있다는 것을 알아보았다.
이 해결방안은 Baeldung에서 제안한 해결 방안인데 한번에 여러 Bag을 뒤지지 않고 한 쿼리당 하나의 Bag에 접근해 두 Bag 컬렉션을 하나씩 성공적으로 가져올 수 있다는 방안을 제시했다.

String jpql = "SELECT DISTINCT user FROM User user "
      + "LEFT JOIN FETCH user.playlists "
      + "LEFT JOIN FETCH user.favoriteSongs ";

해당 쿼리는 Baeldung에서 사용한 예시 JPQL문인데 단일 쿼리를 사용해 한번에 여러 데이터를 가져오고 있다. 이제 쿼리를 2개로 나누어 문제를 해결한 예시를 보자

해당 예시는 artist가 가지고 있는 songs, offers 2개의 bag 컬렉션을

String jpql = "SELECT DISTINCT artist FROM Artist artist "
  + "LEFT JOIN FETCH artist.songs "; // artist를 검색하며 fetch join으로 songs를 가져온다.

List<Artist> artists = entityManager.createQuery(jpql, Artist.class)
  .setHint(QueryHints.HINT_PASS_DISTINCT_THROUGH, false)
  .getResultList();

jpql = "SELECT DISTINCT artist FROM Artist artist "
  + "LEFT JOIN FETCH artist.offers "
  + "WHERE artist IN :artists "; // artist의 offters를 가져옴

artists = entityManager.createQuery(jpql, Artist.class)
  .setParameter("artists", artists)
  .setHint(QueryHints.HINT_PASS_DISTINCT_THROUGH, false)
  .getResultList();

assertEquals(2, artists.size());

해당 접근 방식을 사용해 한 쿼리에 fetch를 여러개 사용하는 것이 아닌 bag 컬렉션 당 하나의 fetch를 사용해 안전하게 가져오는 방법이다.

artist의 offers를 먼저 가져오고, artist의 offers를 가져오는 방식으로 사용해 중복 fetch join으로 인한 카테시안 곱 형성을 방지했다.

나는 현재 Spring Data JPA를 사용하고 있고, 현재 3개의 bag컬렉션을 가져오는 쿼리를 분리해서 가져오는 것도 JPA를 다루는데 아직 익숙치 않아 Native query를 사용하거나 querydsl을 활용해 변경을 해야겠다는 결론을 내렸다. 현재 토이 프로젝트는 fetch join을 사용하는 것으로 마무리 지었지만 향후 개인프로젝트나 다른 프로젝트를 할 때 동일한 상황이 발생한다면, 지금보다 더 유연하게 처리할 수 있을 것이라고 생각한다.


https://www.baeldung.com/java-hibernate-multiplebagfetchexception

0개의 댓글

관련 채용 정보