JPA와 Hibernate를 사용할 때, 연관된 엔티티를 효율적으로 가져오기 위해 흔히 Fetch Join을 사용합니다. 그러나 Fetch Join을 잘못 사용할 경우 예상치 못한 문제가 발생할 수 있습니다. 이번 글에서는 Hibernate 공식 문서를 기준으로 이러한 문제들을 명확히 분석하고 올바른 해결법을 제시합니다.
🔥 Fetch Join과 카르테시안 곱
다음과 같은 엔티티가 있다고 가정합시다.
@Entity
public class A {
@Id
private Long id;
@OneToMany(mappedBy = "a", fetch = FetchType.LAZY)
private List<B> bList = new ArrayList<>();
@OneToMany(mappedBy = "a", fetch = FetchType.LAZY)
private List<C> cList = new ArrayList<>();
}
이 상황에서 흔히 다음과 같은 QueryDSL 쿼리를 작성할 수 있습니다.
A a = queryFactory
.selectFrom(QA.a)
.leftJoin(QA.a.bList, QB.b).fetchJoin()
.leftJoin(QA.a.cList, QC.c).fetchJoin()
.where(QA.a.id.eq(1L))
.fetchOne();
그러나 실제로 위 코드를 실행하면 Hibernate는 다음과 같은 예외를 즉시 발생시킵니다.
org.hibernate.loader.MultipleBagFetchException:
cannot simultaneously fetch multiple bags: [A.bList, A.cList]
이 예외가 발생하는 이유는 Hibernate가 기본적으로 List 컬렉션을 Bag 타입으로 인식하기 때문입니다. Bag 타입은 중복을 허용하며 순서가 없습니다. 두 개 이상의 Bag 컬렉션을 동시에 Fetch Join 하면 카르테시안 곱(행의 조합 폭발)으로 인해 데이터를 정확히 매핑할 수 없기 때문에 Hibernate는 이를 명시적으로 방지하고자 예외를 발생시키는 것입니다.
📖 Hibernate 공식 문서에서의 설명
Hibernate 공식 문서(MultipleBagFetchException)는 이를 다음과 같이 명시하고 있습니다.
Hibernate does not allow fetching more than one bag because that would generate a Cartesian product, causing performance issues and unexpected results. Therefore, fetching multiple bags simultaneously raises a MultipleBagFetchException.
즉, Hibernate는 명시적으로 두 개 이상의 Bag 타입 컬렉션에 Fetch Join을 허용하지 않음을 알 수 있습니다.
⚠️ MultipleBagFetchException이 발생하지 않고 중복 조회가 발생하는 경우
다음과 같은 경우에는 MultipleBagFetchException이 발생하지 않지만 엔티티가 중복 조회될 수 있습니다.
1. 하나의 Bag과 다른 타입 컬렉션(Set)을 동시에 Fetch Join
Hibernate는 여러 개의 Bag 컬렉션을 동시에 Fetch Join하는 것을 방지하지만, 하나의 Bag과 하나의 Set을 동시에 Fetch Join하는 경우에는 예외가 발생하지 않습니다. 그러나 결과적으로는 카르테시안 곱에 의해 중복된 데이터가 조회될 수 있습니다.
2. 여러 개의 Set 컬렉션을 Fetch Join
Set을 사용하는 경우 중복을 허용하지 않기 때문에 예외가 발생하지 않지만, 여전히 조인 결과로 인해 중복된 엔티티가 조회될 수 있습니다.
이러한 상황을 방지하기 위해서는 Fetch Join을 사용할 때 주의가 필요하며, 필요에 따라 쿼리를 분리하거나 Projection을 사용하는 방식을 권장합니다.
🤔 Hibernate는 왜 중복을 제거하지 않을까?
Hibernate는 분명 각 엔티티의 식별자(ID)를 알고 있기 때문에 이론적으로는 중복을 충분히 제거할 수 있습니다. 하지만 Hibernate가 중복을 제거하지 않는 이유는 성능 문제 때문입니다. 중복 제거를 하려면 결과 집합에서 중복 여부를 매번 검사해야 하며, 이는 선형 탐색(O(n))을 요구하므로 데이터가 많아질수록 성능상 큰 부담을 줍니다. 따라서 Hibernate는 정확성보다는 성능을 우선하여 중복 제거의 책임을 명시적으로 개발자에게 위임하고 있습니다.
📌 페이지네이션(fetch join + limit, offset)이 들어간다면?
컬렉션을 Fetch Join하면서 페이징(limit, offset)을 사용하면, DB는 엔티티 개수가 아닌 조인 결과의 행 수를 기준으로 페이징을 처리하게 됩니다. 따라서 예상과 달리 중복된 엔티티가 포함된 부정확한 페이징 결과가 발생합니다. 즉, Fetch Join과 페이징을 함께 사용하는 것은 권장되지 않으며, 반드시 별도의 쿼리나 DTO Projection을 통해 정확한 페이징을 처리해야 합니다.
🔍 Projection을 사용해도 동일할까?
Projection(DTO를 사용하여 특정 필드만 조회) 방식을 사용하면 중복된 엔티티 문제가 해결됩니다. 그 이유는 Projection 쿼리는 필요한 필드만 명시적으로 조회하고, 명시적인 distinct 처리를 통해 중복을 직접 제거할 수 있기 때문입니다.
List<ADto> results = queryFactory
.selectDistinct(Projections.constructor(ADto.class,
QA.a.id,
QB.b.someField,
QC.c.otherField
))
.from(QA.a)
.leftJoin(QA.a.bList, QB.b)
.leftJoin(QA.a.cList, QC.c)
.offset(0)
.limit(10)
.fetch();
위와 같이 distinct와 명시적인 필드 선택을 통해 카르테시안 곱에서 발생하는 중복 데이터를 효과적으로 제거할 수 있으며, 정확한 페이징 결과를 얻을 수 있습니다.
'JVM > JPA' 카테고리의 다른 글
PostgreSQL Array 타입 JPA 연동 (0) | 2023.11.12 |
---|---|
2차 캐시 (0) | 2023.07.05 |
낙관적 락과 비관적 락 (0) | 2023.07.05 |
트랜잭션을 지원하는 쓰기 지연 (0) | 2023.07.04 |
SQL 쿼리 힌트 사용 (0) | 2023.07.04 |