JPA를 사용하다 보면 피할 수 없는 숙명, N+1 문제를 해결하기 위해 우리는 주로 Fetch Join이나 @EntityGraph를 사용합니다.
하지만 의욕적으로 연관된 엔티티를 모두 가져오려다 보면 MultipleBagFetchException이라는 낯선 에러를 마주하게 됩니다. 오늘은 @EntityGraph의 원리부터, 이 에러가 발생하는 이유, 그리고 실무에서 가장 권장하는 해결책인 Batch Size까지 한 번에 정리해 보겠습니다.
1. @EntityGraph란 무엇인가?
Fetch Join은 JPQL을 직접 작성해야 하는 번거로움이 있습니다. 이를 해결하기 위해 Spring Data JPA는 어노테이션만으로 Fetch Join 기능을 수행할 수 있는 @EntityGraph 기능을 제공합니다.
사용법
// Member를 조회할 때 Team도 같이 가져와! (지연 로딩 무시) 만약 2개시 team, member 이런식..
@EntityGraph(attributePaths = {"team"})
List<Member> findAll();
작동 원리 (핵심)
@EntityGraph를 사용하면 내부적으로 Left Outer Join이 실행됩니다.
- Fetch Join (JPQL): 기본적으로 Inner Join (데이터가 있는 교집합만 조회)
- @EntityGraph: 기본적으로 Left Outer Join (연관 데이터가 없어도 원본 데이터는 조회) -> 카테시안 곱이 발생.. 데이터 과부화
장단점 비교
| 구분 | 장점 | 단점 |
| 장점 | 1. 간편함: JPQL 없이 어노테이션 하나로 N+1 해결. 2. 안전함: Left Outer Join이라 데이터 누락 걱정이 없음. |
|
| 단점 | 1. 제어 불가: 무조건 Outer Join이라 Inner Join이 필요한 성능 최적화엔 불리. 2. 복잡도: 관계가 복잡해지면 어노테이션이 지저분해짐. |
2. 문제 발생: MultipleBagFetchException
실무에서 욕심을 부려 "Member를 조회할 때, 주문 목록(Orders)도 가져오고, 쿠폰 목록(Coupons)도 한 번에 가져와야지!" 라고 코드를 짜면 에러가 터집니다.
@EntityGraph(attributePaths = {"orders", "coupons"}) // List 두 개 동시 조회
List<Member> findAll();
Error: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags
왜 발생하는가?
이유는 "Cartesian Product (카테시안 곱)" 때문입니다.
1:N 관계인 컬렉션을 두 개 이상 Join 하게 되면, 데이터가 N × M으로 폭발적으로 늘어납니다.
(예: 회원 1명 * 주문 100개 * 쿠폰 100개 = 10,000줄의 데이터 생성)
자바의 List는 순서가 있고 중복을 허용하는 자료구조(Bag)입니다. 하이버네이트 입장에서는 이렇게 뻥튀기된 데이터 중에서 "어떤 게 진짜 중복이고, 어떤 게 유효한 데이터인지" 식별할 수 없어 아예 예외를 던지고 실행을 막아버립니다.
3. 첫 번째 해결책: List를 Set으로 변경
가장 널리 알려진(하지만 위험한) 해결책은 자료형을 List에서 Set으로 바꾸는 것입니다.
// List 대신 Set 사용
private Set<Order> orders = new HashSet<>();
private Set<Coupon> coupons = new HashSet<>();
왜 해결되는가?
Set은 중복을 허용하지 않는 자료구조입니다. 하이버네이트는 "Set이니까 중복 데이터가 들어와도 알아서 걸러지겠군"이라고 판단하고 Fetch Join을 허용해 줍니다.
치명적인 단점 (함정)
- 성능 문제는 그대로: 에러만 안 날 뿐, DB 내부적으로는 여전히 N × M개의 엄청난 데이터를 조인해서 가져옵니다. (메모리 낭비)
- 순서 보장 불가: Set은 순서가 뒤죽박죽입니다. "최신순 정렬" 같은 비즈니스 로직이 다 깨집니다. LinkedHashSet을 써도 근본적인 데이터 뻥튀기는 막을 수 없습니다.
4. 실무 권장 해결책: default_batch_fetch_size
가장 깔끔하고 성능상 이점이 많은 방법은 **"억지로 Join 하지 말고, 똑똑하게 끊어서 가져오는 것"**입니다. 이를 위해 Batch Size 옵션을 사용합니다.
적용 방법 (application.yml)
컬렉션들은 Lazy Loading 상태로 놔두고, 아래 옵션을 추가합니다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000 # 보통 1000 권장
동작 원리 (IN 절 쿼리)
이 옵션을 켜면, JPA는 데이터를 한 번에 Join 해서 가져오는 대신, SQL의 IN 절을 사용하여 필요한 데이터를 한 번에 당겨옵니다.
- SELECT * FROM Member (회원 100명 조회) -> 쿼리 1번
- (회원의 주문 목록을 사용할 때)
- SELECT * FROM Orders WHERE member_id IN (1, 2, ... 100) -> 쿼리 1번
장점
- 데이터 뻥튀기 없음: 필요한 데이터만 정확하게 가져옵니다. (100+100=200개. 아까처럼 10,000개가 아님)
- N+1 문제 해결: 100번 나갈 쿼리가 단 1번의 IN 쿼리로 줄어듭니다.
- 페이징 가능: Fetch Join을 쓰면 페이징이 불가능(메모리 페이징)하지만, Batch Size는 페이징 쿼리가 정상적으로 나갑니다.
단점
- 쿼리 개수: Fetch Join(1방)보다는 쿼리가 더 나갑니다(N+1이 아니라, 1+1). 하지만 데이터 전송량을 고려하면 오히려 더 빠를 때가 많습니다.
5. 결론 및 요약
상황에 따라 아래와 같이 전략을 선택하세요.
- OneToOne, ManyToOne 관계:
- 그냥 @EntityGraph나 Fetch Join을 사용해서 한 방 쿼리로 가져오세요.
- OneToMany (컬렉션) 관계가 1개일 때:
- Fetch Join을 써도 됩니다.
- OneToMany (컬렉션) 관계가 2개 이상일 때:
- 절대 Set으로 바꿔서 억지로 조인하지 마세요.
- 그냥 Lazy Loading으로 두고 default_batch_fetch_size를 사용한다.
Fetch Join은 "수동 기어(정교함)"이고, @EntityGraph는 "오토 기어(편리함)"
'데이터베이스' 카테고리의 다른 글
| B+, B - tree Index (3) | 2024.06.08 |
|---|---|
| INDEXING (0) | 2024.06.08 |
| SQL 기초 (0) | 2024.06.08 |