![]()
이전 글: 청구서 금액이 2배로 나왔다 — 비개발자도 이해할 수 있는 데이터베이스 곱셈 버그에서 “두 장의 엑셀 시트를 동시에 합치면 곱셈이 일어난다”는 비유로 이 버그를 설명했다. 이번 글에서는 JPA/Hibernate 관점에서 정확히 왜 이런 일이 생기는지,
DISTINCT로는 왜 안 되는지, 올바른 해결법은 무엇인지를 다룬다.
증상을 요약하면: DB에는 주문 항목이 2개뿐인데 API 응답에서 4개로 뻥튀기됐다. 원인은 하나의 JPQL 쿼리에서 두 개의 @OneToMany 컬렉션을 동시에 JOIN FETCH한 것이다.
원인: 두 컬렉션의 Cartesian Product
문제의 쿼리:
@Query("SELECT i FROM Invoice i " +
"LEFT JOIN FETCH i.lineItems " + // @OneToMany List
"LEFT JOIN FETCH i.appliedDiscounts " + // @OneToMany Set
"JOIN FETCH i.studentProfile sp " +
"JOIN FETCH sp.user u " +
"WHERE i.campus.id = :campusId " +
"AND i.billingYear = :year AND i.billingMonth = :month " +
"ORDER BY u.name")
List<Invoice> findAllByCampusAndMonth(...);
lineItems(List)와 appliedDiscounts(Set)를 동시에 JOIN FETCH하고 있다.
SQL 실행 결과
이 JPQL이 실제로 생성하는 SQL을 보면:
SELECT o.*, oli.*, oad.*
FROM orders o
LEFT JOIN order_line_item oli ON oli.order_id = o.id
LEFT JOIN order_applied_discount oad ON oad.order_id = o.id
WHERE ...
Order 369에 lineItem 2개, appliedDiscount 2개가 있으면:

2 × 2 = 4행. 이것이 cartesian product다.
Hibernate의 컬렉션 처리 방식
Hibernate는 이 4행을 엔티티로 변환할 때:
Set(appliedDiscounts):equals/hashCode기반으로 자동 중복 제거 → 2개 (정상)List(lineItems): bag semantics — 중복 제거하지 않음 → 4개 (oli.id 385, 385, 386, 386)
List는 순서가 있는 컬렉션이고, Hibernate는 SQL 결과의 각 행을 List에 그대로 추가한다. 같은 엔티티가 여러 행에 걸쳐 나타나면, 같은 객체가 List에 여러 번 들어간다.
“그럼 List 대신 Set으로 바꾸면 되지 않나?” 주문 항목은 입력 순서대로 표시해야 한다. Set은 순서를 보장하지 않기 때문에 List를 쓸 수밖에 없었다. 올바른 자료구조 선택이 cartesian product와 만나면서 예상치 못한 버그가 된 것이다.
MultipleBagFetchException은 왜 안 터졌나?
Hibernate 5 이전 버전이었다면, 두 개의 List 컬렉션을 동시에 FETCH할 때 MultipleBagFetchException을 던지며 서버가 시작되지 않았을 것이다. 하지만 이 코드에서는 하나가 List, 다른 하나가 Set이기 때문에 예외 없이 통과한다. 에러 없이 데이터만 뻥튀기되므로 오히려 더 위험하다.
DISTINCT로는 해결되지 않는다
첫 번째 시도로 SELECT DISTINCT를 추가해봤다:
@Query("SELECT DISTINCT o FROM Order o " +
"LEFT JOIN FETCH o.lineItems " +
"LEFT JOIN FETCH o.appliedDiscounts " + ...)
효과 없음. 이유:
Hibernate 6에서 JPQL DISTINCT는 root entity(Order)의 중복을 제거한다. 결과 리스트에 같은 Order 객체가 여러 번 나타나는 것을 방지하는 것이다.
하지만 Order 내부의 lineItems List 중복은 건드리지 않는다. Order 369는 결과에 1번만 나타나지만, 그 Order의 lineItems는 이미 4개가 채워진 상태다.
해결: 컬렉션 FETCH 분리
근본 원칙: 하나의 쿼리에서 여러 @OneToMany 컬렉션을 JOIN FETCH하지 않는다.
적용한 해결법: @BatchSize
// Order.java
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
@BatchSize(size = 50) // 별도 IN 쿼리로 배치 로딩
private Set<OrderAppliedDiscount> appliedDiscounts = new LinkedHashSet<>();
// OrderRepository.java — appliedDiscounts JOIN FETCH 제거
@Query("SELECT DISTINCT o FROM Order o " +
"LEFT JOIN FETCH o.lineItems " +
// LEFT JOIN FETCH o.appliedDiscounts — 제거!
"JOIN FETCH o.customer c " +
"WHERE o.billingYear = :year AND o.billingMonth = :month ...")
List<Order> findAllByMonth(...);
이제 실행 흐름:
- 쿼리 1: Order + lineItems JOIN FETCH → lineItem 수만큼 행 (중복 없음)
- 쿼리 2 (자동):
SELECT * FROM order_applied_discount WHERE order_id IN (?, ?, ..., ?)→ 최대 50개씩 배치
쿼리가 1개에서 2개로 늘었지만, cartesian product가 사라져 정확한 데이터가 반환된다.
매번 엔티티마다 @BatchSize를 붙이기 번거롭다면, application.yml에서 프로젝트 전체에 기본값을 설정할 수도 있다:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
대안들

핵심 정리
하나의 쿼리에서 여러 @OneToMany 컬렉션을 JOIN FETCH하면
SQL Cartesian Product → List(bag) 컬렉션 중복 → 데이터 뻥튀기
- DB는 정상, API만 이상 → JPA 쿼리의 컬렉션 FETCH 확인
DISTINCT는 root entity 중복만 제거, List 내부 중복은 해결 못 함Set은 자동 중복 제거,List(bag)는 안 됨- 해결: 한 쿼리에 하나의 컬렉션만 FETCH + 나머지는
@BatchSize
Comments