- 계약 채택 — 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>
14 KiB
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 | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| c1158754-e3d2-47b8-bb41-81787c0ca84b | CASE | collection-fetch-join-in-memory-paging | Collection Fetch Join Pagination의 In-memory Paging | jpa-feed-query-performance | JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 | JPA 피드 조회 성능 | Liner N + 1문제 | 게시 전 | https://hyeonworks.com/studio/documents/c1158754-e3d2-47b8-bb41-81787c0ca84b/edit |
|
|
n+1liner-lab@2026-08 |
|
id: c1158754-e3d2-47b8-bb41-81787c0ca84b kind: CASE slug: collection-fetch-join-in-memory-paging title: Collection Fetch Join Pagination의 In-memory Paging topic: jpa-feed-query-performance topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 topicName: JPA 피드 조회 성능 project: Liner N + 1문제 status: 게시 전 studio: "https://hyeonworks.com/studio/documents/c1158754-e3d2-47b8-bb41-81787c0ca84b/edit" assets:
- key: in-memory-paging file: ../../../final/assets/tech-log-studio/in-memory-paging.svg evidence:
- ../../../final/evidence/raw/explain/l4-entity-paging-limit.txt
- ../../../final/evidence/raw/explain/l5-entity-paging-limit.txt sourceRevision: n+1liner-lab@2026-08 source:
- final/document.md#10-2
- final/document.md#10-4
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 노드가 없었다. 엔티티만 페이징한 SQL에는 Limit 노드가 정렬 위에 얹혀 top-N heapsort로 상위 몇 행만 취한다.
지연은 예상과 달리 처음 구현한 loadFeed보다 낮았는데, 컬렉션 N번 왕복이 조인 하나로 줄었기 때문이다. 할당은 N=10의 1.5 MB에서 N=1,000의 10.0 MB로 늘었지만, 그 loadFeed의 할당량은 재지 않아 두 구조를 직접 비교한 값은 없다.
검증 환경
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)
재현 조건
-
highlights를 join fetch하는 원시 JPQL에 setFirstResult(0)과 setMaxResults(20)을 적용한다.
-
N ∈ {10, 100, 1000}에서 결과 리스트 크기와 EntityStatistics.getLoadCount()를 함께 읽는다.
-
경고 로그를 ListAppender로 캡처한다. 코드 번호만 비교하지 않고 문구도 함께 확인한다.
-
지연은 반복 측정하고 스레드 누적 할당을 함께 잰다.
-
컬렉션 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 |
데이터가 커진 뒤에야 반환 크기와 실제 로드 수의 차이가 나타났다.
fetch join 쿼리는 FeedItem을 루트로 하이드레이트하므로 로드된 부모 수가 EntityStatistics.getLoadCount()에 잡힌다. 인메모리 페이징은 전체를 하이드레이트한 뒤 부모 목록을 자르기 때문에 returned가 20이어도 getLoadCount()는 N이다. getCollectionFetchCount()에는 join으로 로드된 컬렉션이 잡히지 않을 수 있어 이 단계의 지표로 쓰지 않았다.
경고 코드가 알려진 것과 달랐다
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
널리 알려진 코드는 HHH000104지만 이 랩에서는 HHH90003004였다. 메시지 본문은 같았으므로 회귀 가드는 코드 번호만 비교하지 않고 contains("HHH000104") || contains("collection fetch")처럼 문구도 함께 확인하도록 만들었다.
비용은 페이지가 아니라 데이터셋에 비례한다
| 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이 커질수록 함께 올랐다.
지연은 예상과 달랐다. 컬렉션을 부모마다 따로 조회하던 loadFeed는 N=1,000에서 지연 최댓값이 238.4 ms였는데 이 fetch join은 83.526 ms였다. 컬렉션 N번 왕복이 조인 하나로 줄었기 때문이다. 그래도 이 값은 페이지에 필요하지 않은 부모 N개와 그 하이라이트를 전부 하이드레이트하면서 나온 것이다. 할당은 loadFeed 쪽을 재지 않아 두 구조의 메모리를 직접 비교한 값이 없다.
used heap 델타가 아니라 스레드 누적 할당을 쓴 이유는 두 가지다. used heap 델타는 측정 구간 사이의 GC 시점에 좌우되어 실행마다 흔들리고, JVM 전체 값이라 다른 스레드의 활동도 섞인다. getThreadAllocatedBytes는 GC와 무관하게 이 스레드가 만든 총량을 누적하므로 중간에 사라지는 객체까지 센다.
로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다. 반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다. 시드 1,000에 페이지 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 |
데이터가 커진 뒤에야 반환 크기와 실제 로드 수의 차이가 나타났다.
fetch join 쿼리는 FeedItem을 루트로 하이드레이트하므로 로드된 부모 수가 EntityStatistics.getLoadCount()에 잡힌다. 인메모리 페이징은 전체를 하이드레이트한 뒤 부모 목록을 자르기 때문에 returned가 20이어도 getLoadCount()는 N이다. getCollectionFetchCount()에는 join으로 로드된 컬렉션이 잡히지 않을 수 있어 이 단계의 지표로 쓰지 않았다.
경고 코드가 알려진 것과 달랐다
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
널리 알려진 코드는 HHH000104지만 이 랩에서는 HHH90003004였다. 메시지 본문은 같았으므로 회귀 가드는 코드 번호만 비교하지 않고 contains("HHH000104") || contains("collection fetch")처럼 문구도 함께 확인하도록 만들었다.
비용은 페이지가 아니라 데이터셋에 비례한다
| 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이 커질수록 함께 올랐다. 페이징이 데이터를 줄이지 못했다는 시간·메모리 증거다.
지연은 예상과 달랐다. 컬렉션을 부모마다 따로 조회하던 loadFeed는 N=1,000에서 지연 최댓값이 238.4 ms였는데 이 fetch join은 83.526 ms였다. 컬렉션 N번 왕복이 조인 하나로 줄었기 때문이다. 그래도 이 값은 페이지에 필요하지 않은 부모 N개와 그 하이라이트를 전부 하이드레이트하면서 나온 것이다. 할당은 loadFeed 쪽을 재지 않아 두 구조의 메모리를 직접 비교한 값이 없다.
used heap 델타가 아니라 스레드 누적 할당을 쓴 이유는 두 가지다. used heap 델타는 측정 구간 사이의 GC 시점에 좌우되어 실행마다 흔들리고, JVM 전체 값이라 다른 스레드의 활동도 섞인다. getThreadAllocatedBytes는 GC와 무관하게 이 스레드가 만든 총량을 누적하므로 중간에 사라지는 객체까지 센다.
로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다. 반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다. 시드 1,000에 페이지 20으로 확인하니 반환은 20건인데 영속성 컨텍스트 엔티티는 4,937개였다. 같은 실행의 used heap 델타 22,016 KB는 스레드 누적 할당 19,995 KB보다 컸다.