안녕하세요, 여러분의 든든한 IT 길잡이 데브프리입니다.
스프링 부트(Spring Boot)로 게시판이나 쇼핑몰을 만들 때, 백엔드 개발자라면 누구나 한 번쯤 'N+1 문제'라는 거대한 벽에 부딪힙니다. 게시글(Post)을 조회할 때 딸려있는 댓글(Comments) 때문에 수십 개의 추가 쿼리가 발생하는 현상이죠. 우리는 이를 해결하기 위해 자랑스럽게 JOIN FETCH를 도입합니다.
"좋아, 쿼리가 1개로 줄었군! 그럼 게시글을 가져올 때 '댓글'이랑 '첨부파일(Files)' 컬렉션 2개를 한꺼번에 Fetch Join으로 가져오면 성능이 2배로 좋아지겠지?"
그리고 기대에 부풀어 서버를 실행하는 순간, 앱이 켜지기도 전에 아래와 같은 시뻘건 런타임 에러가 콘솔 창을 뒤덮습니다.
Plaintext
org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags
정말 당황스럽죠. 오늘은 2026년 최신 Spring Boot 3.x (Hibernate 6) 환경에서도 수많은 백엔드 주니어들을 좌절시키는 MultipleBagFetchException의 객관적인 원인을 파헤치고, 이를 우아하게 해결하는 실무 베스트 프랙티스를 팩트 기반으로 정리해 드립니다.
1. 에러의 정체: Bag(가방)이 도대체 뭐길래?
에러 메시지를 직역하면 "여러 개의 가방(Bags)을 동시에 가져올 수 없다"는 뜻입니다. 여기서 말하는 'Bag'은 하이버네이트(Hibernate)가 순서가 없고 중복을 허용하는 자바의 List 컬렉션을 부르는 내부 용어입니다.
게시글(Post) 엔티티에 2개의 List 컬렉션 연관관계가 있다고 가정해 봅시다.
Java
@Entity
public class Post {
@Id @GeneratedValue
private Long id;
// 🚨 첫 번째 Bag
@OneToMany(mappedBy = "post")
private List<Comment> comments = new ArrayList<>();
// 🚨 두 번째 Bag
@OneToMany(mappedBy = "post")
private List<AttachedFile> files = new ArrayList<>();
}
이 상태에서 SELECT p FROM Post p JOIN FETCH p.comments JOIN FETCH p.files라는 JPQL을 날리면 어떤 일이 벌어질까요?

데이터베이스는 1개의 게시글에 5개의 댓글과 3개의 첨부파일이 있을 때, 이를 조인하여 총 15줄(1 x 5 x 3)의 데이터로 뻥튀기(카테시안 곱, Cartesian Product)해서 반환합니다.
하이버네이트는 이 15줄의 결과를 분석해서 자바 객체로 예쁘게 조립하려고 시도합니다. 하지만 List는 데이터의 순서나 고유성을 보장하지 않는 자료구조(Bag)이기 때문에, 하이버네이트는 어떤 데이터가 중복된 쓰레기 값이고 어떤 데이터가 진짜인지 식별할 능력을 상실합니다.
결국 하이버네이트는 데이터 정합성이 깨질 것을 우려하여 서버 실행 자체를 막아버리고 MultipleBagFetchException을 던지는 것입니다.
2. 오해와 진실: List를 Set으로 바꾸면 끝일까?
이 에러를 구글링 해보면 가장 많이 나오는 임시방편이 있습니다. 바로 엔티티의 List 자료구조를 중복을 허용하지 않는 Set으로 바꾸라는 것이죠.
Java
// ⚠️ 인터넷에 떠도는 흔한 꼼수
@OneToMany(mappedBy = "post")
private Set<Comment> comments = new HashSet<>();
자료구조를 Set으로 변경하면 하이버네이트는 중복 데이터를 스스로 걸러낼 수 있으므로 에러가 사라지고 쿼리가 정상적으로 나갑니다. 하지만 실무에서 이 방식을 사용하는 것은 절대 권장하지 않습니다.
에러만 피했을 뿐, 데이터베이스 내부에서는 여전히 어마어마한 카테시안 곱(N x M) 연산이 일어나고 있기 때문입니다. 만약 댓글이 100개, 첨부파일이 100개 달린 게시글을 조회한다면, 데이터베이스는 무려 10,000줄의 불필요한 데이터를 생성해서 애플리케이션으로 전송해야 합니다. 엄청난 메모리 낭비와 네트워크 병목(Bottleneck)이 발생하며 트래픽이 몰리는 순간 서버가 그대로 기절해 버립니다.
3. 실무 베스트 프랙티스: Batch Size의 마법
두 마리 토끼(에러 방지와 성능 최적화)를 모두 잡기 위한 가장 완벽한 실무 표준은 '다중 Fetch Join을 과감히 포기하고 default_batch_fetch_size를 사용하는 것'입니다.
애플리케이션 전역 설정 파일(application.yml)에 단 세 줄만 추가하면 마법이 일어납니다.
YAML
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100 # 💡 IN 절로 한 번에 가져올 데이터 개수 지정
어떻게 동작할까요?
이제 코드에서 억지로 JOIN FETCH를 두 번씩 걸 필요가 없습니다. 그냥 순수하게 Post만 조회하세요.
- 하이버네이트가 먼저 Post 목록을 가져옵니다.
- 이후 로직에서 post.getComments()나 post.getFiles()를 호출하는 순간, 하이버네이트가 지연 로딩(Lazy Loading)을 실행합니다.
- 이때 기존처럼 쿼리를 100번 따로 날리지 않고, IN 쿼리를 사용해 방금 가져온 Post들의 ID를 최대 100개씩 묶어서 딱 한 번만 쿼리를 날립니다!
SQL
-- 💡 Batch Size 설정으로 인해 자동으로 최적화되어 나가는 쿼리
SELECT * FROM comment WHERE post_id IN (?, ?, ?, ..., ?); -- 최대 100개 묶음
SELECT * FROM attached_file WHERE post_id IN (?, ?, ?, ..., ?);

