--- id: 7ed75172-fd56-42bf-956a-8f9fc1cca235 kind: CASE slug: fetch-join-multibag-and-row-explosion title: Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증 topic: jpa-feed-query-performance topicName: JPA 피드 조회 성능 project: Liner N + 1문제 status: 게시 전 studio: "https://hyeonworks.com/studio/documents/7ed75172-fd56-42bf-956a-8f9fc1cca235/edit" assets: - key: cartesian-row-multiplication file: ../../../final/assets/tech-log-studio/cartesian-row-multiplication.svg evidence: - ../../../final/evidence/raw/explain/l3-cartesian-join-plan.txt --- # 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가 없다 ## 재현 조건 1. highlights와 mentions를 동시에 join fetch하는 JPQL로 createQuery를 호출하고 예외를 확인한다. 원인 체인을 클래스명 문자열로 펼쳐 MultipleBagFetchException 포함 여부를 본다. 2. highlights만 join fetch하는 JPQL을 N ∈ {10, 100, 1000}에서 실행한다. 3. 결과 리스트 크기와 별도로 조인 카디널리티를 count(*)로 측정해 비교한다. 4. 조인 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 확인해 조인 노드의 actual rows를 본다. 5. 기존 기준선 테스트를 다시 실행해 collectionFetches == N, 접근 0에서 == 0, pageFetch == N이 유지되는지 확인한다. ## 본문 ## 실패 하나 — 두 bag 동시 fetch join ```java label="연관 전부 fetch join — 컬렉션 둘을 동시에" select distinct f from FeedItemJpaEntity f join fetch f.highlights join fetch f.mentions ``` ```text label="실제로 나온 예외 원인 체인" 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였다. ## 조인이 행을 곱하는 것을 실행계획에서 ```text label="seed(100) 직후 fetch join 조인의 EXPLAIN" 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 ```java label="연관 전부 fetch join — 컬렉션 둘을 동시에" select distinct f from FeedItemJpaEntity f join fetch f.highlights join fetch f.mentions ``` ```text label="실제로 나온 예외 원인 체인" java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchException ``` MultipleBagFetchException은 IllegalArgumentException으로 감싸져 나왔다. 테스트를 `hasCauseInstanceOf`에만 맞추면 래핑 계층이나 버전 차이에 취약하다. 원인 체인을 클래스명 문자열로 펼친 뒤 문자열 포함으로 확인했다. ## 실패 둘 — 한 bag만 fetch join」 아래 `:::evidence key="cartesian-row-multiplication"` 자리에 들어갈 그림이다. ![왼쪽 부모 테이블에서 출발한 조인이 부모 한 행을 자식 수만큼 반복한 행 묶음으로 만들어 오른쪽 전송 단계로 내보내고, 아래쪽에서 Hibernate 6 이상이 루트 엔티티를 중복 제거해 결과 리스트를 부모 수로 되돌리지만 늘어난 행은 SQL과 전송 단계에 남는다는 것을 보여 주는 그림.](../../../final/assets/tech-log-studio/cartesian-row-multiplication.svg)