Files
document-haness/docs/n+1liner/tech-log-studio/jpa-feed-query-performance/case/case-collection-fetch-join-in-memory-paging.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

12 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
c1158754-e3d2-47b8-bb41-81787c0ca84b CASE collection-fetch-join-in-memory-paging Collection Fetch Join Pagination의 In-memory Paging jpa-feed-query-performance JPA 피드 조회 성능 Liner N + 1문제 게시 전 https://hyeonworks.com/studio/documents/c1158754-e3d2-47b8-bb41-81787c0ca84b/edit
key file
in-memory-paging ../../../final/assets/tech-log-studio/in-memory-paging.svg
../../../final/evidence/raw/explain/l4-entity-paging-limit.txt
../../../final/evidence/raw/explain/l5-entity-paging-limit.txt

Collection Fetch Join Pagination의 In-memory Paging

컬렉션 하나만 fetch join하고 setMaxResults(20)을 적용하면 전송량도 한 페이지로 줄어들 것이라고 보았다. Hibernate는 DB LIMIT을 사용하지 않고 결과셋 전체를 메모리에 올린 뒤 부모 기준으로 잘랐다. 반환 목록은 20이었지만 로드한 부모 엔티티는 N개 전부였다.

관계

  • Collection Fetch Join과 Pagination을 같이 사용하지 않는다 이 관측에서 나온 결정이다.
  • Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증 이 기록이 이어받은 앞 단계다.
  • Fetch Join · Batch · Projection 선택 기준 이 실패가 배치 선택으로 이어진 기준이다.

문제

컬렉션 fetch join으로 쿼리 수는 줄었지만 전송 행수가 커졌다. 여기에 페이징을 걸면 전송량도 한 페이지로 줄어들 것이라고 예상했다.

반환된 목록 크기는 20이라 겉으로는 페이징이 정상처럼 보였다. 반환 크기만으로는 실제 적재량을 알 수 없어 메모리에 올린 부모 엔티티 수를 따로 측정했다.

결론

returned는 페이지 크기에 고정됐지만 feedItemLoaded는 N을 따라 늘었다. over-fetch 배수는 1.0배에서 50.0배로 커졌다. N=10에서는 데이터셋이 한 페이지보다 작아 두 값이 같았고 문제가 보이지 않았다.

컬렉션 fetch join에서는 부모 한 행이 자식 수만큼 늘어난다. 여기에 DB LIMIT을 걸면 부모 20개가 아니라 조인 행 20개에서 잘려 일부 부모의 하이라이트가 누락될 수 있다. Hibernate는 이 손상을 피하려고 SQL에서 LIMIT을 빼고 전체 조인 결과를 읽은 뒤 메모리에서 자른다.

발행된 SQL에 Limit 노드가 없다는 것 자체가 DB가 페이징을 하지 않았다는 증거다. 엔티티만 페이징한 SQL에는 Limit 노드가 정렬 위에 얹혀 top-N heapsort로 상위 몇 행만 취한다.

지연은 예상과 달리 기준선보다 낮았다. 컬렉션 N번 왕복이 조인 하나로 줄었기 때문이다. 할당은 N을 따라 1.5 MB에서 10.0 MB로 늘었는데, 기준선의 할당량은 재지 않아 두 구조를 직접 비교한 값은 없다.

검증 환경

Java 21 Spring Boot 4.0.0 Hibernate ORM 7.1.8.Final PostgreSQL : postgres:16-alpine (Testcontainers)

쿼리 : 통합 테스트 안의 원시 JPQL (프로덕션 코드 변경 없음) hibernate.query.fail_on_pagination_over_collection_fetch : false (기본값)

측정 지표 returned : 결과 리스트 크기 feedItemLoaded : EntityStatistics.getLoadCount() 할당 : getThreadAllocatedBytes (HotSpot)

재현 조건

  1. highlights를 join fetch하는 원시 JPQL에 setFirstResult(0)과 setMaxResults(20)을 적용한다.

  2. N ∈ {10, 100, 1000}에서 결과 리스트 크기와 EntityStatistics.getLoadCount()를 함께 읽는다.

  3. 경고 로그를 ListAppender로 캡처한다. 코드 번호만 비교하지 않고 문구도 함께 확인한다.

  4. 지연은 반복 측정하고 스레드 누적 할당을 함께 잰다.

  5. 컬렉션 fetch join 쿼리와 엔티티만 페이징한 쿼리를 각각 EXPLAIN해 Limit 노드 유무를 대조한다.

