feat: 가상화 문서들 추가

This commit is contained in:
DongHyeonka
2026-09-10 08:54:05 +09:00
parent e9f6a93327
commit 43e1aadef0
695 changed files with 153404 additions and 12754 deletions
@@ -0,0 +1,604 @@
- generic [ref=f15e3]:
- link "본문으로 건너뛰기" [ref=f15e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f15e5]:
- generic [ref=f15e6]:
- link "TechLog Studio" [ref=f15e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f15e8]: Studio
- navigation "Studio 주 탐색" [ref=f15e10]:
- link "작업본" [ref=f15e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f15e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f15e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f15e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f15e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f15e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f15e17]
- main [ref=f15e18]:
- generic [ref=f15e19]:
- generic [ref=f15e20]:
- region [ref=f15e21]:
- generic [ref=f15e22]:
- paragraph [ref=f15e23]: CASE · VERSION 46
- heading "문서 편집" [level=1] [ref=f15e24]
- paragraph [ref=f15e25]: Collection Fetch Join Pagination의 In-memory Paging
- region [ref=f15e26]:
- generic [ref=f15e27]:
- paragraph [ref=f15e28]: DOCUMENT
- heading "기본 정보" [level=2] [ref=f15e29]
- generic [ref=f15e30]:
- generic [ref=f15e31]:
- generic [ref=f15e32]: 제목
- textbox "제목" [ref=f15e33]: Collection Fetch Join Pagination의 In-memory Paging
- generic [ref=f15e34]:
- generic [ref=f15e35]: slug
- textbox "slug" [ref=f15e36]:
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
- text: collection-fetch-join-in-memory-paging
- generic [ref=f15e37]:
- generic [ref=f15e38]: 요약
- textbox "요약" [ref=f15e39]: "컬렉션 하나만 `fetch join`하고 `setMaxResults(20)`을 적용하면 DB에서도 한 페이지 분량만 조회되어 전송량이 줄어들 것이라고 생각했다. 하지만 컬렉션 `fetch join`이 포함된 상태에서는 Hibernate가 DB 쿼리에 `LIMIT 20`을 적용하지 않는다. 전체 결과를 조회한 뒤 메모리에서 부모 엔티티를 기준으로 결과를 잘라 최종 20개로 반환하게 된다. 그래서 애플리케이션이 반환한 목록의 크기는 20이었지만, 실제 조회 과정에서는 대상 부모 엔티티 N개가 모두 로드되었다. 즉, `setMaxResults(20)`이 반환 결과의 크기는 제한했지만 DB에서 읽어 오는 데이터는 페이지로 줄여 주지는 못했다."
- generic [aria-hidden] [ref=f15e40]: 목록 카드에는 약 90자까지 보입니다 · 364 / 2000
- generic [ref=f15e41]:
- generic [ref=f15e42]: Topic
- combobox "Topic" [ref=f15e43]:
- option "선택하지 않음"
- option "JPA 피드 조회 성능" [selected]
- option "OAuth/OIDC 인증 경계"
- generic [ref=f15e44]:
- generic [ref=f15e45]: Project
- combobox "Project" [ref=f15e46]:
- option "미지정"
- option "Backend Clean Architecture"
- option "KeyCloak Patterns"
- option "Liner N + 1문제" [selected]
- status [ref=f15e47]
- group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f15e48]:
- generic [ref=f15e50] [cursor=pointer]:
- checkbox "파생 쿼리 그대로" [ref=f15e51]
- generic [ref=f15e52]: 파생 쿼리 그대로
- generic [ref=f15e53] [cursor=pointer]:
- checkbox "컬렉션 fetch join" [ref=f15e54]
- generic [ref=f15e55]: 컬렉션 fetch join
- generic [ref=f15e56] [cursor=pointer]:
- checkbox "fetch join + 페이징" [checked] [ref=f15e57]
- generic [ref=f15e58]: fetch join + 페이징
- group "관계" [ref=f15e59]:
- generic [ref=f15e61]:
- generic [ref=f15e62]:
- generic [ref=f15e63]: 관계 1 대상
- combobox "관계 1 대상" [ref=f15e64]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [disabled]
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [disabled]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" [selected]
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f15e65]:
- generic [ref=f15e66]: 관계 1 이유
- textbox "관계 1 이유" [ref=f15e67]: 이 관측에서 나온 결정이다.
- generic [aria-hidden] [ref=f15e68]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f15e69]:
- button "위로" [disabled] [ref=f15e70]
- button "아래로" [ref=f15e71]
- button "삭제" [ref=f15e72]
- generic [ref=f15e73]:
- generic [ref=f15e74]:
- generic [ref=f15e75]: 관계 2 대상
- combobox "관계 2 대상" [ref=f15e76]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [selected]
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [disabled]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" [disabled]
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f15e77]:
- generic [ref=f15e78]: 관계 2 이유
- textbox "관계 2 이유" [ref=f15e79]: 이 기록이 이어받은 앞 단계다.
- generic [aria-hidden] [ref=f15e80]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f15e81]:
- button "위로" [ref=f15e82]
- button "아래로" [ref=f15e83]
- button "삭제" [ref=f15e84]
- generic [ref=f15e85]:
- generic [ref=f15e86]:
- generic [ref=f15e87]: 관계 3 대상
- combobox "관계 3 대상" [ref=f15e88]:
- option "대상 선택"
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [disabled]
- option "Collection Fetch Join Pagination의 In-memory Paging"
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1"
- option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유"
- option "Projection 이후에도 1,509행을 읽은 Row Over-fetch"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우"
- option "Visibility OR이 Keyset Index를 깨뜨린 문제"
- option "Authorization Code와 PKCE가 보호하는 구간"
- option "Bearer JWT가 인증된 principal이 되기까지"
- option "Cookie로 인증하는 요청에서 CSRF token이 하는 일"
- option "브라우저가 credential을 보관하는 위치와 그 성질"
- option "Forward-Auth와 Nginx auth_request의 동작"
- option "외부 IdP Brokering의 동작"
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준"
- option "Feed Visibility Query Pattern"
- option "Fetch Join · Batch · Projection 선택 기준" [selected]
- option "Fetch Type과 Fetch Strategy 구분"
- option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건"
- option "외부 IdP 연동과 Application 인증 구조의 경계"
- option "JPA N+1 정량 진단 기준"
- option "Keyset Pagination 설계 기준"
- option "OAuth/OIDC 인증 패턴 선택 기준"
- option "OAuth Token과 Application Session을 구분하는 기준"
- option "PostgreSQL Query Plan 측정 기준"
- option "Public Client와 Confidential Client 구분 기준"
- option "Top-N-per-group 선택 기준"
- option "실제 동시 트래픽에서도 이 구조가 안정적인가"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가"
- option "ANALYZE 이후 Cardinality Estimate는 어떻게 달라지는가"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가"
- option "feed_visible을 Production CQRS로 승격할 것인가"
- option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가"
- option "Highlight 없는 FeedItem을 허용할 것인가"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "Round Trip과 Row Volume을 독립 측정할 것인가"
- option "BFF가 OAuth Token을 관리하는 조건"
- option "Collection Fetch Join과 Pagination을 같이 사용하지 않는다" [disabled]
- option "Entity Graph 조회에는 Batch Fetch를 사용한다"
- option "Feed Pagination은 Keyset을 사용한다"
- option "외부 IdP와의 연동이라도 별도의 인증 방식이 아니다."
- option "Query Plan은 실제 PostgreSQL에서 측정한다"
- option "Query Strategy는 FeedQueryPort 뒤에서 소유한다"
- option "현재 Read Model은 CQRS-lite로 유지한다"
- option "화면 조회는 Read Projection을 사용한다"
- generic [ref=f15e89]:
- generic [ref=f15e90]: 관계 3 이유
- textbox "관계 3 이유" [ref=f15e91]: 이 실패가 배치 선택으로 이어진 기준이다.
- generic [aria-hidden] [ref=f15e92]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
- generic [ref=f15e93]:
- button "위로" [ref=f15e94]
- button "아래로" [disabled] [ref=f15e95]
- button "삭제" [ref=f15e96]
- button "관계 추가" [ref=f15e97]
- region [ref=f15e98]:
- generic [ref=f15e99]:
- paragraph [ref=f15e100]: CASE
- heading "문제와 검증" [level=2] [ref=f15e101]
- generic [ref=f15e102]:
- generic [ref=f15e103]:
- generic [ref=f15e104]: 문제
- textbox "문제" [ref=f15e105]: "컬렉션을 `fetch join`하면서 연관 데이터를 추가 쿼리 없이 함께 조회할 수 있어 쿼리 수는 줄었다. 대신 부모와 자식이 조인되면서 DB에서 애플리케이션으로 전달되는 행 수가 증가했다. 여기에 페이징을 적용하면 조회 범위도 한 페이지로 제한되어 전송량까지 줄어들 거라고 생각했다. 실제로 `setMaxResults(20)`을 적용한 뒤 반환된 목록에는 부모 엔티티가 20개만 포함되어 있었다. 반환 결과만 보면 페이징이 정상적으로 적용된 것처럼 보였다. 하지만 반환된 목록의 크기만으로는 DB에서 실제로 몇 개의 엔티티를 읽어 메모리에 적재했는지 알 수 없다. 따라서 최종 반환 개수와 별도로 조회 과정에서 메모리에 로드된 부모 엔티티 수를 측정해 실제 페이징 범위를 확인했다."
- generic [ref=f15e106]:
- generic [ref=f15e107]: 결론
- textbox "결론" [ref=f15e108]: "반환는 페이지 크기인 20으로 나왔지만, 실제 로드된 `feedItemLoaded`는 N에 따라 증가했고 over-fetch는 최대 50배까지 커졌다. 컬렉션 `fetch join`에서는 조인 결과에 `LIMIT`을 적용하면 일부 부모의 컬렉션이 잘릴 수 있다. Hibernate는 이를 피하기 위해 SQL에서 `LIMIT`을 적용하지 않고 전체 결과를 읽은 뒤 메모리에서 페이징했다. 실행 계획에서도 `Limit` 노드가 없었다."
- generic [ref=f15e109]:
- generic [ref=f15e110]: 검증 환경
- textbox "검증 환경" [ref=f15e111]: "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 (기본값)"
- generic [ref=f15e112]:
- generic [ref=f15e113]: 재현 조건
- textbox "재현 조건" [ref=f15e114]: "1. highlights를 join fetch하는 원시 JPQL에 setFirstResult(0)과 setMaxResults(20)을 적용한다. 2. N ∈ {10, 100, 1000}에서 결과 리스트 크기와 EntityStatistics.getLoadCount()를 같이 확인한다. 3. 경고 로그를 ListAppender로 확인한다. 코드 번호만 보지않고 문구가 어떻게 나오는지 같이 확인한다. 4. 지연은 반복 측정하고 스레드 누적 할당을 같이 확인한다. 5. 컬렉션 fetch join 쿼리와 엔티티만 페이징한 쿼리를 각각 EXPLAIN해 Limit 노드 유무를 확인한다."
- generic [ref=f15e115]:
- generic [ref=f15e116]: 마지막 검증일
- textbox "마지막 검증일" [ref=f15e117]: 2026-09-01
- generic [ref=f15e118]:
- generic [ref=f15e119]: 본문 Markdown
- group "Markdown 삽입" [ref=f15e120]:
- button "코드" [ref=f15e121] [cursor=pointer]
- button "표" [ref=f15e122] [cursor=pointer]
- button "목록" [ref=f15e123] [cursor=pointer]
- textbox "본문 Markdown" [ref=f15e124]: "## 컬렉션 Fetch join에 페이징 ```java label=\"통합 테스트\" \"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 | 시드 하이라이트 | |---:|---:|---:|---:| | 10 | 10 | 10 | 1,285 | | 100 | 20 | 100 | 1,961 | | 1,000 | 20 | 1,000 | 2,917 | 데이터가 커지게 되면 반환 크기와 실제 로드 수의 차이가 보인다. fetch join 쿼리가 FeedItem을 루트로 하이드레이트하기 때문에, 인메모리 페이징은 부모 목록을 잘라서 반환된 값이 20이어도 메모리에 로드된 값은 N이다. ## Fetch join에 페이징을 적용 했을 때의 경고 ```text label=\"Hibernate ORM 7.1.8이 기록한 경고\" HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory ``` 널리 알려진 코드는 HHH000104지만 테스트할 때는 HHH90003004였다. 메시지 본문은 같았다. ## 페이지가 아닌 데이터셋에 비례한다 | N | 전체 Feed 조회 시간 지연 중앙값(5회) | 전체 Feed 조회 시간 지연 최댓값(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을 따라 오른다. 페이징이 데이터를 줄이지 못했다는 시간·메모리에서 확인할 수 있다. 스레드 누적 할당을 쓴 이유는 두 가지다. used heap 델타는 측정 구간 사이의 GC 시점에 따라 바뀐다, JVM 전체 값이라 다른 스레드의 활동도 섞인다. GC와 무관하게 이 스레드가 만든 총량을 확인해야 하기 때문에 누적 할당을 사용하게 되었다. 로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다. 반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다. ## SQL에 LIMIT 노드가 없다 ```text label=\"seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징\" -- (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)에는 fetch join을 하지 않고 엔티티만 페이징 적용을 하게 되면 Limit 노드가 확인되고 있고 top-N heapsort로 상위 몇 행만 반환하게 된다. 전체 정렬과 상위 몇 행 정렬의 비용 차이가 계획에서 나타나는 걸 볼 수 있다. ## 다음 선택 :::evidence key=\"batch-fetch-in-clause-cb44066a\" alt=\"조회 코드, Hibernate 세션, feed_items, highlights 네 참여자 사이에서 부모 페이징이 먼저 일어나고 그다음 자식 IN 배치 조회가 일어나는 순서도.\" caption=\" \" zoom=\"true\" ::: fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된다. 다만 highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아온다. 그래서 페이지 부모 키를 모아 IN으로 조회하는 Batch Fetch를 함께 적용했다."
- group [ref=f15e125]:
- paragraph [ref=f15e126]: EVIDENCE
- heading "본문에 Asset 삽입" [level=3] [ref=f15e127]
- paragraph [ref=f15e128]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
- generic [ref=f15e129]:
- generic [ref=f15e130]:
- generic [ref=f15e131]: 업로드 종류
- combobox "업로드 종류" [ref=f15e132]:
- option "이미지" [selected]
- option "다이어그램"
- option "첨부파일"
- button "Asset 업로드" [ref=f15e133]
- generic [ref=f15e134]:
- search [ref=f15e135]:
- generic [ref=f15e136]: Asset 검색
- generic [ref=f15e137]:
- searchbox "Asset 검색" [ref=f15e138]
- button "검색" [ref=f15e139]
- generic [ref=f15e140]:
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f15e141]
- generic [ref=f15e142]: 삽입할 때 크게 보기 허용
- status [ref=f15e143]: 삽입할 수 있는 Asset 24개
- list [ref=f15e144]:
- listitem [ref=f15e145]:
- button "ap4-edge-trust-architecture-1a916e10" [ref=f15e146]
- button "삭제" [ref=f15e147]
- listitem [ref=f15e148]:
- button "ap3-bff-session-flow-1b005e15" [ref=f15e149]
- button "삭제" [ref=f15e150]
- listitem [ref=f15e151]:
- button "ap3-bff-architecture-a27ea91c" [ref=f15e152]
- button "삭제" [ref=f15e153]
- listitem [ref=f15e154]:
- button "ap2-mediator-handoff-flow-efe7039c" [ref=f15e155]
- button "삭제" [ref=f15e156]
- listitem [ref=f15e157]:
- button "ap2-mediator-architecture-c95ed25f" [ref=f15e158]
- button "삭제" [ref=f15e159]
- listitem [ref=f15e160]:
- button "projection-row-over-fetch-f2b1943b" [ref=f15e161]
- button "삭제" [ref=f15e162]
- listitem [ref=f15e163]:
- button "cartesian-row-multiplication-dce2e166" [ref=f15e164]
- button "삭제" [ref=f15e165]
- listitem [ref=f15e166]:
- button "eager-lazy-query-sequence-47c12bda" [ref=f15e167]
- button "삭제" [ref=f15e168]
- listitem [ref=f15e169]:
- button "ap3-bff-session-flow-a8dfff6f" [ref=f15e170]
- button "삭제" [ref=f15e171]
- listitem [ref=f15e172]:
- button "ap2-mediator-handoff-flow-8c2a6f8f" [ref=f15e173]
- button "삭제" [ref=f15e174]
- listitem [ref=f15e175]:
- button "ap4-edge-forward-auth-flow-a6ec423a" [ref=f15e176]
- button "삭제" [ref=f15e177]
- listitem [ref=f15e178]:
- button "ap3-csrf-boundary-971df81c" [ref=f15e179]
- button "삭제" [ref=f15e180]
- listitem [ref=f15e181]:
- button "login-api-phase-split-3e354274" [ref=f15e182]
- button "삭제" [ref=f15e183]
- listitem [ref=f15e184]:
- button "ap1-browser-bearer-flow-a7f8aa9e" [ref=f15e185]
- button "삭제" [ref=f15e186]
- listitem [ref=f15e187]:
- button "ap1-direct-architecture-0adf4199" [ref=f15e188]
- button "삭제" [ref=f15e189]
- listitem [ref=f15e190]:
- button "nplus1-query-fanout-644febe6" [ref=f15e191]
- button "삭제" [ref=f15e192]
- listitem [ref=f15e193]:
- button "ap4-edge-trust-1cff2399" [ref=f15e194]
- button "삭제" [ref=f15e195]
- listitem [ref=f15e196]:
- button "ap3-csrf-split-501dd1f7" [ref=f15e197]
- button "삭제" [ref=f15e198]
- listitem [ref=f15e199]:
- button "ap3-bff-custody-82fa18bd" [ref=f15e200]
- button "삭제" [ref=f15e201]
- listitem [ref=f15e202]:
- button "ap2-split-custody-779cb791" [ref=f15e203]
- button "삭제" [ref=f15e204]
- listitem [ref=f15e205]:
- button "ap1-custody-v3-6e0376d2" [ref=f15e206]
- button "삭제" [ref=f15e207]
- listitem [ref=f15e208]:
- button "ap1-custody-v2-e110bd98" [ref=f15e209]
- button "삭제" [ref=f15e210]
- listitem [ref=f15e211]:
- button "ap1-credential-custody-f5e0c027" [ref=f15e212]
- button "삭제" [ref=f15e213]
- listitem [ref=f15e214]:
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f15e215]
- button "삭제" [ref=f15e216]
- region [ref=f15e235]:
- generic [ref=f15e236]:
- paragraph [ref=f15e237]: LIVE
- heading "즉시 미리보기" [level=2] [ref=f15e238]
- generic [ref=f15e241]:
- generic [ref=f15e242]:
- navigation "문서 경로" [ref=f15e243]:
- link "검증 기록" [ref=f15e244] [cursor=pointer]:
- /url: /explore/cases
- generic [aria-hidden] [ref=f15e245]: /
- generic [ref=f15e246]: JPA 피드 조회 성능
- generic [aria-hidden] [ref=f15e247]: /
- link "Liner N + 1문제" [ref=f15e248] [cursor=pointer]:
- /url: /projects/liner-n-plus-1
- heading "Collection Fetch Join Pagination의 In-memory Paging" [level=1] [ref=f15e249]
- paragraph [ref=f15e250]:
- text: 컬렉션 하나만
- code [ref=f15e251]: fetch join
- text: 하고
- code [ref=f15e252]: setMaxResults(20)
- text: 을 적용하면 DB에서도 한 페이지 분량만 조회되어 전송량이 줄어들 것이라고 생각했다.
- paragraph [ref=f15e253]:
- text: 하지만 컬렉션
- code [ref=f15e254]: fetch join
- text: 이 포함된 상태에서는 Hibernate가 DB 쿼리에
- code [ref=f15e255]: LIMIT 20
- text: 을 적용하지 않는다. 전체 결과를 조회한 뒤 메모리에서 부모 엔티티를 기준으로 결과를 잘라 최종 20개로 반환하게 된다.
- paragraph [ref=f15e256]:
- text: 그래서 애플리케이션이 반환한 목록의 크기는 20이었지만, 실제 조회 과정에서는 대상 부모 엔티티 N개가 모두 로드되었다. 즉,
- code [ref=f15e257]: setMaxResults(20)
- text: 이 반환 결과의 크기는 제한했지만 DB에서 읽어 오는 데이터는 페이지로 줄여 주지는 못했다.
- region "문제와 결론" [ref=f15e258]:
- generic [ref=f15e259]:
- paragraph [ref=f15e260]: 문제
- paragraph [ref=f15e261]:
- text: 컬렉션을
- code [ref=f15e262]: fetch join
- text: 하면서 연관 데이터를 추가 쿼리 없이 함께 조회할 수 있어 쿼리 수는 줄었다. 대신 부모와 자식이 조인되면서 DB에서 애플리케이션으로 전달되는 행 수가 증가했다. 여기에 페이징을 적용하면 조회 범위도 한 페이지로 제한되어 전송량까지 줄어들 거라고 생각했다.
- paragraph [ref=f15e263]:
- text: 실제로
- code [ref=f15e264]: setMaxResults(20)
- text: 을 적용한 뒤 반환된 목록에는 부모 엔티티가 20개만 포함되어 있었다. 반환 결과만 보면 페이징이 정상적으로 적용된 것처럼 보였다.
- paragraph [ref=f15e265]: 하지만 반환된 목록의 크기만으로는 DB에서 실제로 몇 개의 엔티티를 읽어 메모리에 적재했는지 알 수 없다. 따라서 최종 반환 개수와 별도로 조회 과정에서 메모리에 로드된 부모 엔티티 수를 측정해 실제 페이징 범위를 확인했다.
- generic [ref=f15e266]:
- paragraph [ref=f15e267]: 결론
- paragraph [ref=f15e268]:
- text: 반환는 페이지 크기인 20으로 나왔지만, 실제 로드된
- code [ref=f15e269]: feedItemLoaded
- text: 는 N에 따라 증가했고 over-fetch는 최대 50배까지 커졌다.
- paragraph [ref=f15e270]:
- text: 컬렉션
- code [ref=f15e271]: fetch join
- text: 에서는 조인 결과에
- code [ref=f15e272]: LIMIT
- text: 을 적용하면 일부 부모의 컬렉션이 잘릴 수 있다. Hibernate는 이를 피하기 위해 SQL에서
- code [ref=f15e273]: LIMIT
- text: 을 적용하지 않고 전체 결과를 읽은 뒤 메모리에서 페이징했다. 실행 계획에서도
- code [ref=f15e274]: Limit
- text: 노드가 없었다.
- generic [ref=f15e275]:
- generic [ref=f15e276]:
- term [ref=f15e277]: 검증 환경
- definition [ref=f15e278]:
- paragraph [ref=f15e279]: "Java 21Spring Boot 4.0.0Hibernate ORM 7.1.8.FinalPostgreSQL : postgres:16-alpine (Testcontainers)"
- paragraph [ref=f15e280]: "쿼리 : JPQLhibernate.query.fail_on_pagination_over_collection_fetch : false (기본값)"
- generic [ref=f15e281]:
- term [ref=f15e282]: 검증 데이터
- definition [ref=f15e283]:
- paragraph [ref=f15e284]: 1. highlights를 join fetch하는 원시 JPQL에 setFirstResult(0)과 setMaxResults(20)을 적용한다.
- paragraph [ref=f15e285]: "2. N ∈ {10, 100, 1000}에서 결과 리스트 크기와 EntityStatistics.getLoadCount()를 같이 확인한다."
- paragraph [ref=f15e286]: 3. 경고 로그를 ListAppender로 확인한다. 코드 번호만 보지않고 문구가 어떻게 나오는지 같이 확인한다.
- paragraph [ref=f15e287]: 4. 지연은 반복 측정하고 스레드 누적 할당을 같이 확인한다.
- paragraph [ref=f15e288]: 5. 컬렉션 fetch join 쿼리와 엔티티만 페이징한 쿼리를 각각 EXPLAIN해 Limit 노드 유무를 확인한다.
- generic [ref=f15e289]:
- term [ref=f15e290]: 기록
- definition [ref=f15e291]: 게시 2026.09.01 · 마지막 검증 2026.09.01
- group [ref=f15e293]:
- generic "목차 · 컬렉션 Fetch join에 페이징" [ref=f15e294] [cursor=pointer]
- article [ref=f15e296]:
- region [ref=f15e297]:
- heading [level=2] [ref=f15e298]:
- link "컬렉션 Fetch join에 페이징 바로가기" [ref=f15e299] [cursor=pointer]:
- /url: "#컬렉션-fetch-join에-페이징"
- text: 컬렉션 Fetch join에 페이징
- generic [aria-hidden] [ref=f15e300]: "#"
- figure "JAVA ·통합 테스트 코드 복사" [ref=f15e301]:
- generic [ref=f15e302]:
- generic [ref=f15e303]: JAVA
- generic [ref=f15e304]: ·통합 테스트
- button "코드 복사" [ref=f15e305] [cursor=pointer]: 복사
- region "통합 테스트 코드" [ref=f15e306]:
- code [ref=f15e307]: "\"select f from FeedItemJpaEntity f join fetch f.highlights \" // ← 한 bag fetch join + \"order by f.firstHighlightedAt desc, f.id asc\" // + .setFirstResult(0).setMaxResults(20) // ← 페이징"
- paragraph [ref=f15e309]: 앞 단계의 데이터와 매핑은 그대로 두고 페이징 처리만 진행 했다.
- region [ref=f15e310]:
- heading [level=2] [ref=f15e311]:
- link "반환은 한 페이지인데 메모리에 전부 로드 바로가기" [ref=f15e312] [cursor=pointer]:
- /url: "#반환은-한-페이지인데-메모리에-전부-로드"
- text: 반환은 한 페이지인데 메모리에 전부 로드
- generic [aria-hidden] [ref=f15e313]: "#"
- region "표" [ref=f15e314]:
- table [ref=f15e315]:
- caption [ref=f15e316]
- rowgroup [ref=f15e317]:
- row [ref=f15e318]:
- columnheader "N" [ref=f15e319]
- columnheader "returned(페이지)" [ref=f15e320]
- columnheader "feedItemLoaded" [ref=f15e321]
- columnheader "시드 하이라이트" [ref=f15e322]
- rowgroup [ref=f15e323]:
- row [ref=f15e324]:
- cell "10" [ref=f15e325]
- cell "10" [ref=f15e326]
- cell "10" [ref=f15e327]
- cell "1,285" [ref=f15e328]
- row [ref=f15e329]:
- cell "100" [ref=f15e330]
- cell "20" [ref=f15e331]
- cell "100" [ref=f15e332]
- cell "1,961" [ref=f15e333]
- row [ref=f15e334]:
- cell "1,000" [ref=f15e335]
- cell "20" [ref=f15e336]
- cell "1,000" [ref=f15e337]
- cell "2,917" [ref=f15e338]
- paragraph [ref=f15e339]: 데이터가 커지게 되면 반환 크기와 실제 로드 수의 차이가 보인다.
- paragraph [ref=f15e340]: fetch join 쿼리가 FeedItem을 루트로 하이드레이트하기 때문에, 인메모리 페이징은 부모 목록을 잘라서 반환된 값이 20이어도 메모리에 로드된 값은 N이다.
- region [ref=f15e341]:
- heading [level=2] [ref=f15e342]:
- link "Fetch join에 페이징을 적용 했을 때의 경고 바로가기" [ref=f15e343] [cursor=pointer]:
- /url: "#fetch-join에-페이징을-적용-했을-때의-경고"
- text: Fetch join에 페이징을 적용 했을 때의 경고
- generic [aria-hidden] [ref=f15e344]: "#"
- figure "TEXT ·Hibernate ORM 7.1.8이 기록한 경고 코드 복사" [ref=f15e345]:
- generic [ref=f15e346]:
- generic [ref=f15e347]: TEXT
- generic [ref=f15e348]: ·Hibernate ORM 7.1.8이 기록한 경고
- button "코드 복사" [ref=f15e349] [cursor=pointer]: 복사
- region "Hibernate ORM 7.1.8이 기록한 경고 코드" [ref=f15e350]:
- code [ref=f15e351]: "HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory"
- paragraph [ref=f15e353]: 널리 알려진 코드는 HHH000104지만 테스트할 때는 HHH90003004였다. 메시지 본문은 같았다.
- region [ref=f15e354]:
- heading [level=2] [ref=f15e355]:
- link "페이지가 아닌 데이터셋에 비례한다 바로가기" [ref=f15e356] [cursor=pointer]:
- /url: "#페이지가-아닌-데이터셋에-비례한다"
- text: 페이지가 아닌 데이터셋에 비례한다
- generic [aria-hidden] [ref=f15e357]: "#"
- region "표" [ref=f15e358]:
- table [ref=f15e359]:
- caption [ref=f15e360]
- rowgroup [ref=f15e361]:
- row [ref=f15e362]:
- columnheader "N" [ref=f15e363]
- columnheader "전체 Feed 조회 시간 지연 중앙값(5회)" [ref=f15e364]
- columnheader "전체 Feed 조회 시간 지연 최댓값(5회)" [ref=f15e365]
- columnheader "스레드 누적 할당" [ref=f15e366]
- rowgroup [ref=f15e367]:
- row [ref=f15e368]:
- cell "10" [ref=f15e369]
- cell "6.184 ms" [ref=f15e370]
- cell "6.566 ms" [ref=f15e371]
- cell "약 1.5 MB" [ref=f15e372]
- row [ref=f15e373]:
- cell "100" [ref=f15e374]
- cell "13.890 ms" [ref=f15e375]
- cell "16.062 ms" [ref=f15e376]
- cell "약 3.0 MB" [ref=f15e377]
- row [ref=f15e378]:
- cell "1,000" [ref=f15e379]
- cell "79.452 ms" [ref=f15e380]
- cell "83.526 ms" [ref=f15e381]
- cell "약 10.0 MB" [ref=f15e382]
- paragraph [ref=f15e383]: returned가 페이지 크기로 고정인데도 지연과 할당이 N을 따라 오른다.페이징이 데이터를 줄이지 못했다는 시간·메모리에서 확인할 수 있다.
- paragraph [ref=f15e384]: 스레드 누적 할당을 쓴 이유는 두 가지다.used heap 델타는 측정 구간 사이의 GC 시점에 따라 바뀐다, JVM 전체 값이라 다른 스레드의 활동도 섞인다.GC와 무관하게 이 스레드가 만든 총량을 확인해야 하기 때문에 누적 할당을 사용하게 되었다.
- paragraph [ref=f15e385]: 로드된 엔티티가 곧바로 GC 대상이 되는 것은 아니다.반환 리스트만 페이지 크기로 잘릴 뿐 영속성 컨텍스트가 나머지를 붙들고 있어서 em.clear나 트랜잭션 종료 전까지 남는다.
- region [ref=f15e386]:
- heading [level=2] [ref=f15e387]:
- link "SQL에 LIMIT 노드가 없다 바로가기" [ref=f15e388] [cursor=pointer]:
- /url: "#sql에-limit-노드가-없다"
- text: SQL에 LIMIT 노드가 없다
- generic [aria-hidden] [ref=f15e389]: "#"
- figure "TEXT ·seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징 코드 복사" [ref=f15e390]:
- generic [ref=f15e391]:
- generic [ref=f15e392]: TEXT
- generic [ref=f15e393]: ·seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징
- button "코드 복사" [ref=f15e394] [cursor=pointer]: 복사
- region "seed(100) — (a) 컬렉션 fetch join / (b) 엔티티만 페이징 코드" [ref=f15e395]:
- code [ref=f15e396]: "-- (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)"
- paragraph [ref=f15e398]: (a)에는 Limit 노드가 없다. 조인 결과 전체를 quicksort로 정렬한 뒤 그대로 반환하고, 페이지로 자르는 일은 Hibernate가 메모리에서 한다.(b)에는 fetch join을 하지 않고 엔티티만 페이징 적용을 하게 되면 Limit 노드가 확인되고 있고 top-N heapsort로 상위 몇 행만 반환하게 된다.
- paragraph [ref=f15e399]: 전체 정렬과 상위 몇 행 정렬의 비용 차이가 계획에서 나타나는 걸 볼 수 있다.
- region [ref=f15e400]:
- heading [level=2] [ref=f15e401]:
- link "다음 선택 바로가기" [ref=f15e402] [cursor=pointer]:
- /url: "#다음-선택"
- text: 다음 선택
- generic [aria-hidden] [ref=f15e403]: "#"
- figure [ref=f15e433]:
- button "batch-fetch-in-clause-cb44066a 이미지 크게 보기" [ref=f15e434]:
- img "조회 코드, Hibernate 세션, feed_items, highlights 네 참여자 사이에서 부모 페이징이 먼저 일어나고 그다음 자식 IN 배치 조회가 일어나는 순서도." [ref=f15e435]
- generic [ref=f15e436]: 크게 보기
- generic [ref=f15e437]: 조회 코드, Hibernate 세션, feed_items, highlights 네 참여자 사이에서 부모 페이징이 먼저 일어나고 그다음 자식 IN 배치 조회가 일어나는 순서도.
- paragraph [ref=f15e438]: fetch join을 버리고 엔티티만 페이징하면 LIMIT이 정상 발행된다.다만 highlights가 다시 지연 로딩이 되어 컬렉션 N+1이 돌아온다.그래서 페이지 부모 키를 모아 IN으로 조회하는 Batch Fetch를 함께 적용했다.
- region [ref=f15e405]:
- paragraph [ref=f15e406]: Next
- heading "다음에 읽을 것" [level=2] [ref=f15e407]
- list [ref=f15e408]:
- listitem [ref=f15e409]:
- link "검증 기록 Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [ref=f15e410] [cursor=pointer]:
- /url: /cases/fetch-join-multibag-and-row-explosion
- generic [ref=f15e411]: 검증 기록
- generic [ref=f15e412]:
- strong [ref=f15e413]: Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제
- paragraph [aria-hidden] [ref=f15e414]: 이 기록이 이어받은 앞 단계다.
- generic [aria-hidden] [ref=f15e415]:
- complementary [ref=f15e416]:
- heading "작업 상태" [level=2] [ref=f15e417]
- status "편집 상태" [ref=f15e418]: 저장됨
- generic [ref=f15e419]:
- generic [ref=f15e420]:
- term [ref=f15e421]: 저장 버전
- definition [ref=f15e422]: "46"
- generic [ref=f15e423]:
- term [ref=f15e424]: 종류
- definition [ref=f15e425]: 검증 기록
- paragraph [ref=f15e426]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
- generic [ref=f15e427]:
- button "저장" [disabled] [ref=f15e428]
- button "게시" [ref=f15e429]
- paragraph [ref=f15e430]: 버전 46으로 저장했습니다.