579 lines
46 KiB
YAML
579 lines
46 KiB
YAML
- generic [ref=f24e3]:
|
||
- link "본문으로 건너뛰기" [ref=f24e4] [cursor=pointer]:
|
||
- /url: "#main-content"
|
||
- banner [ref=f24e5]:
|
||
- generic [ref=f24e6]:
|
||
- link "TechLog Studio" [ref=f24e7] [cursor=pointer]:
|
||
- /url: /studio
|
||
- text: TechLog
|
||
- generic [ref=f24e8]: Studio
|
||
- navigation "Studio 주 탐색" [ref=f24e10]:
|
||
- link "작업본" [ref=f24e11] [cursor=pointer]:
|
||
- /url: /studio/documents
|
||
- link "게시 기록" [ref=f24e12] [cursor=pointer]:
|
||
- /url: /studio/publications
|
||
- link "새 문서" [ref=f24e13] [cursor=pointer]:
|
||
- /url: /studio/documents/new
|
||
- link "주제·프로젝트" [ref=f24e14] [cursor=pointer]:
|
||
- /url: /studio/taxonomy
|
||
- link "릴리즈" [ref=f24e15] [cursor=pointer]:
|
||
- /url: /studio/releases
|
||
- link "공개 사이트 보기" [ref=f24e16] [cursor=pointer]:
|
||
- /url: /
|
||
- button "로그아웃" [ref=f24e17]
|
||
- main [ref=f24e18]:
|
||
- generic [ref=f24e19]:
|
||
- generic [ref=f24e20]:
|
||
- region [ref=f24e21]:
|
||
- generic [ref=f24e22]:
|
||
- paragraph [ref=f24e23]: CASE · VERSION 34
|
||
- heading "문서 편집" [level=1] [ref=f24e24]
|
||
- paragraph [ref=f24e25]: Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제
|
||
- region [ref=f24e26]:
|
||
- generic [ref=f24e27]:
|
||
- paragraph [ref=f24e28]: DOCUMENT
|
||
- heading "기본 정보" [level=2] [ref=f24e29]
|
||
- generic [ref=f24e30]:
|
||
- generic [ref=f24e31]:
|
||
- generic [ref=f24e32]: 제목
|
||
- textbox "제목" [ref=f24e33]: Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제
|
||
- generic [ref=f24e34]:
|
||
- generic [ref=f24e35]: slug
|
||
- textbox "slug" [ref=f24e36]:
|
||
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
|
||
- text: fetch-join-multibag-and-row-explosion
|
||
- generic [ref=f24e37]:
|
||
- generic [ref=f24e38]: 요약
|
||
- textbox "요약" [ref=f24e39]: "추가 쿼리를 줄이기 위해 필요한 연관 데이터를 `fetch join`으로 한 번에 조회했다. 하지만 두 컬렉션을 동시에 `fetch join`하자 `MultipleBagFetchException`이 발생했다. 컬렉션 하나만 `fetch join`했을 때는 쿼리 수가 줄었지만, 부모와 자식이 조인되면서 조회되는 행 수가 크게 늘었다. 실제 전송 행 수도 생성한 Highlight의 전체 개수만큼 증가했다. `fetch join`으로 쿼리 수는 줄일 수 있었지만, 그만큼 DB에서 읽고 애플리케이션에서 처리해야 하는 데이터와 메모리 사용량이 증가했다."
|
||
- generic [aria-hidden] [ref=f24e40]: 목록 카드에는 약 90자까지 보입니다 · 312 / 2000
|
||
- generic [ref=f24e41]:
|
||
- generic [ref=f24e42]: Topic
|
||
- combobox "Topic" [ref=f24e43]:
|
||
- option "선택하지 않음"
|
||
- option "JPA 피드 조회 성능" [selected]
|
||
- option "OAuth/OIDC 인증 경계"
|
||
- generic [ref=f24e44]:
|
||
- generic [ref=f24e45]: Project
|
||
- combobox "Project" [ref=f24e46]:
|
||
- option "미지정"
|
||
- option "Backend Clean Architecture"
|
||
- option "KeyCloak Patterns"
|
||
- option "Liner N + 1문제" [selected]
|
||
- status [ref=f24e47]
|
||
- group "축 — 고르지 않으면 이 주제의 공통 기록이 됩니다" [ref=f24e48]:
|
||
- generic [ref=f24e50] [cursor=pointer]:
|
||
- checkbox "파생 쿼리 그대로" [ref=f24e51]
|
||
- generic [ref=f24e52]: 파생 쿼리 그대로
|
||
- generic [ref=f24e53] [cursor=pointer]:
|
||
- checkbox "컬렉션 fetch join" [checked] [ref=f24e54]
|
||
- generic [ref=f24e55]: 컬렉션 fetch join
|
||
- generic [ref=f24e56] [cursor=pointer]:
|
||
- checkbox "fetch join + 페이징" [ref=f24e57]
|
||
- generic [ref=f24e58]: fetch join + 페이징
|
||
- group "관계" [ref=f24e59]:
|
||
- generic [ref=f24e61]:
|
||
- generic [ref=f24e62]:
|
||
- generic [ref=f24e63]: 관계 1 대상
|
||
- combobox "관계 1 대상" [ref=f24e64]:
|
||
- option "대상 선택"
|
||
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
|
||
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
|
||
- option "Collection Fetch Join Pagination의 In-memory Paging" [disabled]
|
||
- option "DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1"
|
||
- 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=f24e65]:
|
||
- generic [ref=f24e66]: 관계 1 이유
|
||
- textbox "관계 1 이유" [ref=f24e67]: 이 실패에서 나온 선택 기준이다.
|
||
- generic [aria-hidden] [ref=f24e68]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||
- generic [ref=f24e69]:
|
||
- button "위로" [disabled] [ref=f24e70]
|
||
- button "아래로" [ref=f24e71]
|
||
- button "삭제" [ref=f24e72]
|
||
- generic [ref=f24e73]:
|
||
- generic [ref=f24e74]:
|
||
- generic [ref=f24e75]: 관계 2 대상
|
||
- combobox "관계 2 대상" [ref=f24e76]:
|
||
- option "대상 선택"
|
||
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
|
||
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
|
||
- option "Collection Fetch Join Pagination의 In-memory Paging" [selected]
|
||
- option "DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1"
|
||
- 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=f24e77]:
|
||
- generic [ref=f24e78]: 관계 2 이유
|
||
- textbox "관계 2 이유" [ref=f24e79]: 한 bag만 fetch join한 상태에서 페이징을 적용한 다음 기록이다.
|
||
- generic [aria-hidden] [ref=f24e80]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||
- generic [ref=f24e81]:
|
||
- button "위로" [ref=f24e82]
|
||
- button "아래로" [ref=f24e83]
|
||
- button "삭제" [ref=f24e84]
|
||
- generic [ref=f24e85]:
|
||
- generic [ref=f24e86]:
|
||
- generic [ref=f24e87]: 관계 3 대상
|
||
- combobox "관계 3 대상" [ref=f24e88]:
|
||
- option "대상 선택"
|
||
- option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정"
|
||
- option "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제"
|
||
- option "Collection Fetch Join Pagination의 In-memory Paging" [disabled]
|
||
- option "DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1"
|
||
- 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=f24e89]:
|
||
- generic [ref=f24e90]: 관계 3 이유
|
||
- textbox [ref=f24e91]
|
||
- generic [aria-hidden] [ref=f24e92]: 공개 화면의 「다음에 읽을 것」에 그대로 나갑니다.
|
||
- generic [ref=f24e93]:
|
||
- button "위로" [ref=f24e94]
|
||
- button "아래로" [disabled] [ref=f24e95]
|
||
- button "삭제" [ref=f24e96]
|
||
- button "관계 추가" [ref=f24e97]
|
||
- region [ref=f24e98]:
|
||
- generic [ref=f24e99]:
|
||
- paragraph [ref=f24e100]: CASE
|
||
- heading "문제와 검증" [level=2] [ref=f24e101]
|
||
- generic [ref=f24e102]:
|
||
- generic [ref=f24e103]:
|
||
- generic [ref=f24e104]: 문제
|
||
- textbox "문제" [ref=f24e105]: "`@OneToMany`과 `@ManyToOne`에서 발생하는 추가 쿼리를 확인한 뒤, 먼저 컬렉션에 대해서 `highlights`, `mentions`를 모두 `fetch join`해 한 번의 쿼리로 조회해 보았다. mentions은 user와 feedItem의 다대다의 관계를 1대다와 다대1의 관계로 풀어내면서 나온 컬렉션이다."
|
||
- generic [ref=f24e106]:
|
||
- generic [ref=f24e107]: 결론
|
||
- textbox "결론" [ref=f24e108]: "두 `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=f24e109]:
|
||
- generic [ref=f24e110]: 검증 환경
|
||
- textbox "검증 환경" [ref=f24e111]: "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=f24e112]:
|
||
- generic [ref=f24e113]: 재현 조건
|
||
- textbox "재현 조건" [ref=f24e114]: 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=f24e115]:
|
||
- generic [ref=f24e116]: 마지막 검증일
|
||
- textbox "마지막 검증일" [ref=f24e117]: 2026-09-01
|
||
- generic [ref=f24e118]:
|
||
- generic [ref=f24e119]: 본문 Markdown
|
||
- group "Markdown 삽입" [ref=f24e120]:
|
||
- button "코드" [ref=f24e121] [cursor=pointer]
|
||
- button "표" [ref=f24e122] [cursor=pointer]
|
||
- button "목록" [ref=f24e123] [cursor=pointer]
|
||
- textbox "본문 Markdown" [ref=f24e124]: "## 두 컬렉션을 동시 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 | 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=f24e125]:
|
||
- paragraph [ref=f24e126]: EVIDENCE
|
||
- heading "본문에 Asset 삽입" [level=3] [ref=f24e127]
|
||
- paragraph [ref=f24e128]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
|
||
- generic [ref=f24e129]:
|
||
- generic [ref=f24e130]:
|
||
- generic [ref=f24e131]: 업로드 종류
|
||
- combobox "업로드 종류" [ref=f24e132]:
|
||
- option "이미지" [selected]
|
||
- option "다이어그램"
|
||
- option "첨부파일"
|
||
- button "Asset 업로드" [ref=f24e133]
|
||
- generic [ref=f24e134]:
|
||
- search [ref=f24e135]:
|
||
- generic [ref=f24e136]: Asset 검색
|
||
- generic [ref=f24e137]:
|
||
- searchbox "Asset 검색" [ref=f24e138]
|
||
- button "검색" [ref=f24e139]
|
||
- generic [ref=f24e140]:
|
||
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f24e141]
|
||
- generic [ref=f24e142]: 삽입할 때 크게 보기 허용
|
||
- status [ref=f24e143]: 삽입할 수 있는 Asset 9개
|
||
- list [ref=f24e144]:
|
||
- listitem [ref=f24e145]:
|
||
- button "nplus1-query-fanout-644febe6" [ref=f24e146]
|
||
- button "삭제" [ref=f24e147]
|
||
- listitem [ref=f24e148]:
|
||
- button "ap4-edge-trust-1cff2399" [ref=f24e149]
|
||
- button "삭제" [ref=f24e150]
|
||
- listitem [ref=f24e151]:
|
||
- button "ap3-csrf-split-501dd1f7" [ref=f24e152]
|
||
- button "삭제" [ref=f24e153]
|
||
- listitem [ref=f24e154]:
|
||
- button "ap3-bff-custody-82fa18bd" [ref=f24e155]
|
||
- button "삭제" [ref=f24e156]
|
||
- listitem [ref=f24e157]:
|
||
- button "ap2-split-custody-779cb791" [ref=f24e158]
|
||
- button "삭제" [ref=f24e159]
|
||
- listitem [ref=f24e160]:
|
||
- button "ap1-custody-v3-6e0376d2" [ref=f24e161]
|
||
- button "삭제" [ref=f24e162]
|
||
- listitem [ref=f24e163]:
|
||
- button "ap1-custody-v2-e110bd98" [ref=f24e164]
|
||
- button "삭제" [ref=f24e165]
|
||
- listitem [ref=f24e166]:
|
||
- button "ap1-credential-custody-f5e0c027" [ref=f24e167]
|
||
- button "삭제" [ref=f24e168]
|
||
- listitem [ref=f24e169]:
|
||
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f24e170]
|
||
- button "삭제" [ref=f24e171]
|
||
- region [ref=f24e172]:
|
||
- generic [ref=f24e173]:
|
||
- paragraph [ref=f24e174]: LIVE
|
||
- heading "즉시 미리보기" [level=2] [ref=f24e175]
|
||
- generic [ref=f24e178]:
|
||
- generic [ref=f24e179]:
|
||
- navigation "문서 경로" [ref=f24e180]:
|
||
- link "검증 기록" [ref=f24e181] [cursor=pointer]:
|
||
- /url: /explore/cases
|
||
- generic [aria-hidden] [ref=f24e182]: /
|
||
- generic [ref=f24e183]: JPA 피드 조회 성능
|
||
- generic [aria-hidden] [ref=f24e184]: /
|
||
- link "Liner N + 1문제" [ref=f24e185] [cursor=pointer]:
|
||
- /url: /projects/liner-n-plus-1
|
||
- heading "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [level=1] [ref=f24e186]
|
||
- paragraph [ref=f24e187]:
|
||
- text: 추가 쿼리를 줄이기 위해 필요한 연관 데이터를
|
||
- code [ref=f24e188]: fetch join
|
||
- text: 으로 한 번에 조회했다. 하지만 두 컬렉션을 동시에
|
||
- code [ref=f24e189]: fetch join
|
||
- text: 하자
|
||
- code [ref=f24e190]: MultipleBagFetchException
|
||
- text: 이 발생했다.
|
||
- paragraph [ref=f24e191]:
|
||
- text: 컬렉션 하나만
|
||
- code [ref=f24e192]: fetch join
|
||
- text: 했을 때는 쿼리 수가 줄었지만, 부모와 자식이 조인되면서 조회되는 행 수가 크게 늘었다. 실제 전송 행 수도 생성한 Highlight의 전체 개수만큼 증가했다.
|
||
- paragraph [ref=f24e193]:
|
||
- code [ref=f24e194]: fetch join
|
||
- text: 으로 쿼리 수는 줄일 수 있었지만, 그만큼 DB에서 읽고 애플리케이션에서 처리해야 하는 데이터와 메모리 사용량이 증가했다.
|
||
- region "문제와 결론" [ref=f24e195]:
|
||
- generic [ref=f24e196]:
|
||
- paragraph [ref=f24e197]: 문제
|
||
- paragraph [ref=f24e198]:
|
||
- code [ref=f24e199]: "@OneToMany"
|
||
- text: 과
|
||
- code [ref=f24e200]: "@ManyToOne"
|
||
- text: 에서 발생하는 추가 쿼리를 확인한 뒤, 먼저 컬렉션에 대해서
|
||
- code [ref=f24e201]: highlights
|
||
- text: ","
|
||
- code [ref=f24e202]: mentions
|
||
- text: 를 모두
|
||
- code [ref=f24e203]: fetch join
|
||
- text: 해 한 번의 쿼리로 조회해 보았다.
|
||
- paragraph [ref=f24e204]: mentions은 user와 feedItem의 다대다의 관계를 1대다와 다대1의 관계로 풀어내면서 나온 컬렉션이다.
|
||
- generic [ref=f24e205]:
|
||
- paragraph [ref=f24e206]: 결론
|
||
- paragraph [ref=f24e207]:
|
||
- text: 두
|
||
- code [ref=f24e208]: List
|
||
- text: 컬렉션을 동시에
|
||
- code [ref=f24e209]: fetch join
|
||
- text: 하면
|
||
- code [ref=f24e210]: MultipleBagFetchException
|
||
- text: 이 발생했다. Hibernate는 순서 컬럼이 없는 두
|
||
- code [ref=f24e211]: List
|
||
- text: 가 조인되면서
|
||
- code [ref=f24e212]: highlights × mentions
|
||
- text: 형태로 행이 늘어날 경우, 이 결과를 원래 두 컬렉션으로 정확하게 구성할 수 없기 때문에 쿼리 실행 전에 이를 막는다. 실제 데이터가 없는 상태에서도 같은 예외가 발생했다.
|
||
- paragraph [ref=f24e213]:
|
||
- code [ref=f24e214]: highlights
|
||
- text: 하나만
|
||
- code [ref=f24e215]: fetch join
|
||
- text: 하면 예외는 발생하지 않았다. 대신 부모인 Feed Item이 Highlight 수만큼 반복되면서 DB에서 전달되는 행 수가 늘어났다.
|
||
- paragraph [ref=f24e216]:
|
||
- text: Hibernate 6에서는
|
||
- code [ref=f24e217]: fetch join
|
||
- text: 결과의 루트 엔티티 중복을 제거하기 때문에 최종 목록에는 Feed Item이 N개만 남는다. 따라서 반환된 목록의 크기만 보면 조인으로 행이 얼마나 늘어났는지 알 수 없다.
|
||
- paragraph [ref=f24e218]:
|
||
- text: N=100에서는 전체 쿼리가 222개에서 121개로 줄었다. 하지만 120개는 여전히
|
||
- code [ref=f24e219]: User
|
||
- text: 와
|
||
- code [ref=f24e220]: Page
|
||
- text: 를 조회하는 추가 쿼리였고,
|
||
- code [ref=f24e221]: highlights
|
||
- text: 를 가져오는 하나의 조인 쿼리는 1,961행을 전달했다. 즉, 쿼리 수는 줄었지만 실제로 처리하는 데이터까지 같이 줄어든 건 아니었다.
|
||
- generic [ref=f24e222]:
|
||
- generic [ref=f24e223]:
|
||
- term [ref=f24e224]: 검증 환경
|
||
- definition [ref=f24e225]:
|
||
- paragraph [ref=f24e226]: "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=f24e227]:
|
||
- term [ref=f24e228]: 검증 데이터
|
||
- definition [ref=f24e229]:
|
||
- paragraph [ref=f24e230]: 1. highlights와 mentions를 동시에 join fetch하는 JPQL을 실행해 MultipleBagFetchException이 발생하는지 확인한다.
|
||
- paragraph [ref=f24e231]: 2. highlights만 fetch join한 뒤 FeedItem을 각각 10개, 100개, 1000개로 늘려가면서 조회한다.
|
||
- paragraph [ref=f24e232]: 3. Hibernete가 반환한 FeedItem 수와 실제 조인으로 만들어진 행의 수를 각각 비교해본다.
|
||
- paragraph [ref=f24e233]: 4. 같은 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 실행해 DB에서 실제로 처리한 행 수를 확인한다.
|
||
- paragraph [ref=f24e234]: 5. 기존 테스트를 다시 실행해 변경 전 동작이 유지되는지 확인, highlights는 접근할 때 Feed Item 수만큼 조회되고, 접근하지 않으면 추가 조회는 없어야 한다.
|
||
- generic [ref=f24e235]:
|
||
- term [ref=f24e236]: 기록
|
||
- definition [ref=f24e237]: 게시 2026.09.01 · 마지막 검증 2026.09.01
|
||
- group [ref=f24e239]:
|
||
- generic "목차 · 두 컬렉션을 동시 fetch join" [ref=f24e240] [cursor=pointer]
|
||
- article [ref=f24e242]:
|
||
- region [ref=f24e243]:
|
||
- heading [level=2] [ref=f24e244]:
|
||
- link "두 컬렉션을 동시 fetch join 바로가기" [ref=f24e245] [cursor=pointer]:
|
||
- /url: "#두-컬렉션을-동시-fetch-join"
|
||
- text: 두 컬렉션을 동시 fetch join
|
||
- generic [aria-hidden] [ref=f24e246]: "#"
|
||
- figure "JAVA ·컬렉션 둘을 같이 fetch join 코드 복사" [ref=f24e247]:
|
||
- generic [ref=f24e248]:
|
||
- generic [ref=f24e249]: JAVA
|
||
- generic [ref=f24e250]: ·컬렉션 둘을 같이 fetch join
|
||
- button "코드 복사" [ref=f24e251] [cursor=pointer]: 복사
|
||
- region "컬렉션 둘을 같이 fetch join 코드" [ref=f24e252]:
|
||
- code [ref=f24e253]: select distinct f from FeedItemJpaEntity f join fetch f.highlights join fetch f.mentions
|
||
- figure "TEXT ·예외 원인 코드 복사" [ref=f24e255]:
|
||
- generic [ref=f24e256]:
|
||
- generic [ref=f24e257]: TEXT
|
||
- generic [ref=f24e258]: ·예외 원인
|
||
- button "코드 복사" [ref=f24e259] [cursor=pointer]: 복사
|
||
- region "예외 원인 코드" [ref=f24e260]:
|
||
- code [ref=f24e261]: java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchException
|
||
- paragraph [ref=f24e263]: MultipleBagFetchException은 직접 발생하지 않았고 IllegalArgumentException에 감싸진 상태로 전달됐다.전체 예외의 원인을 따라가면서 어떤 예외인지 확인했고 MultipleBagFetchException 해당 예외가 발생하는 것을 확인할 수 있었다.
|
||
- region [ref=f24e264]:
|
||
- heading [level=2] [ref=f24e265]:
|
||
- link "한 컬렉션만 fetch join 바로가기" [ref=f24e266] [cursor=pointer]:
|
||
- /url: "#한-컬렉션만-fetch-join"
|
||
- text: 한 컬렉션만 fetch join
|
||
- generic [aria-hidden] [ref=f24e267]: "#"
|
||
- region "표" [ref=f24e268]:
|
||
- table [ref=f24e269]:
|
||
- caption [ref=f24e270]
|
||
- rowgroup [ref=f24e271]:
|
||
- row [ref=f24e272]:
|
||
- columnheader "FeedItem 수" [ref=f24e273]
|
||
- columnheader "조인된 행 수" [ref=f24e274]
|
||
- columnheader "반환된 Feed Item 수" [ref=f24e275]
|
||
- columnheader "Highlight 수" [ref=f24e276]
|
||
- columnheader "FeedItem 대비 조인 행 수" [ref=f24e277]
|
||
- rowgroup [ref=f24e278]:
|
||
- row [ref=f24e279]:
|
||
- cell "10" [ref=f24e280]
|
||
- cell "1,285" [ref=f24e281]
|
||
- cell "10" [ref=f24e282]
|
||
- cell "1,285" [ref=f24e283]
|
||
- cell "128.5×" [ref=f24e284]
|
||
- row [ref=f24e285]:
|
||
- cell "100" [ref=f24e286]
|
||
- cell "1,961" [ref=f24e287]
|
||
- cell "100" [ref=f24e288]
|
||
- cell "1,961" [ref=f24e289]
|
||
- cell "19.6×" [ref=f24e290]
|
||
- row [ref=f24e291]:
|
||
- cell "1,000" [ref=f24e292]
|
||
- cell "2,917" [ref=f24e293]
|
||
- cell "1,000" [ref=f24e294]
|
||
- cell "2,917" [ref=f24e295]
|
||
- cell "2.9×" [ref=f24e296]
|
||
- paragraph [ref=f24e297]: highlights를 fetch join하자 조인된 행 수는 Highlight의 전체 개수와 같았다.FeedItem 하나에 Highlight가 여러 개 있으면 같은 FeedItem이 Highlight 수만큼 반복되기 때문이다.
|
||
- paragraph [ref=f24e298]: Hibernate는 중복된 Feed Item을 제거해 최종 목록에는 각각 10개, 100개, 1000개만 반환했다.하지만 DB에서 만들어지는 조인 결과까지 줄어드는 건 아니었다.
|
||
- paragraph [ref=f24e299]: FeedItem이 늘어나면서 조인된 행 수는 1285개에서 2917개까지 계속 증가했다.
|
||
- region [ref=f24e300]:
|
||
- heading [level=2] [ref=f24e301]:
|
||
- link "쿼리 수만 보면 개선처럼 보인다 바로가기" [ref=f24e302] [cursor=pointer]:
|
||
- /url: "#쿼리-수만-보면-개선처럼-보인다"
|
||
- text: 쿼리 수만 보면 개선처럼 보인다
|
||
- generic [aria-hidden] [ref=f24e303]: "#"
|
||
- region "표" [ref=f24e304]:
|
||
- table [ref=f24e305]:
|
||
- caption [ref=f24e306]
|
||
- rowgroup [ref=f24e307]:
|
||
- row [ref=f24e308]:
|
||
- columnheader "구분" [ref=f24e309]
|
||
- columnheader "기존 조회" [ref=f24e310]
|
||
- columnheader "highlights fetch join" [ref=f24e311]
|
||
- columnheader "변화" [ref=f24e312]
|
||
- rowgroup [ref=f24e313]:
|
||
- row [ref=f24e314]:
|
||
- cell "Feed Item 조회" [ref=f24e315]
|
||
- cell "1" [ref=f24e316]
|
||
- cell "1" [ref=f24e317]
|
||
- cell "highlights를 같이 조회" [ref=f24e318]
|
||
- row [ref=f24e319]:
|
||
- cell "count 조회" [ref=f24e320]
|
||
- cell "1" [ref=f24e321]
|
||
- cell "0" [ref=f24e322]
|
||
- cell "JPQL로 조회" [ref=f24e323]
|
||
- row [ref=f24e324]:
|
||
- cell "Highlight 조회" [ref=f24e325]
|
||
- cell "100" [ref=f24e326]
|
||
- cell "0" [ref=f24e327]
|
||
- cell "개별 조회 제거" [ref=f24e328]
|
||
- row [ref=f24e329]:
|
||
- cell "User+Page 조회" [ref=f24e330]
|
||
- cell "120" [ref=f24e331]
|
||
- cell "120" [ref=f24e332]
|
||
- cell "변화 없음" [ref=f24e333]
|
||
- row [ref=f24e334]:
|
||
- cell "전체" [ref=f24e335]
|
||
- cell "222" [ref=f24e336]
|
||
- cell "121" [ref=f24e337]
|
||
- cell "101개 감소" [ref=f24e338]
|
||
- paragraph [ref=f24e339]: 전체 쿼리는 222개가 121개로 줄었다. 하지만 User 와 Page를 조회하는 120개의 쿼리는 그대로 남아있어서 n+1은 highlights만 제거된 상태다.
|
||
- region [ref=f24e340]:
|
||
- heading [level=2] [ref=f24e341]:
|
||
- link "조인이 행을 곱하는 것을 실행계획 바로가기" [ref=f24e342] [cursor=pointer]:
|
||
- /url: "#조인이-행을-곱하는-것을-실행계획"
|
||
- text: 조인이 행을 곱하는 것을 실행계획
|
||
- generic [aria-hidden] [ref=f24e343]: "#"
|
||
- figure "TEXT ·N=100일 때, fetch join EXPLAIN 코드 복사" [ref=f24e344]:
|
||
- generic [ref=f24e345]:
|
||
- generic [ref=f24e346]: TEXT
|
||
- generic [ref=f24e347]: ·N=100일 때, fetch join EXPLAIN
|
||
- button "코드 복사" [ref=f24e348] [cursor=pointer]: 복사
|
||
- region "N=100일 때, fetch join EXPLAIN 코드" [ref=f24e349]:
|
||
- code [ref=f24e350]: "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=f24e352]: Feed Item은 100개가 반환되지만 실행계획에서 조인 결과는 1,961행이었다.쿼리는 한 번만 실행됐지만, 각 Feed Item이 Highlight 수만큼 반복되면서 실제로 처리하고 전달한 행은 훨씬 많았다.
|
||
- paragraph [ref=f24e353]: Hibernate가 중복된 Feed Item을 제거해 최종 목록에는 100개만 남기 때문에 반환된 목록 크기만으로는 실제로 반환되는 행의 수를 알 수 없다.
|
||
- paragraph [ref=f24e354]: 실행계획의 예상 행 수는 4,202행이었지만 실제로는 1,961행이었다.
|
||
- region [ref=f24e355]:
|
||
- paragraph [ref=f24e356]: Next
|
||
- heading "다음에 읽을 것" [level=2] [ref=f24e357]
|
||
- list [ref=f24e358]:
|
||
- listitem [ref=f24e359]:
|
||
- link "검증 기록 Collection Fetch Join Pagination의 In-memory Paging" [ref=f24e360] [cursor=pointer]:
|
||
- /url: /cases/collection-fetch-join-in-memory-paging
|
||
- generic [ref=f24e361]: 검증 기록
|
||
- generic [ref=f24e362]:
|
||
- strong [ref=f24e363]: Collection Fetch Join Pagination의 In-memory Paging
|
||
- paragraph [aria-hidden] [ref=f24e364]: 한 bag만 fetch join한 상태에서 페이징을 적용한 다음 기록이다.
|
||
- generic [aria-hidden] [ref=f24e365]: ↗
|
||
- listitem [ref=f24e366]:
|
||
- link "검증 기록 Fetch 타입이 아닌 조회 방식으로 인한 N+1" [ref=f24e367] [cursor=pointer]:
|
||
- /url: /cases/eager-toone-nplus1-without-access
|
||
- generic [ref=f24e368]: 검증 기록
|
||
- strong [ref=f24e370]: Fetch 타입이 아닌 조회 방식으로 인한 N+1
|
||
- generic [aria-hidden] [ref=f24e371]: ↗
|
||
- complementary [ref=f24e372]:
|
||
- heading "작업 상태" [level=2] [ref=f24e373]
|
||
- status "편집 상태" [ref=f24e374]: 저장되지 않음
|
||
- generic [ref=f24e375]:
|
||
- generic [ref=f24e376]:
|
||
- term [ref=f24e377]: 저장 버전
|
||
- definition [ref=f24e378]: "34"
|
||
- generic [ref=f24e379]:
|
||
- term [ref=f24e380]: 종류
|
||
- definition [ref=f24e381]: 검증 기록
|
||
- paragraph [ref=f24e382]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
|
||
- generic [ref=f24e383]:
|
||
- button "저장" [ref=f24e384]
|
||
- button "게시" [ref=f24e385]
|
||
- paragraph [ref=f24e386] |