이렇게 구성하면 카테시안 곱으로 인한 데이터 뻥튀기도 전혀 발생하지 않고, N+1 문제도 1+1 쿼리로 극적으로 최적화되며, MultipleBagFetchException이라는 에러 자체를 원천 차단할 수 있습니다.
4. 전문가의 확고한 결론 (Verdict)
다중 컬렉션 조회 처리, 실무에 언제 도입하고 언제 피해야 할까요?
- 👍 Batch Size 옵션 (전역 적용 필수): Spring Boot + JPA 기반의 백엔드 프로젝트를 시작한다면 default_batch_fetch_size (보통 100 ~ 1000 사이) 설정은 그냥 묻지도 따지지도 말고 무조건 디폴트로 세팅하고 시작하는 것이 실무 표준입니다. 개발자가 실수로 Fetch Join을 누락하더라도 N+1의 재앙을 방어해 주는 아주 강력한 안전벨트 역할을 합니다.
- 🛑 다중 Fetch Join (도입 금지):만약 특정한 비즈니스 요구사항 때문에 1번 쿼리로 모든 걸 완벽히 조립해야 한다면, 이는 JPA 엔티티로 해결할 문제가 아니라 QueryDSL이나 JdbcTemplate을 이용해 화면 전용 DTO로 직접 매핑해 오는 방식을 채택해야 합니다.
하나의 JPQL 쿼리 안에서 X To Many (예: @OneToMany, @ManyToMany) 관계를 2개 이상 JOIN FETCH 하는 것은 그 어떤 상황에서도 절대 피해야 할 최악의 안티 패턴입니다. Set으로 변환해서 에러를 억누르는 우회법조차도 DB 리소스를 심각하게 낭비하므로 폐기해야 합니다.
💡 [데브프리의 심층 면접 꿀팁: "컬렉션이 1개면 FETCH JOIN + 페이징 써도 되나요?" 절대 안 됩니다!]
면접관이 "다중 컬렉션이 아니라 comments 딱 1개의 컬렉션만 FETCH JOIN으로 가져오면서 .setFirstResult(0).setMaxResults(10) 같은 페이징 처리를 하면 어떻게 될까요?"라고 묻는다면 함정 경보를 울려야 합니다.
정답은 "WARN 로그가 뜨면서 데이터베이스의 모든 데이터를 메모리에 퍼올린 뒤 애플리케이션 단에서 페이징을 시도하는 대참사가 일어납니다!"입니다.
DB 입장에서는 Post와 Comment가 조인되어 데이터가 뻥튀기되었기 때문에, 쿼리 레벨의 Limit / Offset을 정상적으로 적용할 수 없습니다. 따라서 하이버네이트는 DB에 있는 수십만 건의 데이터를 서버 메모리(RAM)로 싹 다 가져와서 페이징을 시도하게 되고, 곧바로 OOM으로 서버가 죽어버립니다. 결론: X To Many (컬렉션) 관계에서는 FETCH JOIN과 페이징을 절대로 함께 사용하면 안 됩니다. 이 경우에도 방금 배운 Batch Size가 유일하고 완벽한 해결책이라는 점을 꼭 명심하세요!
가장 좋은 프레임워크 최적화는 프레임워크가 제공하는 기본 동작 원리에 순응하는 것입니다.
데이터베이스 쿼리를 무리하게 한 줄로 줄이려는 억지보다는, 2~3번의 쿼리로 안전하게 나누어 가져오되 네트워크 통신(I/O) 횟수만 배치로 묶어줄 때 가장 유연하고 강력한 아키텍처가 탄생합니다.
항상 팩트에 기반한 객관적인 실무 가이드로 여러분의 삽질 시간을 줄여드리겠습니다. 다음 포스팅에서도 개발자들에게 유용한 백엔드 트러블슈팅 가이드로 찾아뵙겠습니다!
'💻 트러블슈팅 (에러 해결)' 카테고리의 다른 글
| 스케줄러가 멈췄어요! 스프링 @Scheduled 단일 스레드 병목 원인과 ThreadPool 완벽 해결법 (0) | 2026.08.16 |
|---|---|
| @Transactional을 붙였는데 롤백이 안 된다고요? 스프링 AOP 프록시 내부 호출(Self-Invocation)의 함정 (0) | 2026.08.14 |
| 예외 처리(try-catch)를 했는데 왜 롤백될까? UnexpectedRollbackException 완벽 해결법 (0) | 2026.08.13 |
| 로그인 성공했는데 403 에러? 스프링 시큐리티 6 (Spring Boot 3) CSRF와 /error 3분 해결법 (0) | 2026.08.12 |
| 쿼리가 폭주해요! JPA N+1 문제 원인과 Fetch Join 해결법 (마트 심부름 비유) (0) | 2026.07.27 |