Files
document-haness/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-fetch-join-multibag-and-row-explosion.md
T
DongHyeonkaandClaude Fable 5.1 62520a4dce docs(n+1liner): adopt the decomposition contract and strip evaluative prose
- 계약 채택 — schema 2 → 4. 독자 질문, 후보 36건(PROMOTE 24 · MERGE_INTO 8 ·
  KEEP_IN_SSOT 4), 종류별 칸, kind:slug 관계. 저장소 github-project/ca-tmpl @ 761384d
  (문서가 인용한 IT 7종이 그 커밋에 있다)
- 재선별 결과 기존 24건이 전부 살아남았다. 측정이 Reference 안에 들어 있었지만 독립성 검사를
  통과하지 못해 Case 로 빼지 않았다
- 평문 칸의 백틱 제거, 「기준선」을 실제 이름으로, frontmatter 에 source·sourceRevision
- 리뷰 39건 반영 — 설명 뒤에 붙은 평가·예고·되풀이·독자 오해 가정을 지웠다. 사실을 담은
  절반이 있는 문장은 평가만 뺐다

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:39:20 +09:00

11 KiB
Raw Blame History

id, kind, slug, title, topic, topicName, topicName, project, status, studio, assets, evidence, sourceRevision, source
id kind slug title topic topicName topicName project status studio assets evidence sourceRevision source
7ed75172-fd56-42bf-956a-8f9fc1cca235 CASE fetch-join-multibag-and-row-explosion Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증 jpa-feed-query-performance JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 JPA 피드 조회 성능 Liner N + 1문제 게시 전 https://hyeonworks.com/studio/documents/7ed75172-fd56-42bf-956a-8f9fc1cca235/edit
key file
cartesian-row-multiplication ../../../final/assets/tech-log-studio/cartesian-row-multiplication.svg
../../../final/evidence/raw/explain/l3-cartesian-join-plan.txt
n+1liner-lab@2026-08
final/document.md#9-2
final/document.md#9-3
final/document.md#9-5

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 매핑이다. highlightsmentions가 둘 다 bag인 상태에서 연관을 전부 한 쿼리에 합쳐 보았다.

mentions는 이 예외를 재현하려고 이 단계에서 붙인 두 번째 bag이다. 목표 스키마의 feed_item_mentions(feed_item_id, mentioned_user_id)를 복합 기본키로 쓰지만, 여기서는 @OneToMany List bag 매핑을 단순하게 만들려고 대리키 id를 두고 UNIQUE(feed_item_id, mentioned_user_id)를 걸었다. 복합 기본키가 주던 유일성은 이 제약이 대신 보장한다.

select distinct f from FeedItemJpaEntity f
  join fetch f.highlights
  join fetch f.mentions

이 쿼리는 실행까지 가지 못하고 createQuery 시점에 거부됐다. 콘솔에 찍힌 원인 체인은 이렇다.

java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchException

MultipleBagFetchExceptionIllegalArgumentException으로 감싸져 나왔다. 테스트를 hasCauseInstanceOf에만 맞추면 래핑 계층이나 버전 차이에 취약해서, 원인 체인을 클래스명 문자열로 펼친 뒤 문자열 포함으로 확인했다.

실패 둘 — 컬렉션 하나만 합치면 전송 행수가 늘어난다

컬렉션을 highlights 하나만 fetch join하면 예외는 나지 않는다. 대신 feed_itemshighlights의 조인이 부모 한 행을 자식 수만큼 반복해 내보내므로, 쿼리 수가 아니라 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)로 실행했다.

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()를 붙이거나 ListSet으로 바꾸거나 @BatchSize로 바로 우회하지 않고, 실패한 쿼리를 별도 통합 테스트에 남겼다. 그래야 fetch join이 만든 페이징 문제와 그다음 Batch Fetch 선택까지 이어서 확인할 수 있기 때문이다.

로컬 미리보기

본문 「실패 둘 — 컬렉션 하나만 합치면 전송 행수가 늘어난다」 절의 첫 문단 다음 :::evidence key="cartesian-row-multiplication"에 들어갈 그림이다.

왼쪽 부모 테이블에서 출발한 조인이 부모 한 행을 자식 수만큼 반복한 행 묶음으로 만들어 오른쪽 전송 단계로 내보내고, 아래쪽에서 Hibernate 6 이상이 루트 엔티티를 중복 제거해 결과 리스트를 부모 수로 되돌리지만 늘어난 행은 SQL과 전송 단계에 남는다는 것을 보여 주는 그림.