MultipleBagFetchException 원인과 해결 방법

NCOOKIE·2025년 4월 17일
0

TIL

목록 보기
17/20

개요

jakarta.servlet.ServletException: Request processing failed: org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [org.example.expert.domain.todo.entity.Todo.comments, org.example.expert.domain.todo.entity.Todo.managers]

JPA로 이것 저것 테스트 해보다가 MultipleBagFetchException 에러가 발생했다.

@Getter
@Entity
@NoArgsConstructor
@Table(name = "todos")
public class Todo extends Timestamped {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private String contents;
    private String weather;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @OneToMany(mappedBy = "todo", cascade = CascadeType.REMOVE)
    private List<Comment> comments = new HashSet<>();

    @OneToMany(mappedBy = "todo", cascade = CascadeType.PERSIST)
    private List<Manager> managers = new HashSet<>();
    
    ...
}
@Query("SELECT t FROM Todo t " +
        "LEFT JOIN FETCH t.user u " +
        "LEFT JOIN FETCH t.comments " +
        "LEFT JOIN FETCH t.managers " +
        "ORDER BY t.modifiedAt DESC")
Optional<Todo> findTodoWithFetch();

원인

MultipleBagFetchException은 JPQL에서 OneToMany 또는 ManyToMany 연관관계를 가지는 엔티티를 2개 이상 fetch join 하면 발생하는 문제다. (fetch type을 EAGER로 설정해도 발생한다.)

Bag 이란?

먼저 Bag이 무엇인지 알아보자.

JPA의 구현체인 Hibernate의 Collection Type들 중 하나로, 순서가 없고 중복을 허용하는 자료구조다. 자바 컬렉션에는 Bag이라는 개념이 없기 때문에 List를 사용하면 Hibernate 내부에서 Bag으로 치환해서 사용한다. (Set은 그대로 사용)

MultipleBagFetchException

MultipleBagFetchException은 두 개 이상의 Bag을 동시에 fetch join 하려고 할 때 발생한다.

SELECT * 
FROM todo t
LEFT JOIN comment c ON c.todo_id = t.id
LEFT JOIN manager m ON m.todo_id = t.id;

가져올 comments 데이터가 2개, managers 데이터가 3개 있다고 하자. 그럼 쿼리 결과는 다음과 같을 것이다.

todo idcomment idmanager id
1101201
1101202
1101203
2102201
2102202
2102203

comments가 2개, managers는 3개일 뿐인데 쿼리 결과는 카테시안 곱인 6개(2*3)다.

Bag에는 순서가 없고 중복을 허용하기 때문에 어떤 comment가 Todo의 몇 번째인지 알 수 있는 명확한 기준이 없다. 즉, Hibernate 입장에서는 row를 매핑시킬 수 없는 것이다.

그래서 Hibernate에서는 데이터를 매핑했을 때 순서를 보장할 수도 없고 효율적이지도 않으니 MultipleBagFetchException 에러를 발생시켜 버린다.

해결 방법

해결 방법에는 여러 가지가 있다.

1️⃣ 모든 연관 테이블을 lazy loading으로 가져오기

Lazy loading으로 데이터를 가져오면 프록시 객체 상태로 있다가 실제로 사용할 때 쿼리를 날리기 때문에 MultipleBagFetchException 에러가 발생하지 않는다. 근데 fetch join을 쓰는 이유가 N + 1 문제를 해결하기 위함인데 이러면 말짱도루묵이다.

2️⃣ List 대신 Set 사용

List는 최대 한 개까지만, 나머지는 Set으로 대체하면 된다.

Set은 내부적으로 중복을 방지해주기 때문에 위와 같은 문제를 방지할 수 있다. 다만 순서를 보장하지 않기 때문에 정렬이 중요하면 사용하기 어렵다.

@OneToMany(mappedBy = "todo", cascade = CascadeType.REMOVE)
private List<Comment> comments = new HashSet<>();

@OneToMany(mappedBy = "todo", cascade = CascadeType.PERSIST)
private Set<Manager> managers = new HashSet<>();

3️⃣ 가장 데이터가 많은 자식쪽에 Fetch Join을 사용 + default_batch_fetch_size 적용

https://jojoldu.tistory.com/457

fetch join을 통해서 최대한 성능 튜닝을 진행하고 fetch join으로 해결이 되지 않는 쿼리에 대해서 batch_fetch_size를 통해서 최소한의 성능을 보장하는 것이 좋다고 한다.

4️⃣ DTO Projection 사용

엔티티가 아닌 DTO로 결과를 매핑하기 때문에 중복 걱정이 없다.

(아래 코드는 저번에 사용했던 DTO Projection 예시다)

@Query(value = """
    SELECT new com.sns.api.posts.domain.dto.response.PostFlatDto(
        p.id,
        u.id,
        u.username,
        p.content,
        (SELECT CAST(count(c) as long) FROM Comments c WHERE c.post.id = p.id),
        p.createdAt,
        p.modifiedAt
    )
    FROM Posts p
    JOIN p.createdBy u
""",
countQuery = "SELECT COUNT(p) FROM Posts p")
Page<PostFlatDto> findAllWithCommentCount(Pageable pageable);

5️⃣ 쿼리 분리

튜터님께 듣기로 현업에서 자주 사용하는 방식이라고 한다.

지금처럼 join depth가 깊지 않으면 별 상관 없겠지만, todo의 manager의 user의 bookmark의... 처럼 depth가 깊어지면 한 번에 조회하는 데이터도 엄청 늘어날 것이다. 이럴 땐 한방 쿼리를 사용하는데 미련을 버리고 쿼리를 분리하는게 좋다고 한다.

@Transactional(readOnly = true)
@Override
public PostWithCommentsResponseDto getPostById(Long postId, UserBaseDto userBaseDto) {

    PostResponseDto postResponseDto = postsRepository.findPostWithQuery(postId, userBaseDto.getUserId())
            .orElseThrow(() -> new CustomException(ResultCode.NOT_FOUND, "존재하지 않는 게시글 ID 입니다.: " + postId));

    // 댓글 조회
    Page<CommentResponseDto> commentResponseDtos = commentsRepository.findAllWithQuery(
            postId,
            userBaseDto.getUserId(),
            PageRequest.of(0, 10)
    );

    return PostWithCommentsResponseDto.of(postResponseDto, commentResponseDtos);
}

참고

profile
일단 해보자

0개의 댓글