본문

무대 — 페이징 한 줄만 추가

"select f from FeedItemJpaEntity f join fetch f.highlights "   // ← 한 bag fetch join
    + "order by f.firstHighlightedAt desc, f.id asc"
// + .setFirstResult(0).setMaxResults(20)                       // ← 방아쇠: 페이징

새 엔티티·마이그레이션·시더·프로덕션 코드는 만들지 않았다. 앞 단계의 데이터와 매핑을 그대로 두고 페이징 한 줄만 더했다.

응답은 한 페이지인데 부모는 전부 로드한다

N returned(페이지) feedItemLoaded over-fetch 배수 시드 하이라이트
10 10 10 1.0× (안 보임) 1,285
100 20 100 5.0× 1,961
1,000 20 1,000 50.0× 2,917

데이터가 커진 뒤에야 반환 크기와 실제 로드 수의 차이가 나타났다.

getLoadCount()를 사용한 이유는 fetch join 쿼리가 FeedItem을 루트로 하이드레이트하기 때문이다. 인메모리 페이징은 전체를 하이드레이트한 뒤 부모 목록을 자르므로 returned가 20이어도 getLoadCount()는 N이다. getCollectionFetchCount()에는 join으로 로드된 컬렉션이 잡히지 않을 수 있어 이 단계의 지표로 쓰지 않았다.

경고 코드가 알려진 것과 달랐다

HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

널리 알려진 코드는 HHH000104지만 이 랩에서는 HHH90003004였다. 메시지 본문은 같았다. 회귀 가드는 코드 번호만 비교하지 않고 문구도 함께 확인하도록 만들었다.

비용은 페이지가 아니라 데이터셋에 비례한다

N 지연 중앙값(5회) 지연 최댓값(5회) 스레드 누적 할당
10 6.184 ms 6.566 ms 약 1.5 MB
100 13.890 ms 16.062 ms 약 3.0 MB
1,000 79.452 ms 83.526 ms 약 10.0 MB

returned가 페이지 크기로 고정인데도 지연과 할당이 N을 따라 오른다. 페이징이 데이터를 줄이지 못했다는 시간·메모리 증거다.

힙 델타가 아니라 스레드 누적 할당을 쓴 이유는 두 가지다. used heap 델타는 측정 구간 사이의 GC 시점에 좌우되어 실행마다 흔들리고, JVM 전체 값이라 다른 스레드의 활동도 섞인다. getThreadAllocatedBytes는 GC와 무관하게 이 스레드가 만든 총량을 누적하므로 중간에 사라지는 객체까지 센다.

로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다. 반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다. seed 1,000에 page 20으로 확인하니 반환은 20건인데 영속성 컨텍스트 엔티티는 4,937개였고, 같은 실행의 used heap 델타 22,016 KB가 스레드 누적 할당 19,995 KB보다 컸다.

발행 SQL에 LIMIT이 없다

:::evidence key="in-memory-paging" alt="위쪽은 컬렉션 fetch join에 페이징을 건 경로로 조인 결과 전량이 애플리케이션으로 넘어와 메모리에서 잘리고, 아래쪽은 엔티티만 페이징한 경로로 정렬에 Limit이 붙어 DB가 페이지만 돌려주는 두 경로를 위아래로 대조한 그림." caption=" " zoom="true" :::

-- (a) 컬렉션 fetch join의 조인 — Limit 노드 없음
Sort  (... rows=1782 ...) (actual ... rows=1961 loops=1)
  Sort Method: quicksort  Memory: 445kB
  ->  Hash Join  (... actual ... rows=1961 loops=1)
        ->  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)

-- (b) 엔티티만 페이징 — Limit 노드 존재
Limit  (... rows=20 ...) (actual ... rows=20 loops=1)
  ->  Sort  (actual ... rows=20 loops=1)
        Sort Method: top-N heapsort  Memory: 28kB
        ->  Seq Scan on feed_items fi   (actual ... rows=100 loops=1)

