--- 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 피드 조회 성능 — N+1 진단과 조회 전략의 진화 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 sourceRevision: n+1liner-lab@2026-08 source: - final/document.md#9-2 - final/document.md#9-3 - final/document.md#9-5 --- # 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** 컬렉션 하나만 fetch join한 상태에서 페이징을 적용한 다음 기록이다. ## 문제 컬렉션 N+1과 User·Page 연관의 숨은 쿼리를 확인한 뒤 user·page·highlights·mentions를 모두 join fetch로 루트 SQL에 합쳐 보았다. MultipleBagFetchException을 재현하려면 fetch join할 컬렉션이 둘 이상 필요했다. 처음 만든 스키마에는 highlights만 있어서 목표 스키마의 feed_item_mentions를 퍼시스턴스 계층까지만 먼저 추가했다. 도메인 애그리거트·응답 매핑·공개 범위 판정은 뒤로 미뤘다. ## 결론 bag은 순서 컬럼이 없는 List 매핑이다. highlights와 mentions가 둘 다 bag이면 feed_item 한 행이 highlights h개 × mentions m개로 늘어난다. Hibernate는 이 곱집합을 원래 컬렉션으로 되돌릴 수 없다고 판단해 쿼리 생성 시점에 예외를 던진다. 데이터가 0건이어도 예외는 그대로 나온다. 실행 결과가 아니라 매핑 단계에서 막기 때문이다. 컬렉션을 하나만 fetch join하면 예외는 나지 않지만 부모 한 행이 자식 수만큼 반복돼 전송된다. 전송 행수는 항상 시드 하이라이트 총량과 정확히 일치했다. Hibernate 6 이상이 fetch join의 루트 엔티티를 자동으로 중복 제거하므로 결과 리스트 크기는 N이다. 곱으로 늘어난 행은 SQL과 전송 단계를 그대로 지나가지만 리스트 크기에는 드러나지 않는다. 같은 N=100에서 PreparedStatement는 손대지 않은 loadFeed의 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. 앞 단계에서 loadFeed를 재던 테스트를 다시 실행해 collectionFetches == N, 접근 0에서 == 0, pageFetch == N이 그대로 유지되는지 확인한다. ## 본문 ## 실패 하나 — 컬렉션 둘을 동시에 fetch join하면 거부된다 bag은 순서 컬럼(`@OrderColumn`)이 없는 `List` 매핑이다. `highlights`와 `mentions`가 둘 다 bag인 상태에서 연관을 전부 한 쿼리에 합쳐 보았다. `mentions`는 이 예외를 재현하려고 이 단계에서 붙인 두 번째 bag이다. 목표 스키마의 `feed_item_mentions`는 `(feed_item_id, mentioned_user_id)`를 복합 기본키로 쓰지만, 여기서는 `@OneToMany List` bag 매핑을 단순하게 만들려고 대리키 `id`를 두고 `UNIQUE(feed_item_id, mentioned_user_id)`를 걸었다. 복합 기본키가 주던 유일성은 이 제약이 대신 보장한다. ```java label="연관 전부 fetch join — 컬렉션 둘을 동시에" select distinct f from FeedItemJpaEntity f join fetch f.highlights join fetch f.mentions ``` 이 쿼리는 실행까지 가지 못하고 `createQuery` 시점에 거부됐다. 콘솔에 찍힌 원인 체인은 이렇다. ```text label="실제로 나온 예외 원인 체인" java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchException ``` `MultipleBagFetchException`은 `IllegalArgumentException`으로 감싸져 나왔다. 테스트를 `hasCauseInstanceOf`에만 맞추면 래핑 계층이나 버전 차이에 취약해서, 원인 체인을 클래스명 문자열로 펼친 뒤 문자열 포함으로 확인했다. ## 실패 둘 — 컬렉션 하나만 합치면 전송 행수가 늘어난다 컬렉션을 `highlights` 하나만 fetch join하면 예외는 나지 않는다. 대신 `feed_items`와 `highlights`의 조인이 부모 한 행을 자식 수만큼 반복해 내보내므로, 쿼리 수가 아니라 DB가 애플리케이션에 전달한 조인 행수를 쟀다. :::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×로 줄었다. 그래도 전송 행수는 세 N에서 모두 시드 하이라이트 총량과 정확히 같았다. ## 쿼리 수만 보면 개선처럼 보인다 같은 N=100 데이터에서 PreparedStatement를 항목별로 나눠 세어 보았다. | 구분 | 기준선 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개로 줄어든 주된 이유는 `highlights` 컬렉션 SELECT 100개가 루트 조인 하나로 접혔기 때문이다. 나머지 1개 차이는 원시 JPQL에 Spring Data count가 없어서 생겼다. 121개 중 120개는 여전히 User·Page를 읽는 ToOne 2차 SELECT였다. ## 조인이 곱한 행을 실행계획에서 확인하기 시드(100) 직후 같은 형태의 쿼리를 `EXPLAIN (ANALYZE, BUFFERS)`로 실행했다. ```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이다. 쿼리는 하나지만 그 하나가 1,961행을 실어 나르고, 리스트 크기 100에는 이 수가 잡히지 않는다. 추정 `rows=4202`와 실제 `rows=1961`의 오차는 대량 시드 직후 `ANALYZE`를 실행하지 않아 생긴 통계 문제다. ## 측정 정정 — 리스트 크기는 N이었다 처음에는 `distinct` 없이 받은 결과 리스트 크기가 전송 행수와 같을 것으로 예상했지만, 실제 리스트 크기는 N이었다. Hibernate 6 이상이 fetch join의 루트 엔티티를 자동으로 중복 제거하기 때문이다. 리스트 크기가 N으로 접혀도 SQL이 만든 곱과 DB가 실어 보낸 행수는 줄지 않는다. 그래서 리스트 크기 대신 조인 카디널리티를 `count(*)`로 셌다. 이 문제는 `EXPLAIN`의 actual rows나 조인 count로 확인해야 한다. 쿼리 수는 줄었는데 전송량은 커졌으므로, 이 단계부터는 쿼리 수와 전송 행수를 같이 쟀다. ## 이 실패를 남긴 이유 `.distinct()`를 붙이거나 `List`를 `Set`으로 바꾸거나 `@BatchSize`로 바로 우회하지 않고, 실패한 쿼리를 별도 통합 테스트에 남겼다. 그래야 fetch join이 만든 페이징 문제와 그다음 Batch Fetch 선택까지 이어서 확인할 수 있기 때문이다. ## 로컬 미리보기 본문 「실패 둘 — 컬렉션 하나만 합치면 전송 행수가 늘어난다」 절의 첫 문단 다음 `:::evidence key="cartesian-row-multiplication"`에 들어갈 그림이다. ![왼쪽 부모 테이블에서 출발한 조인이 부모 한 행을 자식 수만큼 반복한 행 묶음으로 만들어 오른쪽 전송 단계로 내보내고, 아래쪽에서 Hibernate 6 이상이 루트 엔티티를 중복 제거해 결과 리스트를 부모 수로 되돌리지만 늘어난 행은 SQL과 전송 단계에 남는다는 것을 보여 주는 그림.](../../../final/assets/tech-log-studio/cartesian-row-multiplication.svg)