- 계약 채택 — 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>
6.5 KiB
id, kind, slug, title, topic, topicName, topicName, project, status, studio, sourceRevision, source
| id | kind | slug | title | topic | topicName | topicName | project | status | studio | sourceRevision | source | |||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| db99cbc5-9123-4599-b368-39ff3170e81d | REFERENCE | fetch-strategy-selection | Fetch Join · Batch · Projection 선택 기준 | jpa-feed-query-performance | JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 | JPA 피드 조회 성능 | Liner N + 1문제 | 게시 전 | https://hyeonworks.com/studio/documents/db99cbc5-9123-4599-b368-39ff3170e81d/edit | n+1liner-lab@2026-08 |
|
id: db99cbc5-9123-4599-b368-39ff3170e81d kind: REFERENCE slug: fetch-strategy-selection title: Fetch Join · Batch · Projection 선택 기준 topic: jpa-feed-query-performance topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 topicName: JPA 피드 조회 성능 project: Liner N + 1문제 status: 게시 전 studio: "https://hyeonworks.com/studio/documents/db99cbc5-9123-4599-b368-39ff3170e81d/edit" sourceRevision: n+1liner-lab@2026-08 source:
- final/document.md#9-4
- final/document.md#11-5
- final/document.md#12-5
Fetch Join · Batch · Projection 선택 기준
세 전략은 줄이는 것이 서로 다르다. fetch join은 쿼리 수를 1+N에서 1로 줄이지만 전송 행을 자식 수만큼 부풀리고, 배치는 왕복을 배치 크기 단위로 묶지만 엔티티는 통째로 하이드레이트하고, 프로젝션은 엔티티를 하나도 만들지 않지만 부모당 자식 행은 전부 가져온다.
관계
- Fetch Join으로 N+1을 해결하다 만난 MultiBag과 행 폭증 컬렉션 둘을 동시에 fetch join하면 거부되고, 하나만 걸면 행이 늘어난다.
- Collection Fetch Join Pagination의 In-memory Paging 컬렉션 fetch join에 페이징을 걸자 DB LIMIT이 빠지고 부모가 전부 메모리에 올라왔다.
- Projection 이후에도 1,509행을 읽은 Row Over-fetch 프로젝션으로 엔티티 로드는 0이 되었지만 자식 행 1,509개는 줄지 않았다.
목적
컬렉션 fetch join은 쿼리 수를 1+N에서 1로 줄이지만, 부모 100행짜리 조회가 조인 뒤 1,961행을 실어 날랐다. 배치는 왕복을 2,022개에서 23개로 줄였지만 엔티티 1,569개를 하이드레이트했다.
무엇을 줄이려는지 먼저 정하고 그것을 세는 지표로 전후를 비교한다. 왕복은 PreparedStatement 수로, 메모리에 올린 부모 수는 feedItemLoaded로, 영속 엔티티 수는 Statistics.getEntityLoadCount()로 잰다.
규칙
1. 컬렉션 fetch join은 두 개 이상 쓰지 않는다
순서 컬럼(@OrderColumn)이 없는 List를 bag이라고 한다. bag 두 개를 동시에 fetch join하면 부모 한 행이 두 자식 개수의 곱만큼 늘어나는데, Hibernate는 이 곱집합을 원래 컬렉션으로 되돌릴 수 없다고 판단해 createQuery 시점에 MultipleBagFetchException을 던진다. 데이터가 0건이어도 매핑 단계에서 거부한다.
2. 컬렉션 fetch join은 행을 곱한다
컬렉션 하나만 fetch join하면 예외는 나지 않지만 부모 한 행이 자식 수만큼 반복된다. seed(100)에서 부모 feed_items는 100행인데 조인이 실어 나른 행은 자식 총합인 1,961행이었다.
Hibernate 6 이상은 루트 엔티티를 자동으로 중복 제거하므로 결과 리스트 크기는 100이라 이 증가가 보이지 않는다. 실행계획의 actual rows로 확인한다.
3. 컬렉션 fetch join과 페이징을 같이 쓰지 않는다
조인 행에 부모 기준 LIMIT을 그대로 걸면 일부 부모의 자식이 잘려 나가므로, Hibernate는 SQL에서 LIMIT을 빼고 결과셋 전체를 메모리에 올린 뒤 부모 기준으로 잘라 내고 경고를 남긴다.
setMaxResults(20)을 걸어도 반환 목록만 20건이었고 메모리에 올린 부모는 N건 전부였다. 발행 SQL에는 Limit 노드가 없었다.
4. fetch join은 ToOne에 쓴다
ToOne 연관은 부모 한 행에 자식이 하나라 행을 곱하지 않는다. 루트 SQL에 합쳐도 카테시안이 생기지 않으므로 fetch join은 여기에 쓴다.
5. 컬렉션에는 배치 페치를 쓴다
fetch join을 빼고 엔티티만 페이징하면 DB LIMIT이 다시 적용되고, 지연 연관은 부모 키를 모아 IN으로 채운다. hibernate.default_batch_fetch_size를 B로 두면 초기화되지 않은 프록시를 최대 B개씩 모아 ceil(N/B)번에 로드한다. N=1,000에서 PreparedStatement는 2,022개에서 23개로 줄었고 메모리에 올린 부모는 페이지 크기인 20에 머물렀다.
배치는 부모와 자식을 곱하지 않는다. 실행계획에는 Hash Semi Join으로 나타났고 자식 행 1,509개만 돌아왔다.
6. 화면 조회에는 프로젝션을 쓴다
SELECT new Carrier(...)로 필요한 스칼라 값만 조회하면 영속 엔티티를 만들지 않으므로 1차 캐시도, 더티체킹도, 지연 프록시도 생기지 않는다. 배치까지 적용하고도 1,569개였던 엔티티 로드가 프로젝션에서는 0개가 되었다.
조인이 있어도 컬럼을 읽는 경로일 뿐 엔티티를 만들지 않는다. 배치 설정 여부와 관계없이 성립하는 동작이다.
7. 프로젝션의 효과는 실행계획이 아니라 ORM 층에서 확인한다
필요한 컬럼만 골라도 EXPLAIN의 width는 줄지 않을 수 있다. 이 랩에서는 부모 프로젝션의 width가 2088로 엔티티 조회의 1194보다 오히려 컸는데, users와 pages 조인의 행폭이 반영되고 PostgreSQL의 width가 실제 전송 바이트가 아니라 컬럼 타입의 평균폭 추정치이기 때문이다.
프로젝션의 이득은 Statistics.getEntityLoadCount()로 확인한다.
8. 전략을 바꾼 뒤 무엇이 줄지 않았는지 적는다
fetch join은 쿼리 수를 1로 줄이는 대신 행을 곱했고 페이징을 막았다. 배치는 왕복과 페이징을 풀었지만 FeedItem·User·Page·Highlight를 합해 1,569개를 하이드레이트했다. 프로젝션은 그 1,569개를 0개로 만들었지만 페이지 부모 20개의 자식 1,509행을 전부 가져왔다.
적용 조건
- 연관을 포함한 목록 조회를 설계할 때
- N+1을 확인하고 fetch 전략을 고를 때
- 전략을 바꾼 뒤 무엇이 줄고 무엇이 줄지 않았는지 정리할 때
예외
- 컬렉션이 하나이고 페이징이 없으며 자식 수가 작다면 컬렉션 fetch join이 단순하다. 자식 수가 커질 수 있는 구조에는 쓰지 않는다.
- hibernate.default_batch_fetch_size는 세션 전체에 영향을 준다. 앞서 잰 값을 다시 재려면 이 랩처럼 별도 테스트 클래스(FeedBatchFetchIT)에만 설정한다.
예시
- 컬렉션 두 개 fetch join : createQuery 시점에 MultipleBagFetchException
- 컬렉션 한 개 fetch join : 부모 100행, 조인 행 1,961행
- 컬렉션 fetch join + 페이징 : Limit 노드 없음, 반환 20건에 부모 로드 N건
- ToOne fetch join : 행 곱하지 않음
- 배치 페치 : 왕복 ceil(N/B), N=1,000에서 2,022개 → 23개
- 프로젝션 : 엔티티 로드 1,569개 → 0개, 쿼리 2개, 자식 행 1,509개