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
이 무엇인지 알아보자.
JPA의 구현체인 Hibernate의 Collection Type들 중 하나로, 순서가 없고 중복을 허용하는 자료구조다. 자바 컬렉션에는 Bag
이라는 개념이 없기 때문에 List
를 사용하면 Hibernate 내부에서 Bag
으로 치환해서 사용한다. (Set
은 그대로 사용)
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 id | comment id | manager id |
---|---|---|
1 | 101 | 201 |
1 | 101 | 202 |
1 | 101 | 203 |
2 | 102 | 201 |
2 | 102 | 202 |
2 | 102 | 203 |
comments가 2개, managers는 3개일 뿐인데 쿼리 결과는 카테시안 곱인 6개(2*3)다.
Bag
에는 순서가 없고 중복을 허용하기 때문에 어떤 comment가 Todo의 몇 번째인지 알 수 있는 명확한 기준이 없다. 즉, Hibernate 입장에서는 row를 매핑시킬 수 없는 것이다.
그래서 Hibernate에서는 데이터를 매핑했을 때 순서를 보장할 수도 없고 효율적이지도 않으니 MultipleBagFetchException
에러를 발생시켜 버린다.
해결 방법에는 여러 가지가 있다.
Lazy loading으로 데이터를 가져오면 프록시 객체 상태로 있다가 실제로 사용할 때 쿼리를 날리기 때문에 MultipleBagFetchException
에러가 발생하지 않는다. 근데 fetch join을 쓰는 이유가 N + 1 문제를 해결하기 위함인데 이러면 말짱도루묵이다.
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<>();
https://jojoldu.tistory.com/457
fetch join을 통해서 최대한 성능 튜닝을 진행하고 fetch join으로 해결이 되지 않는 쿼리에 대해서 batch_fetch_size를 통해서 최소한의 성능을 보장하는 것이 좋다고 한다.
엔티티가 아닌 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);
튜터님께 듣기로 현업에서 자주 사용하는 방식이라고 한다.
지금처럼 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);
}