(a)에는 Limit 노드가 없다. 조인 결과 전체를 quicksort로 정렬한 뒤 그대로 반환하고, 페이지로 자르는 일은 Hibernate가 메모리에서 한다. (b)에는 Limit이 정렬 위에 얹혀 top-N heapsort로 상위 몇 행만 취한다.

전체 정렬과 상위 몇 행 정렬의 비용 차이가 계획 수준에서 드러난다.

다음 선택

fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된다. 다만 highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아온다. 그래서 페이지 부모 키를 모아 IN으로 조회하는 Batch Fetch를 함께 적용했다.

이 실패도 프로덕션 코드에 섞지 않고 통합 테스트에 격리했다. 다음 단계의 전후 차이를 같은 기준으로 비교하기 위해서다.

로컬 미리보기

본문 「무대 — 페이징 한 줄만 추가

"select f from FeedItemJpaEntity f join fetch f.highlights "   // ← 한 bag fetch join
    + "order by f.firstHighlightedAt desc, f.id asc"
// + .setFirstResult(0).setMaxResults(20)                       // ← 방아쇠: 페이징

새 엔티티·마이그레이션·시더·프로덕션 코드는 만들지 않았다. 앞 단계의 데이터와 매핑을 그대로 두고 페이징 한 줄만 더했다.

응답은 한 페이지인데 부모는 전부 로드한다

N returned(페이지) feedItemLoaded over-fetch 배수 시드 하이라이트
10 10 10 1.0× (안 보임) 1,285
100 20 100 5.0× 1,961
1,000 20 1,000 50.0× 2,917

데이터가 커진 뒤에야 반환 크기와 실제 로드 수의 차이가 나타났다.

getLoadCount()를 사용한 이유는 fetch join 쿼리가 FeedItem을 루트로 하이드레이트하기 때문이다. 인메모리 페이징은 전체를 하이드레이트한 뒤 부모 목록을 자르므로 returned가 20이어도 getLoadCount()는 N이다. getCollectionFetchCount()에는 join으로 로드된 컬렉션이 잡히지 않을 수 있어 이 단계의 지표로 쓰지 않았다.

경고 코드가 알려진 것과 달랐다

HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

널리 알려진 코드는 HHH000104지만 이 랩에서는 HHH90003004였다. 메시지 본문은 같았다. 회귀 가드는 코드 번호만 비교하지 않고 문구도 함께 확인하도록 만들었다.

비용은 페이지가 아니라 데이터셋에 비례한다

N 지연 중앙값(5회) 지연 최댓값(5회) 스레드 누적 할당
10 6.184 ms 6.566 ms 약 1.5 MB
100 13.890 ms 16.062 ms 약 3.0 MB
1,000 79.452 ms 83.526 ms 약 10.0 MB

returned가 페이지 크기로 고정인데도 지연과 할당이 N을 따라 오른다. 페이징이 데이터를 줄이지 못했다는 시간·메모리 증거다.

힙 델타가 아니라 스레드 누적 할당을 쓴 이유는 두 가지다. used heap 델타는 측정 구간 사이의 GC 시점에 좌우되어 실행마다 흔들리고, JVM 전체 값이라 다른 스레드의 활동도 섞인다. getThreadAllocatedBytes는 GC와 무관하게 이 스레드가 만든 총량을 누적하므로 중간에 사라지는 객체까지 센다.

로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다. 반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다. seed 1,000에 page 20으로 확인하니 반환은 20건인데 영속성 컨텍스트 엔티티는 4,937개였고, 같은 실행의 used heap 델타 22,016 KB가 스레드 누적 할당 19,995 KB보다 컸다.

발행 SQL에 LIMIT이 없다」 아래 :::evidence key="in-memory-paging" 자리에 들어갈 그림이다.

위쪽은 컬렉션 fetch join에 페이징을 건 경로로 조인 결과 전량이 애플리케이션으로 넘어와 메모리에서 잘리고, 아래쪽은 엔티티만 페이징한 경로로 정렬에 Limit이 붙어 DB가 페이지만 돌려주는 두 경로를 위아래로 대조한 그림.