--- title: error / hibernate-getcollectionfetchcount-batch-semantics-2026-07-13 source_type: error-note status: raw related_branches: [experiment-nplus1-highlight-feed] related_projects: [ca-skeleton] tags: [error, ca-skeleton, hibernate, statistics, batch-fetch, n-plus-one, metric-semantics, resolved] created: 2026-07-13 status_label: resolved --- # error: hibernate-getcollectionfetchcount-batch-semantics-2026-07-13 > Layer: `raw/errors/` — 작업 중 마주친 지표 의미 오해(측정 정정) 기록. ## Parent / 부모 - [[raw/branch-notes/experiment-nplus1-highlight-feed]] — N+1 랩 L5(엔티티 페이징 + 배치 페치) 실행 중 `getCollectionFetchCount()`의 실제 의미가 문서 모델과 달라 발견. ## 증상 / Symptom - 문서 모델(L4 가이드 §0.4, 발표 문서 §6.1): *"`Statistics.getCollectionFetchCount()` = 초기화된 컬렉션 수라 배치를 켜도 그대로 N, 변하는 건 SQL 수(`getPrepareStatementCount`)뿐"*. - 실측(`FeedBatchFetchIT`, `default_batch_fetch_size=100`): `getCollectionFetchCount()`가 L1(배치 없음)의 N(10/100/1000)에서 L5(배치)의 **1 / 1 / 10 = ceil(N/batch)**로 떨어졌다. - 즉 이 지표는 "초기화된 컬렉션 수"가 아니라 **컬렉션을 채운 fetch SELECT 연산 수**다. 만약 회귀가드를 "배치를 켜도 collectionFetch는 N으로 그대로"라는 전제로 짰다면 거짓 실패했을 것. - 발생 환경: Java 21 · Spring Boot 4.0.0 · Hibernate ORM 7.1.8 · PostgreSQL 16(Testcontainers). - 재현 가능 여부: `always`. ## 재현 절차 / Reproduction 1. `FeedBatchFetchIT`에 `@TestPropertySource(… hibernate.default_batch_fetch_size=100)`를 얹고 `queryAdapter.loadFeed(0, n)`(N=10/100/1000) 호출. 2. `stats.getCollectionFetchCount()` 관측 → 1 / 1 / 10. 3. 대조: `FeedPersistenceIT`(배치 없음)의 L1 `l1CollectionNPlusOneGrowsLinearlyWithN`에서 같은 지표 = N(10/100/1000). 4. 결론: 배치가 컬렉션 fetch 연산을 `ceil(N/batch)`로 접는다 — 지표는 초기화 수가 아니라 fetch 연산 수. ## 근본 원인 / Root cause - 직접 원인: `getCollectionFetchCount()`의 이름을 "초기화된 컬렉션 수"로 가정했으나, 실제 집계 단위는 **컬렉션을 채운 fetch(SELECT) 연산 횟수**다. 배치 페치는 여러 컬렉션을 한 SELECT로 채우므로 이 카운트가 준다. - 근본 원인: Hibernate `Statistics`의 카운터 이름을 문서 없이 "직관적 의미"로 가정. L1에서는 배치가 없어 "초기화 수 N = fetch 연산 N"이 우연히 일치해 오해가 드러나지 않았다. - 트리거 조건: 배치 페치(`default_batch_fetch_size` 또는 `@BatchSize`)를 켠 뒤 컬렉션 다수를 초기화. ## Sources / 근거 - 로컬 실측: `ca-tmpl:app-bootstrap` `FeedBatchFetchIT.l5BatchFetchCollapsesQueryCount` — `collectionInit=1/1/10` 관찰(`build/lab-results/feed-nplus1-l5.md`). 대조 `FeedPersistenceIT` L1 = N. 둘 다 `:app-bootstrap:test` GREEN. - 문서: `ca-tmpl:docs/notes/L5.md` §"실측 정정" + 발표 문서 `topic-arrange/n+1liner/n+1liner.md` §11.2 "⚠ 실측 정정" 콜아웃. ## 권고 해결 / Recommended resolution (적용됨) - 적용: 문서 모델을 정정 — "`getCollectionFetchCount()` = 컬렉션 fetch SELECT 연산 수(배치에서 `ceil(N/batch)`로 접힘)". 배치 해결의 증인은 `prepared`와 `collectionFetch` **둘 다**. - 회귀가드는 "배치를 켜도 collectionFetch가 N으로 유지"라는 전제를 쓰지 않는다. 대신 `prepared < N`(붕괴) 또는 `feedItemLoaded == min(pageSize, N)`(페이징 정상) 같은 배치-불변 단언을 쓴다. ## 교훈 / Lesson - **ORM 통계 카운터는 이름의 직관과 집계 단위가 다를 수 있다.** `getCollectionFetchCount`/`getEntityFetchCount` 등은 "초기화된 개수"가 아니라 "fetch 연산(SELECT) 수"에 가깝다 — 배치/서브셀렉트를 켜면 그 값이 준다. 지표를 회귀가드로 쓰기 전에 **대조 실측**(배치 on/off)으로 의미를 못 박아라. - 같은 결의 정정이 이 랩에 셋: L3 "Hibernate 6+ 루트 dedup"(리스트 크기≠전송 행수), L4 "`HHH000104`→`HHH90003004`"(로그 코드 드리프트), L5 여기(collectionFetch=fetch 연산 수). **ORM 버전·설정이 관측 지표를 바꾸므로 실측으로 재확인**이 원칙.