- 계약 채택 — 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>
4.5 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 | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 08a74b35-10c3-4874-8fbc-209b0b6e942e | PROJECT_DECISION | batch-fetch-for-entity-graph | Entity Graph 조회에는 Batch Fetch를 사용한다 | jpa-feed-query-performance | JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 | JPA 피드 조회 성능 | Liner N + 1문제 | 게시 전 | https://hyeonworks.com/studio/documents/08a74b35-10c3-4874-8fbc-209b0b6e942e/edit | PROPOSED | n+1liner-lab@2026-08 |
|
id: 08a74b35-10c3-4874-8fbc-209b0b6e942e kind: PROJECT_DECISION slug: batch-fetch-for-entity-graph title: Entity Graph 조회에는 Batch Fetch를 사용한다 topic: jpa-feed-query-performance topicName: JPA 피드 조회 성능 — N+1 진단과 조회 전략의 진화 topicName: JPA 피드 조회 성능 project: Liner N + 1문제 status: 게시 전 studio: "https://hyeonworks.com/studio/documents/08a74b35-10c3-4874-8fbc-209b0b6e942e/edit" decisionStatus: PROPOSED sourceRevision: n+1liner-lab@2026-08 source:
- final/document.md#11-1
- final/document.md#11-5
Entity Graph 조회에는 Batch Fetch를 사용한다
엔티티를 그래프로 조회해야 하는 경로에서는 컬렉션을 fetch join하지 않고 배치 페치를 쓴다. 엔티티만 페이징해 DB가 LIMIT을 발행하게 하고, 지연 연관은 부모 키를 모아 IN으로 채운다.
근거
- Fetch Join · Batch · Projection 선택 기준 fetch join · 배치 · 프로젝션을 각각 어느 조회에 쓸지 나눠 둔 기록이다.
- Collection Fetch Join Pagination의 In-memory Paging 컬렉션을 fetch join한 채 페이징을 걸면 Hibernate가 SQL에서 LIMIT을 빼고 조인 결과를 전부 읽은 뒤 메모리에서 페이지를 자른다. 그 동작을 확인한 기록이다.
- Projection 이후에도 1,509행을 읽은 Row Over-fetch 배치를 적용해도 엔티티가 통째로 올라온다는 것을 확인한 기록이다.
결정문
엔티티 그래프가 필요한 조회에서는 컬렉션을 fetch join하지 않고 hibernate.default_batch_fetch_size를 설정해 지연 연관을 IN으로 묶는다.
이 설정은 세션 전체에 걸리므로 적용 범위를 함께 정한다. 앞서 잰 값을 그대로 두려면 새 테스트 클래스처럼 격리된 범위에만 건다.
판단 이유
fetch join은 부모와 자식을 한 결과에 합쳐 행을 곱했기 때문에 DB가 부모를 기준으로 LIMIT을 적용할 수 없었다. 배치는 부모만 먼저 페이징하고 자식은 부모 키를 모아 IN으로 따로 가져오므로, 컬렉션 N+1과 페이징이 한꺼번에 풀린다.
고친 것은 loadFeed가 아니라 세션 설정 한 줄이다. findAllBy(Pageable)로 엔티티를 페이징하고 매핑하면서 지연 연관에 접근하는 코드는 그대로 두었고, 앞서 N+1을 만들었던 그 코드가 이 설정 아래에서는 배치로 동작했다. 애플리케이션 코드는 바꾸지 않았다.
N=1,000에서 총 PreparedStatement가 2,022개에서 23개로 줄었다. 초기화되지 않은 프록시를 배치 크기만큼씩 모아 한 번에 로드하므로, 자식 조회는 부모 수를 배치 크기로 나눈 올림값만큼만 나간다. 컬렉션뿐 아니라 user·page 즉시 로딩 연관도 같은 배치에 묶였다.
같은 N=1,000에서 로드한 부모 엔티티는 데이터셋 전체가 아니라 페이지 크기인 20에서 멈췄다. 인메모리가 아니라 DB에서 LIMIT으로 부모를 먼저 자른 결과다.
엔티티만 페이징한 SQL의 실행계획에는 Limit 노드가 붙었고, 자식 IN 조회는 부모와 자식을 곱하지 않는 준조인으로 나타났다. 앞 단계에서 본 카테시안 곱과 인메모리 페이징이 여기서는 나타나지 않았다.
영향
- 배치를 적용하면 getCollectionFetchCount()가 초기화된 컬렉션 수가 아니라 여러 컬렉션을 함께 채운 fetch SELECT 연산 수를 센다. 처음에는 이 값을 초기화된 컬렉션 수로만 보고 배치를 걸어도 N으로 유지될 것이라고 예상했지만 10 / 100 / 1,000에서 1 / 1 / 10으로 줄었고, 그 결과에 맞춰 지표를 다시 해석했다. 배치가 걸렸는지는 PreparedStatement 수와 이 값을 함께 보고 판단한다.
- default_batch_fetch_size는 세션 전체에 걸린다. 앞서 loadFeed로 잰 값과 그대로 비교하려면 새 IT 클래스처럼 격리된 범위에만 걸어야 한다.
- 엔티티는 여전히 통째로 하이드레이트된다. seed 1,000의 첫 페이지 20건을 조회하자 FeedItem·User·Page·Highlight를 합해 1,569개가 영속 객체로 올라왔다. 화면에 필요하지 않은 컬럼까지 올라오는 이 비용은 배치가 줄이지 못한다.
- 배치 크기를 정해야 한다. 크기가 크면 IN 목록이 길어지고 작으면 왕복이 늘어난다.
- 특정 컬렉션에만 @BatchSize(size=100)를 붙일 수도 있지만 그러면 엔티티 매핑 자체가 바뀌어 앞의 측정과 나란히 놓을 수 없다. 매핑을 건드리지 않고 비교하려면 세션 설정으로 거는 편이 낫다.