Files
document-haness/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-fetch-join-multibag-and-row-explosion.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:59 +09:00

9.2 KiB
Raw Blame History

id, kind, slug, title, topic, topicName, project, status, studio, assets, evidence
id kind slug title topic topicName project status studio assets evidence
7ed75172-fd56-42bf-956a-8f9fc1cca235 CASE fetch-join-multibag-and-row-explosion Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증 jpa-feed-query-performance 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

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

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에만 맞추면 래핑 계층이나 버전 차이에 취약하다. 원인 체인을 클래스명 문자열로 펼친 뒤 문자열 포함으로 확인했다.

실패 둘 — 한 bag만 fetch join」 아래 :::evidence key="cartesian-row-multiplication" 자리에 들어갈 그림이다.

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