- 계약 채택 — 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>
3.8 KiB
id, kind, slug, title, topic, topicName, topicName, project, status, studio, decisionStatus, sourceRevision, source
| id | kind | slug | title | topic | topicName | topicName | project | status | studio | decisionStatus | sourceRevision | source | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 5e4d033c-d6fe-4257-a4dc-1ade44473c72 | PROJECT_DECISION | no-collection-fetch-join-with-pagination | Collection Fetch Join과 Pagination을 같이 사용하지 않는다 | jpa-feed-query-performance | JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 | JPA 피드 조회 성능 | Liner N + 1문제 | 게시 전 | https://hyeonworks.com/studio/documents/5e4d033c-d6fe-4257-a4dc-1ade44473c72/edit | PROPOSED | n+1liner-lab@2026-08 |
|
id: 5e4d033c-d6fe-4257-a4dc-1ade44473c72 kind: PROJECT_DECISION slug: no-collection-fetch-join-with-pagination title: Collection Fetch Join과 Pagination을 같이 사용하지 않는다 topic: jpa-feed-query-performance topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 topicName: JPA 피드 조회 성능 project: Liner N + 1문제 status: 게시 전 studio: "https://hyeonworks.com/studio/documents/5e4d033c-d6fe-4257-a4dc-1ade44473c72/edit" decisionStatus: PROPOSED sourceRevision: n+1liner-lab@2026-08 source:
- final/document.md#10-5
- final/document.md#9-6
Collection Fetch Join과 Pagination을 같이 사용하지 않는다
컬렉션을 fetch join한 쿼리에 페이징을 걸지 않는다. Hibernate가 DB LIMIT을 빼고 결과셋 전체를 메모리에 올린 뒤 부모 기준으로 자르기 때문에, 반환 목록만 한 페이지일 뿐 메모리에 올라오는 부모 엔티티는 데이터셋 전체다.
근거
- Collection Fetch Join Pagination의 In-memory Paging 발행 SQL에 Limit 노드가 없다는 것과 부모 엔티티 로드 수를 이 기록에서 함께 쟀다.
- Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증 부모 100행짜리 조인이 실제로는 1,961행을 실어 나른다는 것을 확인한 기록이다.
- Fetch Join · Batch · Projection 선택 기준 컬렉션 fetch join을 버린 뒤 배치와 프로젝션 중 무엇을 쓸지 정한 기록이다.
결정문
컬렉션을 fetch join하는 쿼리에 firstResult나 maxResults를 적용하지 않는다.
페이징이 필요한 목록 조회에서는 엔티티만 페이징해 DB LIMIT이 정상 발행되게 하고, 지연 연관은 배치나 별도 쿼리로 채운다.
판단 이유
컬렉션 fetch join에서는 부모 한 행이 자식 수만큼 늘어난다. 페이지 크기 20으로 DB LIMIT을 걸면 부모 20개가 아니라 조인 행 20개에서 잘리므로 일부 부모의 자식이 누락될 수 있다.
Hibernate는 이 누락을 피하려고 SQL에서 LIMIT을 빼고 전체 조인 결과를 읽은 뒤 메모리에서 부모 기준으로 페이지를 자른다. seed(100)에서 실행계획을 떠 보니 이 쿼리에는 Limit 노드가 없었고, 조인 결과 전체를 quicksort로 정렬한 뒤 그대로 반환했다.
N=1,000에서 반환 목록은 페이지 크기인 20에 머물렀지만 메모리에 올라온 부모 엔티티는 1,000개, 반환의 50.0배였다. N=10에서는 데이터셋이 한 페이지보다 작아 두 값이 10으로 같았고 문제가 보이지 않았다.
엔티티만 페이징하면 Limit 노드가 정렬 위에 얹혀 정렬 방식이 quicksort에서 top-N heapsort로 바뀌고, 상위 20행만 가져온다.
문제의 쿼리는 FeedQueryAdapter가 아니라 통합 테스트 안의 원시 JPQL로만 실행했고, 늘어난 코드는 그 테스트의 측정 메서드뿐이다.
영향
- 컬렉션을 한 번에 가져오는 편의를 포기한다. 자식은 쿼리를 따로 내서 채워야 한다.
- 엔티티만 페이징하면 highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아오므로, 페이지 부모 키를 모아 한 번에 조회하는 배치나 프로젝션을 함께 적용해야 한다.
- hibernate.query.fail_on_pagination_over_collection_fetch를 true로 바꾸면 같은 쿼리가 곧바로 실패한다. 근본 해결은 아니지만 운영에서 실수를 조기에 발견하는 안전장치로는 쓸 수 있다.
- 이 랩의 Hibernate ORM 7.1.8은 널리 알려진 HHH000104가 아니라 HHH90003004를 남겼다. 그래서 회귀 가드는 경고 코드 번호만 비교하지 않고 collection fetch라는 문구도 함께 확인한다.
- 작은 데이터셋으로만 검증하면 이 문제를 놓치므로, 데이터 규모를 바꿔 가며 반환 크기와 로드 수를 함께 본다.