서론
이번에는 JPA를 사용하면서 발생한 MultipleBagException 오류와 해결하는 방법에 대해서 알아보도록 하겠습니다.
구조
AppUser
@Getter
@Setter
@ToString
@Entity
@Table(name = "app_user")
public class AppUser {
...
@OneToMany(mappedBy = "user")
private List<Board> boards;
@OneToMany(mappedBy = "user")
private List<Comment> comments;
}
AppUserRepository
@Query(
"SELECT u FROM AppUser u" +
" JOIN FETCH u.boards b" +
" JOIN FETCH u.comments c" +
" WHERE u.userId = :userId"
)
Optional<AppUser> findByUserId(@Param("userId") Long userId);
AppUserSerivce
@Service
@RequiredArgsConstructor
public class AppUserService {
private final AppUserRepository userRepository;
public AppUserDTO getUser() {
AppUser user = userRepository.findByUserId(1L)
.orElseThrow(() -> new IllegalArgumentException("User Not Found."));
return AppUserDTO.builder()
.userId(user.getUserId())
.nickname(user.getNickname())
.createdTime(user.getCreatedTime())
.boards(
user.getBoards().stream().map(
board -> BoardDTO.builder()
.boardId(board.getBoardId())
.title(board.getTitle())
.content(board.getContent())
.createdTime(board.getCreatedTime())
.build()
).toList()
)
.comments(
user.getComments().stream().map(
comment -> CommentDTO.builder()
.commentId(comment.getCommentId())
.content(comment.getContent())
.createdTime(comment.getCreatedTime())
.build()
).toList()
)
.build();
}
}
위와 같이 코드를 작성하고, 돌려보니 다음과 같은 에러가 발생하였다.

발생 원인
MultipleBagException은 @OnteToMany 등 연관 관계가 List 형태로 2개 이상이 JOIN으로 즉시 로딩할라고 할 때 발생하는 에러이다.
하나의 유저가 게시글과 댓글을 2개씩 썼다고 가정해보자.

사용자의 관점에서는 "User 객체와 List로 이루어진 게시글과 댓글"을 반환할 거다 생각하고 코드를 짰을 겁니다.
즉, AppUser Entity에 선언한 형태로 반환될 거라고 생각할 겁니다.
하지만, DB 관점으로 다시 생각해보겠습니다.
SQL 문의 반환은 하나의 컬럼으로 무조건 만들어야 하기 때문에 다음과 같은 결과를 반환하게 됩니다.

즉, 같은 데이터가 중복적으로 발생하여 수많은 조합을 만들어내는 카테시안 곱이 만들어지게 됩니다.
MultipleBagException은 이러한 현상을 막기 위해서 예외를 발생시킵니다.
해결 방법
List 대신 Set
많은 블로그에서 제안하는 방법은 List 대신 Set을 사용하자입니다.
@Getter
@Setter
@ToString
@Entity
@Table(name = "app_user")
public class AppUser {
...
@OneToMany(mappedBy = "user")
private Set<Board> boards;
@OneToMany(mappedBy = "user")
private Set<Comment> comments;
}

위와 같이 해결되어 나오는 것을 볼 수 있다.
하지만, Set을 List 대신 사용하는 것은 최악의 해결법입니다.
Set을 사용해 중복을 제거하여 예외를 피했지만, 결국 DB 단에서는 카테시안 곱은 여전히 발생하고 있는 겁니다.
해당 내용은 다음에서 자세히 확인해보실 수 있습니다.
MultipleBagFetchException thrown by Hibernate
I want to have an option in my repository layer to eager load entites, so I tried adding a method that should eager load a question entity with all the relationships, but it throws
stackoverflow.com
쿼리 분리
두 개를 동시에 Join 하는 것이 아니라 쿼리를 분리하여 하나씩 가져옵니다.
AppUserService
@Slf4j
@Service
@RequiredArgsConstructor
public class AppUserService {
private final AppUserRepository userRepository;
private final BoardRepository boardRepository;
private final CommentRepository commentRepository;
public AppUserDTO getUser() {
AppUser user = userRepository.findByUserId(1L)
.orElseThrow(() -> new IllegalArgumentException("User Not Found."));
// 쿼리 분리
List<Board> boards = boardRepository.findByUserId(user.getUserId());
List<Comment> comments = commentRepository.findByUserId(user.getUserId());
return AppUserDTO.builder()
.userId(user.getUserId())
.nickname(user.getNickname())
.createdTime(user.getCreatedTime())
.boards(
boards.stream().map(
board -> BoardDTO.builder()
.boardId(board.getBoardId())
.title(board.getTitle())
.content(board.getContent())
.createdTime(board.getCreatedTime())
.build()
).toList()
)
.comments(
comments.stream().map(
comment -> CommentDTO.builder()
.commentId(comment.getCommentId())
.content(comment.getContent())
.createdTime(comment.getCreatedTime())
.build()
).toList()
)
.build();
}
}
AppUserRepository
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
@Query(
"SELECT u FROM AppUser u WHERE u.userId = :userId"
)
Optional<AppUser> findByUserId(@Param("userId") Long userId);
}
BoardRepository
public interface BoardRepository extends JpaRepository<Board, Long> {
Optional<Board> findFirstBy();
@Query(
"SELECT b FROM Board b" +
" JOIN FETCH b.user" +
" WHERE b.user.userId = :userId"
)
List<Board> findByUserId(@Param("userId") Long userId);
}
CommentRepository
public interface CommentRepository extends JpaRepository<Comment, Long> {
@Query(
"SELECT c FROM Comment c" +
" JOIN FETCH c.user" +
" WHERE c.user.userId = :userId"
)
List<Comment> findByUserId(@Param("userId") Long userId);
}
이 방법이 저는 가장 좋은 것 같습니다.
User 1개, Board 1개, Comment 1개 해서 총 3개의 쿼리가 나가게 됩니다.
'Project 하면서 알아가는 것들' 카테고리의 다른 글
JPA의 N + 1 문제를 알아보고 해결하자 (0) | 2025.03.23 |
---|---|
@Transactional의 readonly와 OSIV는 뭐 하는 친구일까? (0) | 2025.03.19 |
[Spring Boot + Python] Kafka를 사용해보자 (4) | 2024.09.30 |
Kafka와 RabbitMQ를 알아보자 (0) | 2024.08.20 |
[Nextjs] Tiptap 사용법과 커스텀마이징 기능 구현 (1) | 2024.02.28 |