9.2 KiB
id, kind, slug, title, topic, project, status, studio, assets, evidence
| id | kind | slug | title | topic | project | status | studio | assets | evidence | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 7ed75172-fd56-42bf-956a-8f9fc1cca235 | CASE | fetch-join-multibag-and-row-explosion | Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증 | JPA 피드 조회 성능 | Liner N + 1문제 | 게시 전 | https://hyeonworks.com/studio/documents/7ed75172-fd56-42bf-956a-8f9fc1cca235/edit |
|
|
Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증
나누어 가져오지 말고 한 번에 가져오려고 연관을 모두 join fetch했다. 컬렉션 두 개를 동시에 fetch join하자 MultipleBagFetchException이 발생했고, 하나만 합치자 전송 행수가 시드 하이라이트 총량과 같아졌다. 쿼리 수는 줄었지만 비용이 전송 행수와 메모리로 옮겨 갔다.
관계
- Fetch Join · Batch · Projection 선택 기준 이 실패에서 나온 선택 기준이다.
- Fetch 타입이 아니라 조회 방식이 만든 ToOne N+1 이 시도가 풀려던 문제다.
- Collection Fetch Join Pagination의 In-memory Paging 한 bag만 fetch join한 상태에서 페이징을 적용한 다음 기록이다.
문제
컬렉션 N+1과 ToOne의 숨은 쿼리를 확인한 뒤 user·page·highlights·mentions를 모두 join fetch로 루트 SQL에 합쳐 보았다.
MultipleBagFetchException을 재현하려면 fetch join할 두 번째 bag이 필요했다. 기준선 스키마에는 highlights만 있어서 목표 스키마의 feed_item_mentions를 퍼시스턴스 계층까지만 먼저 추가했다. 도메인 애그리거트·응답 매핑·공개 범위 판정은 뒤로 미뤘다.
결론
두 bag을 동시에 fetch join하면 쿼리 생성 시점에 거부된다. bag은 순서 컬럼이 없는 List라, feed_item 한 행이 highlights h개 × mentions m개로 늘어난 곱집합을 원래 컬렉션으로 되돌릴 수 없기 때문이다. 데이터가 0건이어도 발생하는 매핑 단계의 거부다.
컬렉션을 하나만 fetch join하면 예외는 없지만 부모가 자식 수만큼 반복된 행이 전송된다. 전송 행수는 항상 시드 하이라이트 총량과 정확히 일치했다.
Hibernate 6 이상은 fetch join의 루트 엔티티를 자동으로 중복 제거한다. 그래서 결과 리스트 크기는 N이고, 카테시안은 SQL과 전송 단계에만 남는다. 리스트 크기로는 이 문제가 보이지 않는다.
같은 N=100에서 기준선 222개가 121개로 줄었지만 그중 120개는 여전히 ToOne 2차 SELECT였고, 조인 하나가 1,961행을 전달했다. 쿼리 수만 보면 개선처럼 보이는 구간이다.
검증 환경
Java 21 Spring Boot 4.0.0 Hibernate ORM 7.1.8.Final PostgreSQL : postgres:16-alpine (Testcontainers)
추가한 것 마이그레이션 : V7__feed_mentions.sql 엔티티 : FeedItemMentionJpaEntity 부모 매핑 : @OneToMany List mentions feed_item_mentions : 대리키 id + UNIQUE(feed_item_id, mentioned_user_id)
측정 방식 전송 행수는 resultList.size()가 아니라 조인 카디널리티로 측정 SELECT count(*) FROM feed_items fi JOIN highlights h ON h.feed_item_id = fi.id 이 절의 쿼리는 원시 JPQL이라 Spring Data count가 없다
재현 조건
-
highlights와 mentions를 동시에 join fetch하는 JPQL로 createQuery를 호출하고 예외를 확인한다. 원인 체인을 클래스명 문자열로 펼쳐 MultipleBagFetchException 포함 여부를 본다.
-
highlights만 join fetch하는 JPQL을 N ∈ {10, 100, 1000}에서 실행한다.
-
결과 리스트 크기와 별도로 조인 카디널리티를 count(*)로 측정해 비교한다.
-
조인 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 확인해 조인 노드의 actual rows를 본다.
-
기존 기준선 테스트를 다시 실행해 collectionFetches == N, 접근 0에서 == 0, pageFetch == N이 유지되는지 확인한다.
본문
실패 하나 — 두 bag 동시 fetch join
select distinct f from FeedItemJpaEntity f
join fetch f.highlights
join fetch f.mentions
java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchException
MultipleBagFetchException은 IllegalArgumentException으로 감싸져 나왔다. 테스트를 hasCauseInstanceOf에만 맞추면 래핑 계층이나 버전 차이에 취약하다. 원인 체인을 클래스명 문자열로 펼친 뒤 문자열 포함으로 확인했다.
실패 둘 — 한 bag만 fetch join
:::evidence key="cartesian-row-multiplication" alt="왼쪽 부모 테이블에서 출발한 조인이 부모 한 행을 자식 수만큼 반복한 행 묶음으로 만들어 오른쪽 전송 단계로 내보내고, 아래쪽에서 Hibernate 6 이상이 루트 엔티티를 중복 제거해 결과 리스트를 부모 수로 되돌리지만 늘어난 행은 SQL과 전송 단계에 남는다는 것을 보여 주는 그림." caption=" " zoom="true" :::
| N | 전송 행수(조인 카디널리티) | 리스트 크기(Hib6 dedup) | distinct 아이템 | 시드 하이라이트 | 폭발 배수 | 총 PreparedStatement |
|---|---|---|---|---|---|---|
| 10 | 1,285 | 10 | 10 | 1,285 | 128.5× | 14 |
| 100 | 1,961 | 100 | 100 | 1,961 | 19.6× | 121 |
| 1,000 | 2,917 | 1,000 | 1,000 | 2,917 | 2.9× | 1,021 |
전송 행수는 언제나 시드 하이라이트 총량과 같았다. 편중 분포에서 뒤쪽 아이템은 하이라이트가 하나뿐이라 폭발 배수는 128.5×에서 2.9×로 줄었지만 절대 전송 행수는 계속 하이라이트 총합이었다.
쿼리 수만 보면 개선처럼 보인다
| 구분 | 기준선 loadFeed | highlights fetch join | 결과 |
|---|---|---|---|
| 목록 루트 | 1 (content) | 1 (join) | 루트가 조인 한 방으로 바뀜 |
| Page count | 1 | 0 | 원시 JPQL이라 Spring Data count 없음 |
| highlights 컬렉션 | 100 | 0 | N개 컬렉션 SELECT가 조인으로 접힘 |
| ToOne(User+Page) | 120 | 120 | 그대로 — highlights만 fetch join했으므로 |
| 합 | 222 | 121 |
222개가 121개로 줄어든 주된 이유는 컬렉션 N개가 루트 조인 하나로 합쳐졌기 때문이다. 121개 중 120개는 여전히 ToOne 2차 SELECT였다.
조인이 행을 곱하는 것을 실행계획에서
Hash Join (cost=77.18..512.34 rows=4202 width=32) (actual time=0.589..0.894 rows=1961 loops=1)
Hash Cond: (h.feed_item_id = fi.id)
-> Seq Scan on highlights h (actual ... rows=1961 loops=1)
-> Hash (actual ... rows=100 loops=1)
-> Seq Scan on feed_items fi (actual ... rows=100 loops=1)
Execution Time: 0.959 ms
부모 feed_items는 100행인데 Hash Join 노드의 actual rows는 1,961이다. 쿼리는 하나인데 그 하나가 실어 나르는 행이 곱이라는 사실은 리스트 크기로는 보이지 않고 실행계획에서 드러난다.
추정 rows=4202와 실제 rows=1961의 오차는 대량 시드 직후 ANALYZE를 실행하지 않은 통계 문제다.
측정 정정
처음에는 distinct 없는 결과 리스트 크기가 전송 행수와 같을 것으로 예상했다. 실제 리스트 크기는 N이었다. Hibernate 6 이상이 fetch join의 루트 엔티티를 자동으로 중복 제거하기 때문이다.
카테시안은 SQL과 전송 단계에 그대로 남아 있다. 이 문제는 EXPLAIN의 actual rows나 조인 count로 확인해야 한다.
이 실패를 남긴 이유
distinct나 List에서 Set으로 바꾸기, @BatchSize로 바로 우회하지 않고 실패를 별도 테스트에 남겼다. fetch join이 만든 페이징 문제와 그다음 Batch Fetch 선택까지 이어서 확인하기 위해서다.
로컬 미리보기
본문 「실패 하나 — 두 bag 동시 fetch join
select distinct f from FeedItemJpaEntity f
join fetch f.highlights
join fetch f.mentions
java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchException
MultipleBagFetchException은 IllegalArgumentException으로 감싸져 나왔다. 테스트를 hasCauseInstanceOf에만 맞추면 래핑 계층이나 버전 차이에 취약하다. 원인 체인을 클래스명 문자열로 펼친 뒤 문자열 포함으로 확인했다.