- 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]