628 lines
49 KiB
YAML
628 lines
49 KiB
YAML
- generic [ref=f12e3]:
|
||
- link "본문으로 건너뛰기" [ref=f12e4] [cursor=pointer]:
|
||
- /url: "#main-content"
|
||
- banner [ref=f12e5]:
|
||
- generic [ref=f12e6]:
|
||
- link "TechLog Studio" [ref=f12e7] [cursor=pointer]:
|
||
- /url: /studio
|
||
- text: TechLog
|
||
- generic [ref=f12e8]: Studio
|
||
- navigation "Studio 주 탐색" [ref=f12e10]:
|
||
- link "작업본" [ref=f12e11] [cursor=pointer]:
|
||
- /url: /studio/documents
|
||
- link "게시 기록" [ref=f12e12] [cursor=pointer]:
|
||
- /url: /studio/publications
|
||
- link "새 문서" [ref=f12e13] [cursor=pointer]:
|
||
- /url: /studio/documents/new
|
||
- link "주제·프로젝트" [ref=f12e14] [cursor=pointer]:
|
||
- /url: /studio/taxonomy
|
||
- link "릴리즈" [ref=f12e15] [cursor=pointer]:
|
||
- /url: /studio/releases
|
||
- link "공개 사이트 보기" [ref=f12e16] [cursor=pointer]:
|
||
- /url: /
|
||
- button "로그아웃" [ref=f12e17]
|
||
- main [ref=f12e18]:
|
||
- generic [ref=f12e19]:
|
||
- generic [ref=f12e20]:
|
||
- region [ref=f12e21]:
|
||
- generic [ref=f12e22]:
|
||
- paragraph [ref=f12e23]: CASE · VERSION 36
|
||
- heading "문서 편집" [level=1] [ref=f12e24]
|
||
- paragraph [ref=f12e25]: Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제
|
||
- region [ref=f12e26]:
|
||
- generic [ref=f12e27]:
|
||
- paragraph [ref=f12e28]: DOCUMENT
|
||
- heading "기본 정보" [level=2] [ref=f12e29]
|
||
- generic [ref=f12e30]:
|
||
- generic [ref=f12e31]:
|
||
- generic [ref=f12e32]: 제목
|
||
- textbox "제목" [ref=f12e33]: Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제
|
||
- generic [ref=f12e34]:
|
||
- generic [ref=f12e35]: slug
|
||
- textbox "slug" [ref=f12e36]:
|
||
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
|
||
- text: fetch-join-multibag-and-row-explosion
|
||
- generic [ref=f12e37]:
|
||
- generic [ref=f12e38]: 요약
|
||
- textbox "요약" [ref=f12e39]: "추가 쿼리를 줄이기 위해 필요한 연관 데이터를 `fetch join`으로 한 번에 조회했다. 하지만 두 컬렉션을 동시에 `fetch join`하자 `MultipleBagFetchException`이 발생했다. 컬렉션 하나만 `fetch join`했을 때는 쿼리 수가 줄었지만, 부모와 자식이 조인되면서 조회되는 행 수가 크게 늘었다. 실제 전송 행 수도 생성한 Highlight의 전체 개수만큼 증가했다. `fetch join`으로 쿼리 수는 줄일 수 있었지만, 그만큼 DB에서 읽고 애플리케이션에서 처리해야 하는 데이터와 메모리 사용량이 증가했다."
|
||
- generic [aria-hidden] [ref=f12e40]: 목록 카드에는 약 90자까지 보입니다 · 312 / 2000
|
||
- generic [ref=f12e41]:
|
||
- generic [ref=f12e42]: Topic
|
||
- combobox "Topic" [ref=f12e43]:
|
||
- option "선택하지 않음"
|
||
- option "JPA 피드 조회 성능" [selected]
|
||
- option "OAuth/OIDC 인증 경계"
|
||
- generic [ref=f12e44]:
|
||
- generic [ref=f12e45]: Project
|
||
- combobox "Project" [ref=f12e46]:
|
||
- option "미지정"
|
||
- option "Backend Clean Architecture"
|
||
- option "KeyCloak Patterns"
|
||
- option "Liner N + 1문제" [selected]
|
||
- status [ref=f12e47]
|
||
- group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f12e48]:
|
||
- generic [ref=f12e50] [cursor=pointer]:
|
||
- checkbox "파생 쿼리 그대로" [ref=f12e51]
|
||
- generic [ref=f12e52]: 파생 쿼리 그대로
|
||
- generic [ref=f12e53] [cursor=pointer]:
|
||
- checkbox "컬렉션 fetch join" [checked] [ref=f12e54]
|
||
- generic [ref=f12e55]: 컬렉션 fetch join
|
||
- generic [ref=f12e56] [cursor=pointer]:
|
||
- checkbox "fetch join + 페이징" [ref=f12e57]
|
||
- generic [ref=f12e58]: fetch join + 페이징
|
||
- group "관계" [ref=f12e59]:
|
||
- generic [ref=f12e61]:
|
||
- generic [ref=f12e62]:
|
||
- generic [ref=f12e63]: 관계 1 대상
|
||
- combobox "관계 1 대상" [ref=f12e64]:
|
||
- option "대상 선택"
|
||
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
|
||
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
|
||
- option "Collection Fetch Join Pagination의 In-memory Paging" [disabled]
|
||
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1" [disabled]
|
||
- 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을 같이 사용하지 않는다"
|
||
- 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=f12e65]:
|
||
- generic [ref=f12e66]: 관계 1 이유
|
||
- textbox "관계 1 이유" [ref=f12e67]: 이 실패에서 나온 선택 기준이다.
|
||
- generic [aria-hidden] [ref=f12e68]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||
- generic [ref=f12e69]:
|
||
- button "위로" [disabled] [ref=f12e70]
|
||
- button "아래로" [ref=f12e71]
|
||
- button "삭제" [ref=f12e72]
|
||
- generic [ref=f12e73]:
|
||
- generic [ref=f12e74]:
|
||
- generic [ref=f12e75]: 관계 2 대상
|
||
- combobox "관계 2 대상" [ref=f12e76]:
|
||
- option "대상 선택"
|
||
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
|
||
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
|
||
- option "Collection Fetch Join Pagination의 In-memory Paging" [selected]
|
||
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1" [disabled]
|
||
- 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을 같이 사용하지 않는다"
|
||
- 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=f12e77]:
|
||
- generic [ref=f12e78]: 관계 2 이유
|
||
- textbox "관계 2 이유" [ref=f12e79]: 한 bag만 fetch join한 상태에서 페이징을 적용한 다음 기록이다.
|
||
- generic [aria-hidden] [ref=f12e80]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||
- generic [ref=f12e81]:
|
||
- button "위로" [ref=f12e82]
|
||
- button "아래로" [ref=f12e83]
|
||
- button "삭제" [ref=f12e84]
|
||
- generic [ref=f12e85]:
|
||
- generic [ref=f12e86]:
|
||
- generic [ref=f12e87]: 관계 3 대상
|
||
- combobox "관계 3 대상" [ref=f12e88]:
|
||
- option "대상 선택"
|
||
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
|
||
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
|
||
- option "Collection Fetch Join Pagination의 In-memory Paging" [disabled]
|
||
- option "Fetch 타입이 아닌 조회 방식으로 인한 N+1" [selected]
|
||
- 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을 같이 사용하지 않는다"
|
||
- 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=f12e89]:
|
||
- generic [ref=f12e90]: 관계 3 이유
|
||
- textbox "관계 3 이유" [ref=f12e91]: 이 시도가 풀려던 문제다.
|
||
- generic [aria-hidden] [ref=f12e92]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||
- generic [ref=f12e93]:
|
||
- button "위로" [ref=f12e94]
|
||
- button "아래로" [disabled] [ref=f12e95]
|
||
- button "삭제" [ref=f12e96]
|
||
- button "관계 추가" [ref=f12e97]
|
||
- region [ref=f12e98]:
|
||
- generic [ref=f12e99]:
|
||
- paragraph [ref=f12e100]: CASE
|
||
- heading "문제와 검증" [level=2] [ref=f12e101]
|
||
- generic [ref=f12e102]:
|
||
- generic [ref=f12e103]:
|
||
- generic [ref=f12e104]: 문제
|
||
- textbox "문제" [ref=f12e105]: "`@OneToMany`과 `@ManyToOne`에서 발생하는 추가 쿼리를 확인한 뒤, 먼저 컬렉션에 대해서 `highlights`, `mentions`를 모두 `fetch join`해 한 번의 쿼리로 조회해 보았다. mentions은 user와 feedItem의 다대다의 관계를 1대다와 다대1의 관계로 풀어내면서 나온 컬렉션이다."
|
||
- generic [ref=f12e106]:
|
||
- generic [ref=f12e107]: 결론
|
||
- textbox "결론" [ref=f12e108]: "두 `List` 컬렉션을 동시에 `fetch join`하면 `MultipleBagFetchException`이 발생했다. Hibernate는 순서 컬럼이 없는 두 `List`가 조인되면서 `highlights × mentions` 형태로 행이 늘어날 경우, 이 결과를 원래 두 컬렉션으로 정확하게 구성할 수 없기 때문에 쿼리 실행 전에 이를 막는다. 실제 데이터가 없는 상태에서도 같은 예외가 발생했다. `highlights` 하나만 `fetch join`하면 예외는 발생하지 않았다. 대신 부모인 Feed Item이 Highlight 수만큼 반복되면서 DB에서 전달되는 행 수가 늘어났다. Hibernate 6에서는 `fetch join` 결과의 루트 엔티티 중복을 제거하기 때문에 최종 목록에는 Feed Item이 N개만 남는다. 따라서 반환된 목록의 크기만 보면 조인으로 행이 얼마나 늘어났는지 알 수 없다. N=100에서는 전체 쿼리가 222개에서 121개로 줄었다. 하지만 120개는 여전히 `User`와 `Page`를 조회하는 추가 쿼리였고, `highlights`를 가져오는 하나의 조인 쿼리는 1,961행을 전달했다. 즉, 쿼리 수는 줄었지만 실제로 처리하는 데이터까지 같이 줄어든 건 아니었다."
|
||
- generic [ref=f12e109]:
|
||
- generic [ref=f12e110]: 검증 환경
|
||
- textbox "검증 환경" [ref=f12e111]: "Java 21 Spring Boot 4.0.0 Hibernate ORM 7.1.8.Final PostgreSQL : postgres:16-alpine (Testcontainers) Query : JPQL Unique Constraint : (feed_item_id, mentioned_user_id)"
|
||
- generic [ref=f12e112]:
|
||
- generic [ref=f12e113]: 재현 조건
|
||
- textbox "재현 조건" [ref=f12e114]: 1. highlights와 mentions를 동시에 join fetch하는 JPQL을 실행해 MultipleBagFetchException이 발생하는지 확인한다. 2. highlights만 fetch join한 뒤 FeedItem을 각각 10개, 100개, 1000개로 늘려가면서 조회한다. 3. Hibernete가 반환한 FeedItem 수와 실제 조인으로 만들어진 행의 수를 각각 비교해본다. 4. 같은 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 실행해 DB에서 실제로 처리한 행 수를 확인한다. 5. 기존 테스트를 다시 실행해 변경 전 동작이 유지되는지 확인, highlights는 접근할 때 Feed Item 수만큼 조회되고, 접근하지 않으면 추가 조회는 없어야 한다.
|
||
- generic [ref=f12e115]:
|
||
- generic [ref=f12e116]: 마지막 검증일
|
||
- textbox "마지막 검증일" [ref=f12e117]: 2026-09-01
|
||
- generic [ref=f12e118]:
|
||
- generic [ref=f12e119]: 본문 Markdown
|
||
- group "Markdown 삽입" [ref=f12e120]:
|
||
- button "코드" [ref=f12e121] [cursor=pointer]
|
||
- button "표" [ref=f12e122] [cursor=pointer]
|
||
- button "목록" [ref=f12e123] [cursor=pointer]
|
||
- textbox "본문 Markdown" [ref=f12e124]: "## 두 컬렉션을 동시 fetch join ```java label=\"컬렉션 둘을 같이 fetch join\" select distinct f from FeedItemJpaEntity f join fetch f.highlights join fetch f.mentions ``` ```text label=\"예외 원인\" java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchException ``` MultipleBagFetchException은 직접 발생하지 않았고 IllegalArgumentException에 감싸진 상태로 전달됐다. 전체 예외의 원인을 따라가면서 어떤 예외인지 확인했고 MultipleBagFetchException 해당 예외가 발생하는 것을 확인할 수 있었다. ## 한 컬렉션만 fetch join :::evidence key=\"cartesian-row-multiplication-dce2e166\" alt=\"feed_items와 highlights가 fetch join으로 합쳐져 전송 조인 행이 되고, 그 행이 결과 리스트로 갈 때만 루트 엔티티가 중복 제거되는 흐름.\" caption=\" \" zoom=\"true\" ::: | FeedItem 수 | 조인된 행 수 | 반환된 Feed Item 수 | Highlight 수 | FeedItem 대비 조인 행 수 | |---:|---:|---:|---:|---:| | 10 | 1,285 | 10 | 1,285 | 128.5× | | 100 | 1,961 | 100 | 1,961 | 19.6× | | 1,000 | 2,917 | 1,000 | 2,917 | 2.9× | highlights를 fetch join하자 조인된 행 수는 Highlight의 전체 개수와 같았다. FeedItem 하나에 Highlight가 여러 개 있으면 같은 FeedItem이 Highlight 수만큼 반복되기 때문이다. Hibernate는 중복된 Feed Item을 제거해 최종 목록에는 각각 10개, 100개, 1000개만 반환했다. 하지만 DB에서 만들어지는 조인 결과까지 줄어드는 건 아니었다. FeedItem이 늘어나면서 조인된 행 수는 1285개에서 2917개까지 계속 증가했다. ## 쿼리 수만 보면 개선처럼 보인다 | 구분 | 기존 조회 | highlights fetch join | 변화 | |---|---:|---:|---| | Feed Item 조회 | 1 | 1 | highlights를 같이 조회 | | count 조회 | 1 | 0 | JPQL로 조회 | | Highlight 조회 | 100 | 0 | 개별 조회 제거 | | User+Page 조회 | 120 | 120 | 변화 없음 | | 전체 | 222 | 121 | 101개 감소 | 전체 쿼리는 222개가 121개로 줄었다. 하지만 User 와 Page를 조회하는 120개의 쿼리는 그대로 남아있어서 n+1은 highlights만 제거된 상태다. ## 조인이 행을 곱하는 것을 실행계획 ```text label=\"N=100일 때, fetch join EXPLAIN\" Hash Join (cost=77.18..512.34 rows=4202 width=32) (actual time=0.589..0.894 rows=1961 loops=1) Hash Cond: (h.feed_item_id = fi.id) -> 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) Execution Time: 0.959 ms ``` Feed Item은 100개가 반환되지만 실행계획에서 조인 결과는 1,961행이었다. 쿼리는 한 번만 실행됐지만, 각 Feed Item이 Highlight 수만큼 반복되면서 실제로 처리하고 전달한 행은 훨씬 많았다. Hibernate가 중복된 Feed Item을 제거해 최종 목록에는 100개만 남기 때문에 반환된 목록 크기만으로는 실제로 반환되는 행의 수를 알 수 없다. 실행계획의 예상 행 수는 4,202행이었지만 실제로는 1,961행이었다."
|
||
- group [ref=f12e125]:
|
||
- paragraph [ref=f12e126]: EVIDENCE
|
||
- heading "본문에 Asset 삽입" [level=3] [ref=f12e127]
|
||
- paragraph [ref=f12e128]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
|
||
- generic [ref=f12e129]:
|
||
- generic [ref=f12e130]:
|
||
- generic [ref=f12e131]: 업로드 종류
|
||
- combobox "업로드 종류" [ref=f12e132]:
|
||
- option "이미지" [selected]
|
||
- option "다이어그램"
|
||
- option "첨부파일"
|
||
- button "Asset 업로드" [ref=f12e133]
|
||
- generic [ref=f12e134]:
|
||
- search [ref=f12e135]:
|
||
- generic [ref=f12e136]: Asset 검색
|
||
- generic [ref=f12e137]:
|
||
- searchbox "Asset 검색" [ref=f12e138]
|
||
- button "검색" [ref=f12e139]
|
||
- generic [ref=f12e140]:
|
||
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f12e141]
|
||
- generic [ref=f12e142]: 삽입할 때 크게 보기 허용
|
||
- status [ref=f12e143]: 삽입할 수 있는 Asset 24개
|
||
- list [ref=f12e144]:
|
||
- listitem [ref=f12e145]:
|
||
- button "ap4-edge-trust-architecture-1a916e10" [ref=f12e146]
|
||
- button "삭제" [ref=f12e147]
|
||
- listitem [ref=f12e148]:
|
||
- button "ap3-bff-session-flow-1b005e15" [ref=f12e149]
|
||
- button "삭제" [ref=f12e150]
|
||
- listitem [ref=f12e151]:
|
||
- button "ap3-bff-architecture-a27ea91c" [ref=f12e152]
|
||
- button "삭제" [ref=f12e153]
|
||
- listitem [ref=f12e154]:
|
||
- button "ap2-mediator-handoff-flow-efe7039c" [ref=f12e155]
|
||
- button "삭제" [ref=f12e156]
|
||
- listitem [ref=f12e157]:
|
||
- button "ap2-mediator-architecture-c95ed25f" [ref=f12e158]
|
||
- button "삭제" [ref=f12e159]
|
||
- listitem [ref=f12e160]:
|
||
- button "projection-row-over-fetch-f2b1943b" [ref=f12e161]
|
||
- button "삭제" [ref=f12e162]
|
||
- listitem [ref=f12e163]:
|
||
- button "cartesian-row-multiplication-dce2e166" [ref=f12e164]
|
||
- button "삭제" [ref=f12e165]
|
||
- listitem [ref=f12e166]:
|
||
- button "eager-lazy-query-sequence-47c12bda" [ref=f12e167]
|
||
- button "삭제" [ref=f12e168]
|
||
- listitem [ref=f12e169]:
|
||
- button "ap3-bff-session-flow-a8dfff6f" [ref=f12e170]
|
||
- button "삭제" [ref=f12e171]
|
||
- listitem [ref=f12e172]:
|
||
- button "ap2-mediator-handoff-flow-8c2a6f8f" [ref=f12e173]
|
||
- button "삭제" [ref=f12e174]
|
||
- listitem [ref=f12e175]:
|
||
- button "ap4-edge-forward-auth-flow-a6ec423a" [ref=f12e176]
|
||
- button "삭제" [ref=f12e177]
|
||
- listitem [ref=f12e178]:
|
||
- button "ap3-csrf-boundary-971df81c" [ref=f12e179]
|
||
- button "삭제" [ref=f12e180]
|
||
- listitem [ref=f12e181]:
|
||
- button "login-api-phase-split-3e354274" [ref=f12e182]
|
||
- button "삭제" [ref=f12e183]
|
||
- listitem [ref=f12e184]:
|
||
- button "ap1-browser-bearer-flow-a7f8aa9e" [ref=f12e185]
|
||
- button "삭제" [ref=f12e186]
|
||
- listitem [ref=f12e187]:
|
||
- button "ap1-direct-architecture-0adf4199" [ref=f12e188]
|
||
- button "삭제" [ref=f12e189]
|
||
- listitem [ref=f12e190]:
|
||
- button "nplus1-query-fanout-644febe6" [ref=f12e191]
|
||
- button "삭제" [ref=f12e192]
|
||
- listitem [ref=f12e193]:
|
||
- button "ap4-edge-trust-1cff2399" [ref=f12e194]
|
||
- button "삭제" [ref=f12e195]
|
||
- listitem [ref=f12e196]:
|
||
- button "ap3-csrf-split-501dd1f7" [ref=f12e197]
|
||
- button "삭제" [ref=f12e198]
|
||
- listitem [ref=f12e199]:
|
||
- button "ap3-bff-custody-82fa18bd" [ref=f12e200]
|
||
- button "삭제" [ref=f12e201]
|
||
- listitem [ref=f12e202]:
|
||
- button "ap2-split-custody-779cb791" [ref=f12e203]
|
||
- button "삭제" [ref=f12e204]
|
||
- listitem [ref=f12e205]:
|
||
- button "ap1-custody-v3-6e0376d2" [ref=f12e206]
|
||
- button "삭제" [ref=f12e207]
|
||
- listitem [ref=f12e208]:
|
||
- button "ap1-custody-v2-e110bd98" [ref=f12e209]
|
||
- button "삭제" [ref=f12e210]
|
||
- listitem [ref=f12e211]:
|
||
- button "ap1-credential-custody-f5e0c027" [ref=f12e212]
|
||
- button "삭제" [ref=f12e213]
|
||
- listitem [ref=f12e214]:
|
||
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f12e215]
|
||
- button "삭제" [ref=f12e216]
|
||
- region [ref=f12e217]:
|
||
- generic [ref=f12e218]:
|
||
- paragraph [ref=f12e219]: LIVE
|
||
- heading "즉시 미리보기" [level=2] [ref=f12e220]
|
||
- generic [ref=f12e223]:
|
||
- generic [ref=f12e224]:
|
||
- navigation "문서 경로" [ref=f12e225]:
|
||
- link "검증 기록" [ref=f12e226] [cursor=pointer]:
|
||
- /url: /explore/cases
|
||
- generic [aria-hidden] [ref=f12e227]: /
|
||
- generic [ref=f12e228]: JPA 피드 조회 성능
|
||
- generic [aria-hidden] [ref=f12e229]: /
|
||
- link "Liner N + 1문제" [ref=f12e230] [cursor=pointer]:
|
||
- /url: /projects/liner-n-plus-1
|
||
- heading "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [level=1] [ref=f12e231]
|
||
- paragraph [ref=f12e232]:
|
||
- text: 추가 쿼리를 줄이기 위해 필요한 연관 데이터를
|
||
- code [ref=f12e233]: fetch join
|
||
- text: 으로 한 번에 조회했다. 하지만 두 컬렉션을 동시에
|
||
- code [ref=f12e234]: fetch join
|
||
- text: 하자
|
||
- code [ref=f12e235]: MultipleBagFetchException
|
||
- text: 이 발생했다.
|
||
- paragraph [ref=f12e236]:
|
||
- text: 컬렉션 하나만
|
||
- code [ref=f12e237]: fetch join
|
||
- text: 했을 때는 쿼리 수가 줄었지만, 부모와 자식이 조인되면서 조회되는 행 수가 크게 늘었다. 실제 전송 행 수도 생성한 Highlight의 전체 개수만큼 증가했다.
|
||
- paragraph [ref=f12e238]:
|
||
- code [ref=f12e239]: fetch join
|
||
- text: 으로 쿼리 수는 줄일 수 있었지만, 그만큼 DB에서 읽고 애플리케이션에서 처리해야 하는 데이터와 메모리 사용량이 증가했다.
|
||
- region "문제와 결론" [ref=f12e240]:
|
||
- generic [ref=f12e241]:
|
||
- paragraph [ref=f12e242]: 문제
|
||
- paragraph [ref=f12e243]:
|
||
- code [ref=f12e244]: "@OneToMany"
|
||
- text: 과
|
||
- code [ref=f12e245]: "@ManyToOne"
|
||
- text: 에서 발생하는 추가 쿼리를 확인한 뒤, 먼저 컬렉션에 대해서
|
||
- code [ref=f12e246]: highlights
|
||
- text: ","
|
||
- code [ref=f12e247]: mentions
|
||
- text: 를 모두
|
||
- code [ref=f12e248]: fetch join
|
||
- text: 해 한 번의 쿼리로 조회해 보았다.
|
||
- paragraph [ref=f12e249]: mentions은 user와 feedItem의 다대다의 관계를 1대다와 다대1의 관계로 풀어내면서 나온 컬렉션이다.
|
||
- generic [ref=f12e250]:
|
||
- paragraph [ref=f12e251]: 결론
|
||
- paragraph [ref=f12e252]:
|
||
- text: 두
|
||
- code [ref=f12e253]: List
|
||
- text: 컬렉션을 동시에
|
||
- code [ref=f12e254]: fetch join
|
||
- text: 하면
|
||
- code [ref=f12e255]: MultipleBagFetchException
|
||
- text: 이 발생했다. Hibernate는 순서 컬럼이 없는 두
|
||
- code [ref=f12e256]: List
|
||
- text: 가 조인되면서
|
||
- code [ref=f12e257]: highlights × mentions
|
||
- text: 형태로 행이 늘어날 경우, 이 결과를 원래 두 컬렉션으로 정확하게 구성할 수 없기 때문에 쿼리 실행 전에 이를 막는다. 실제 데이터가 없는 상태에서도 같은 예외가 발생했다.
|
||
- paragraph [ref=f12e258]:
|
||
- code [ref=f12e259]: highlights
|
||
- text: 하나만
|
||
- code [ref=f12e260]: fetch join
|
||
- text: 하면 예외는 발생하지 않았다. 대신 부모인 Feed Item이 Highlight 수만큼 반복되면서 DB에서 전달되는 행 수가 늘어났다.
|
||
- paragraph [ref=f12e261]:
|
||
- text: Hibernate 6에서는
|
||
- code [ref=f12e262]: fetch join
|
||
- text: 결과의 루트 엔티티 중복을 제거하기 때문에 최종 목록에는 Feed Item이 N개만 남는다. 따라서 반환된 목록의 크기만 보면 조인으로 행이 얼마나 늘어났는지 알 수 없다.
|
||
- paragraph [ref=f12e263]:
|
||
- text: N=100에서는 전체 쿼리가 222개에서 121개로 줄었다. 하지만 120개는 여전히
|
||
- code [ref=f12e264]: User
|
||
- text: 와
|
||
- code [ref=f12e265]: Page
|
||
- text: 를 조회하는 추가 쿼리였고,
|
||
- code [ref=f12e266]: highlights
|
||
- text: 를 가져오는 하나의 조인 쿼리는 1,961행을 전달했다. 즉, 쿼리 수는 줄었지만 실제로 처리하는 데이터까지 같이 줄어든 건 아니었다.
|
||
- generic [ref=f12e267]:
|
||
- generic [ref=f12e268]:
|
||
- term [ref=f12e269]: 검증 환경
|
||
- definition [ref=f12e270]:
|
||
- paragraph [ref=f12e271]: "Java 21Spring Boot 4.0.0Hibernate ORM 7.1.8.FinalPostgreSQL : postgres:16-alpine (Testcontainers)Query : JPQLUnique Constraint : (feed_item_id, mentioned_user_id)"
|
||
- generic [ref=f12e272]:
|
||
- term [ref=f12e273]: 검증 데이터
|
||
- definition [ref=f12e274]:
|
||
- paragraph [ref=f12e275]: 1. highlights와 mentions를 동시에 join fetch하는 JPQL을 실행해 MultipleBagFetchException이 발생하는지 확인한다.
|
||
- paragraph [ref=f12e276]: 2. highlights만 fetch join한 뒤 FeedItem을 각각 10개, 100개, 1000개로 늘려가면서 조회한다.
|
||
- paragraph [ref=f12e277]: 3. Hibernete가 반환한 FeedItem 수와 실제 조인으로 만들어진 행의 수를 각각 비교해본다.
|
||
- paragraph [ref=f12e278]: 4. 같은 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 실행해 DB에서 실제로 처리한 행 수를 확인한다.
|
||
- paragraph [ref=f12e279]: 5. 기존 테스트를 다시 실행해 변경 전 동작이 유지되는지 확인, highlights는 접근할 때 Feed Item 수만큼 조회되고, 접근하지 않으면 추가 조회는 없어야 한다.
|
||
- generic [ref=f12e280]:
|
||
- term [ref=f12e281]: 기록
|
||
- definition [ref=f12e282]: 게시 2026.09.01 · 마지막 검증 2026.09.01
|
||
- group [ref=f12e284]:
|
||
- generic "목차 · 두 컬렉션을 동시 fetch join" [ref=f12e285] [cursor=pointer]
|
||
- article [ref=f12e287]:
|
||
- region [ref=f12e288]:
|
||
- heading [level=2] [ref=f12e289]:
|
||
- link "두 컬렉션을 동시 fetch join 바로가기" [ref=f12e290] [cursor=pointer]:
|
||
- /url: "#두-컬렉션을-동시-fetch-join"
|
||
- text: 두 컬렉션을 동시 fetch join
|
||
- generic [aria-hidden] [ref=f12e291]: "#"
|
||
- figure "JAVA ·컬렉션 둘을 같이 fetch join 코드 복사" [ref=f12e292]:
|
||
- generic [ref=f12e293]:
|
||
- generic [ref=f12e294]: JAVA
|
||
- generic [ref=f12e295]: ·컬렉션 둘을 같이 fetch join
|
||
- button "코드 복사" [ref=f12e296] [cursor=pointer]: 복사
|
||
- region "컬렉션 둘을 같이 fetch join 코드" [ref=f12e297]:
|
||
- code [ref=f12e298]: select distinct f from FeedItemJpaEntity f join fetch f.highlights join fetch f.mentions
|
||
- figure "TEXT ·예외 원인 코드 복사" [ref=f12e300]:
|
||
- generic [ref=f12e301]:
|
||
- generic [ref=f12e302]: TEXT
|
||
- generic [ref=f12e303]: ·예외 원인
|
||
- button "코드 복사" [ref=f12e304] [cursor=pointer]: 복사
|
||
- region "예외 원인 코드" [ref=f12e305]:
|
||
- code [ref=f12e306]: java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchException
|
||
- paragraph [ref=f12e308]: MultipleBagFetchException은 직접 발생하지 않았고 IllegalArgumentException에 감싸진 상태로 전달됐다.전체 예외의 원인을 따라가면서 어떤 예외인지 확인했고 MultipleBagFetchException 해당 예외가 발생하는 것을 확인할 수 있었다.
|
||
- region [ref=f12e309]:
|
||
- heading [level=2] [ref=f12e310]:
|
||
- link "한 컬렉션만 fetch join 바로가기" [ref=f12e311] [cursor=pointer]:
|
||
- /url: "#한-컬렉션만-fetch-join"
|
||
- text: 한 컬렉션만 fetch join
|
||
- generic [aria-hidden] [ref=f12e312]: "#"
|
||
- figure [ref=f12e313]:
|
||
- button "cartesian-row-multiplication-dce2e166 이미지 크게 보기" [ref=f12e314]:
|
||
- img "feed_items와 highlights가 fetch join으로 합쳐져 전송 조인 행이 되고, 그 행이 결과 리스트로 갈 때만 루트 엔티티가 중복 제거되는 흐름." [ref=f12e315]
|
||
- generic [ref=f12e316]: 크게 보기
|
||
- generic [ref=f12e317]: feed_items와 highlights가 fetch join으로 합쳐져 전송 조인 행이 되고, 그 행이 결과 리스트로 갈 때만 루트 엔티티가 중복 제거되는 흐름.
|
||
- region "표" [ref=f12e318]:
|
||
- table [ref=f12e319]:
|
||
- caption [ref=f12e320]
|
||
- rowgroup [ref=f12e321]:
|
||
- row [ref=f12e322]:
|
||
- columnheader "FeedItem 수" [ref=f12e323]
|
||
- columnheader "조인된 행 수" [ref=f12e324]
|
||
- columnheader "반환된 Feed Item 수" [ref=f12e325]
|
||
- columnheader "Highlight 수" [ref=f12e326]
|
||
- columnheader "FeedItem 대비 조인 행 수" [ref=f12e327]
|
||
- rowgroup [ref=f12e328]:
|
||
- row [ref=f12e329]:
|
||
- cell "10" [ref=f12e330]
|
||
- cell "1,285" [ref=f12e331]
|
||
- cell "10" [ref=f12e332]
|
||
- cell "1,285" [ref=f12e333]
|
||
- cell "128.5×" [ref=f12e334]
|
||
- row [ref=f12e335]:
|
||
- cell "100" [ref=f12e336]
|
||
- cell "1,961" [ref=f12e337]
|
||
- cell "100" [ref=f12e338]
|
||
- cell "1,961" [ref=f12e339]
|
||
- cell "19.6×" [ref=f12e340]
|
||
- row [ref=f12e341]:
|
||
- cell "1,000" [ref=f12e342]
|
||
- cell "2,917" [ref=f12e343]
|
||
- cell "1,000" [ref=f12e344]
|
||
- cell "2,917" [ref=f12e345]
|
||
- cell "2.9×" [ref=f12e346]
|
||
- paragraph [ref=f12e347]: highlights를 fetch join하자 조인된 행 수는 Highlight의 전체 개수와 같았다.FeedItem 하나에 Highlight가 여러 개 있으면 같은 FeedItem이 Highlight 수만큼 반복되기 때문이다.
|
||
- paragraph [ref=f12e348]: Hibernate는 중복된 Feed Item을 제거해 최종 목록에는 각각 10개, 100개, 1000개만 반환했다.하지만 DB에서 만들어지는 조인 결과까지 줄어드는 건 아니었다.
|
||
- paragraph [ref=f12e349]: FeedItem이 늘어나면서 조인된 행 수는 1285개에서 2917개까지 계속 증가했다.
|
||
- region [ref=f12e350]:
|
||
- heading [level=2] [ref=f12e351]:
|
||
- link "쿼리 수만 보면 개선처럼 보인다 바로가기" [ref=f12e352] [cursor=pointer]:
|
||
- /url: "#쿼리-수만-보면-개선처럼-보인다"
|
||
- text: 쿼리 수만 보면 개선처럼 보인다
|
||
- generic [aria-hidden] [ref=f12e353]: "#"
|
||
- region "표" [ref=f12e354]:
|
||
- table [ref=f12e355]:
|
||
- caption [ref=f12e356]
|
||
- rowgroup [ref=f12e357]:
|
||
- row [ref=f12e358]:
|
||
- columnheader "구분" [ref=f12e359]
|
||
- columnheader "기존 조회" [ref=f12e360]
|
||
- columnheader "highlights fetch join" [ref=f12e361]
|
||
- columnheader "변화" [ref=f12e362]
|
||
- rowgroup [ref=f12e363]:
|
||
- row [ref=f12e364]:
|
||
- cell "Feed Item 조회" [ref=f12e365]
|
||
- cell "1" [ref=f12e366]
|
||
- cell "1" [ref=f12e367]
|
||
- cell "highlights를 같이 조회" [ref=f12e368]
|
||
- row [ref=f12e369]:
|
||
- cell "count 조회" [ref=f12e370]
|
||
- cell "1" [ref=f12e371]
|
||
- cell "0" [ref=f12e372]
|
||
- cell "JPQL로 조회" [ref=f12e373]
|
||
- row [ref=f12e374]:
|
||
- cell "Highlight 조회" [ref=f12e375]
|
||
- cell "100" [ref=f12e376]
|
||
- cell "0" [ref=f12e377]
|
||
- cell "개별 조회 제거" [ref=f12e378]
|
||
- row [ref=f12e379]:
|
||
- cell "User+Page 조회" [ref=f12e380]
|
||
- cell "120" [ref=f12e381]
|
||
- cell "120" [ref=f12e382]
|
||
- cell "변화 없음" [ref=f12e383]
|
||
- row [ref=f12e384]:
|
||
- cell "전체" [ref=f12e385]
|
||
- cell "222" [ref=f12e386]
|
||
- cell "121" [ref=f12e387]
|
||
- cell "101개 감소" [ref=f12e388]
|
||
- paragraph [ref=f12e389]: 전체 쿼리는 222개가 121개로 줄었다. 하지만 User 와 Page를 조회하는 120개의 쿼리는 그대로 남아있어서 n+1은 highlights만 제거된 상태다.
|
||
- region [ref=f12e390]:
|
||
- heading [level=2] [ref=f12e391]:
|
||
- link "조인이 행을 곱하는 것을 실행계획 바로가기" [ref=f12e392] [cursor=pointer]:
|
||
- /url: "#조인이-행을-곱하는-것을-실행계획"
|
||
- text: 조인이 행을 곱하는 것을 실행계획
|
||
- generic [aria-hidden] [ref=f12e393]: "#"
|
||
- figure "TEXT ·N=100일 때, fetch join EXPLAIN 코드 복사" [ref=f12e394]:
|
||
- generic [ref=f12e395]:
|
||
- generic [ref=f12e396]: TEXT
|
||
- generic [ref=f12e397]: ·N=100일 때, fetch join EXPLAIN
|
||
- button "코드 복사" [ref=f12e398] [cursor=pointer]: 복사
|
||
- region "N=100일 때, fetch join EXPLAIN 코드" [ref=f12e399]:
|
||
- code [ref=f12e400]: "Hash Join (cost=77.18..512.34 rows=4202 width=32) (actual time=0.589..0.894 rows=1961 loops=1) Hash Cond: (h.feed_item_id = fi.id) -> 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) Execution Time: 0.959 ms"
|
||
- paragraph [ref=f12e402]: Feed Item은 100개가 반환되지만 실행계획에서 조인 결과는 1,961행이었다.쿼리는 한 번만 실행됐지만, 각 Feed Item이 Highlight 수만큼 반복되면서 실제로 처리하고 전달한 행은 훨씬 많았다.
|
||
- paragraph [ref=f12e403]: Hibernate가 중복된 Feed Item을 제거해 최종 목록에는 100개만 남기 때문에 반환된 목록 크기만으로는 실제로 반환되는 행의 수를 알 수 없다.
|
||
- paragraph [ref=f12e404]: 실행계획의 예상 행 수는 4,202행이었지만 실제로는 1,961행이었다.
|
||
- region [ref=f12e405]:
|
||
- paragraph [ref=f12e406]: Next
|
||
- heading "다음에 읽을 것" [level=2] [ref=f12e407]
|
||
- list [ref=f12e408]:
|
||
- listitem [ref=f12e409]:
|
||
- link "검증 기록 Collection Fetch Join Pagination의 In-memory Paging" [ref=f12e410] [cursor=pointer]:
|
||
- /url: /cases/collection-fetch-join-in-memory-paging
|
||
- generic [ref=f12e411]: 검증 기록
|
||
- generic [ref=f12e412]:
|
||
- strong [ref=f12e413]: Collection Fetch Join Pagination의 In-memory Paging
|
||
- paragraph [aria-hidden] [ref=f12e414]: 한 bag만 fetch join한 상태에서 페이징을 적용한 다음 기록이다.
|
||
- generic [aria-hidden] [ref=f12e415]: ↗
|
||
- listitem [ref=f12e416]:
|
||
- link "검증 기록 Fetch 타입이 아닌 조회 방식으로 인한 N+1" [ref=f12e417] [cursor=pointer]:
|
||
- /url: /cases/eager-toone-nplus1-without-access
|
||
- generic [ref=f12e418]: 검증 기록
|
||
- generic [ref=f12e419]:
|
||
- strong [ref=f12e420]: Fetch 타입이 아닌 조회 방식으로 인한 N+1
|
||
- paragraph [aria-hidden] [ref=f12e421]: 이 시도가 풀려던 문제다.
|
||
- generic [aria-hidden] [ref=f12e422]: ↗
|
||
- complementary [ref=f12e423]:
|
||
- heading "작업 상태" [level=2] [ref=f12e424]
|
||
- status "편집 상태" [ref=f12e425]: 저장됨
|
||
- generic [ref=f12e426]:
|
||
- generic [ref=f12e427]:
|
||
- term [ref=f12e428]: 저장 버전
|
||
- definition [ref=f12e429]: "36"
|
||
- generic [ref=f12e430]:
|
||
- term [ref=f12e431]: 종류
|
||
- definition [ref=f12e432]: 검증 기록
|
||
- paragraph [ref=f12e433]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
|
||
- generic [ref=f12e434]:
|
||
- button "저장" [disabled] [ref=f12e435]
|
||
- button "게시" [ref=f12e436]
|
||
- paragraph [ref=f12e437]: 버전 36으로 저장했습니다. |