- generic [ref=f30e3]: - link "본문으로 건너뛰기" [ref=f30e4] [cursor=pointer]: - /url: "#main-content" - banner [ref=f30e5]: - generic [ref=f30e6]: - link "TechLog Studio" [ref=f30e7] [cursor=pointer]: - /url: /studio - text: TechLog - generic [ref=f30e8]: Studio - navigation "Studio 주 탐색" [ref=f30e10]: - link "작업본" [ref=f30e11] [cursor=pointer]: - /url: /studio/documents - link "게시 기록" [ref=f30e12] [cursor=pointer]: - /url: /studio/publications - link "새 문서" [ref=f30e13] [cursor=pointer]: - /url: /studio/documents/new - link "주제·프로젝트" [ref=f30e14] [cursor=pointer]: - /url: /studio/taxonomy - link "릴리즈" [ref=f30e15] [cursor=pointer]: - /url: /studio/releases - link "공개 사이트 보기" [ref=f30e16] [cursor=pointer]: - /url: / - button "로그아웃" [ref=f30e17] - main [ref=f30e18]: - generic [ref=f30e55]: - generic [ref=f30e56]: - generic [ref=f30e57]: - paragraph [ref=f30e58]: PUBLIC PREVIEW · VERSION 35 - paragraph [ref=f30e59]: Public Preview - paragraph [ref=f30e60]: Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제 - link "검증 결과 보기" [ref=f30e61] [cursor=pointer]: - /url: /studio/documents/7ed75172-fd56-42bf-956a-8f9fc1cca235/validation - region "Public Preview 상태" [ref=f30e62]: - generic [ref=f30e63]: - strong [ref=f30e64]: EXPIRED - paragraph [ref=f30e65]: 이 Public Preview는 만료되었습니다. - generic [ref=f30e66]: - link "검증 화면으로 이동" [ref=f30e67] [cursor=pointer]: - /url: /studio/documents/7ed75172-fd56-42bf-956a-8f9fc1cca235/validation - link "편집으로 돌아가기" [ref=f30e68] [cursor=pointer]: - /url: /studio/documents/7ed75172-fd56-42bf-956a-8f9fc1cca235/edit - generic [ref=f30e69]: - generic [ref=f30e70]: - term [ref=f30e71]: Preview 버전 - definition [ref=f30e72]: "33" - generic [ref=f30e73]: - term [ref=f30e74]: 생성 - definition [ref=f30e75]: 2026. 9. 1. 오후 2:04 - generic [ref=f30e76]: - term [ref=f30e77]: 만료 - definition [ref=f30e78]: 2026. 9. 2. 오후 2:04 - generic [ref=f30e80]: - generic [ref=f30e81]: - navigation "문서 경로" [ref=f30e82]: - link "검증 기록" [ref=f30e83] [cursor=pointer]: - /url: /explore/cases - generic [aria-hidden] [ref=f30e84]: / - link "JPA 피드 조회 성능" [ref=f30e85] [cursor=pointer]: - /url: /topics/jpa-feed-query-performance - generic [aria-hidden] [ref=f30e86]: / - link "Liner N + 1문제" [ref=f30e87] [cursor=pointer]: - /url: /projects/liner-n-plus-1 - heading "Collection Fetch Join으로 N+1을 해결하다 만난 MultiBag예외와 행 폭증 문제" [level=1] [ref=f30e88] - paragraph [ref=f30e89]: - text: 추가 쿼리를 줄이기 위해 필요한 연관 데이터를 - code [ref=f30e90]: fetch join - text: 으로 한 번에 조회했다. 하지만 두 컬렉션을 동시에 - code [ref=f30e91]: fetch join - text: 하자 - code [ref=f30e92]: MultipleBagFetchException - text: 이 발생했다. - paragraph [ref=f30e93]: - text: 컬렉션 하나만 - code [ref=f30e94]: fetch join - text: 했을 때는 쿼리 수가 줄었지만, 부모와 자식이 조인되면서 조회되는 행 수가 크게 늘었다. 실제 전송 행 수도 생성한 Highlight의 전체 개수만큼 증가했다. - paragraph [ref=f30e95]: - code [ref=f30e96]: fetch join - text: 으로 쿼리 수는 줄일 수 있었지만, 그만큼 DB에서 읽고 애플리케이션에서 처리해야 하는 데이터와 메모리 사용량이 증가했다. - region "문제와 결론" [ref=f30e97]: - generic [ref=f30e98]: - paragraph [ref=f30e99]: 문제 - paragraph [ref=f30e100]: - code [ref=f30e101]: "@OneToMany" - text: 과 - code [ref=f30e102]: "@ManyToOne" - text: 에서 발생하는 추가 쿼리를 확인한 뒤, 먼저 컬렉션에 대해서 - code [ref=f30e103]: highlights - text: "," - code [ref=f30e104]: mentions - text: 를 모두 - code [ref=f30e105]: fetch join - text: 해 한 번의 쿼리로 조회해 보았다. - paragraph [ref=f30e106]: mentions은 user와 feedItem의 다대다의 관계를 1대다와 다대1의 관계로 풀어내면서 나온 컬렉션이다. - generic [ref=f30e107]: - paragraph [ref=f30e108]: 결론 - paragraph [ref=f30e109]: - text: 두 - code [ref=f30e110]: List - text: 컬렉션을 동시에 - code [ref=f30e111]: fetch join - text: 하면 - code [ref=f30e112]: MultipleBagFetchException - text: 이 발생했다. Hibernate는 순서 컬럼이 없는 두 - code [ref=f30e113]: List - text: 가 조인되면서 - code [ref=f30e114]: highlights × mentions - text: 형태로 행이 늘어날 경우, 이 결과를 원래 두 컬렉션으로 정확하게 구성할 수 없기 때문에 쿼리 실행 전에 이를 막는다. 실제 데이터가 없는 상태에서도 같은 예외가 발생했다. - paragraph [ref=f30e115]: - code [ref=f30e116]: highlights - text: 하나만 - code [ref=f30e117]: fetch join - text: 하면 예외는 발생하지 않았다. 대신 부모인 Feed Item이 Highlight 수만큼 반복되면서 DB에서 전달되는 행 수가 늘어났다. - paragraph [ref=f30e118]: - text: Hibernate 6에서는 - code [ref=f30e119]: fetch join - text: 결과의 루트 엔티티 중복을 제거하기 때문에 최종 목록에는 Feed Item이 N개만 남는다. 따라서 반환된 목록의 크기만 보면 조인으로 행이 얼마나 늘어났는지 알 수 없다. - paragraph [ref=f30e120]: - text: N=100에서는 전체 쿼리가 222개에서 121개로 줄었다. 하지만 120개는 여전히 - code [ref=f30e121]: User - text: 와 - code [ref=f30e122]: Page - text: 를 조회하는 추가 쿼리였고, - code [ref=f30e123]: highlights - text: 를 가져오는 하나의 조인 쿼리는 1,961행을 전달했다. 즉, 쿼리 수는 줄었지만 실제로 처리하는 데이터까지 같이 줄어든 건 아니었다. - generic [ref=f30e124]: - generic [ref=f30e125]: - term [ref=f30e126]: 검증 환경 - definition [ref=f30e127]: - paragraph [ref=f30e128]: "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=f30e129]: - term [ref=f30e130]: 검증 데이터 - definition [ref=f30e131]: - paragraph [ref=f30e132]: 1. highlights와 mentions를 동시에 join fetch하는 JPQL을 실행해 MultipleBagFetchException이 발생하는지 확인한다. - paragraph [ref=f30e133]: 2. highlights만 fetch join한 뒤 FeedItem을 각각 10개, 100개, 1000개로 늘려가면서 조회한다. - paragraph [ref=f30e134]: 3. Hibernete가 반환한 FeedItem 수와 실제 조인으로 만들어진 행의 수를 각각 비교해본다. - paragraph [ref=f30e135]: 4. 같은 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 실행해 DB에서 실제로 처리한 행 수를 확인한다. - paragraph [ref=f30e136]: 5. 기존 테스트를 다시 실행해 변경 전 동작이 유지되는지 확인, highlights는 접근할 때 Feed Item 수만큼 조회되고, 접근하지 않으면 추가 조회는 없어야 한다. - generic [ref=f30e137]: - term [ref=f30e138]: 기록 - definition [ref=f30e139]: 게시 2026.09.01 · 마지막 검증 2026.09.01 - group [ref=f30e141]: - generic "목차 · 두 컬렉션을 동시 fetch join" [ref=f30e142] [cursor=pointer] - article [ref=f30e144]: - region [ref=f30e145]: - heading [level=2] [ref=f30e146]: - link "두 컬렉션을 동시 fetch join 바로가기" [ref=f30e147] [cursor=pointer]: - /url: "#두-컬렉션을-동시-fetch-join" - text: 두 컬렉션을 동시 fetch join - generic [aria-hidden] [ref=f30e148]: "#" - figure "JAVA LABEL=\"컬렉션 둘을 같이 FETCH JOIN\" ·코드 코드 복사" [ref=f30e149]: - generic [ref=f30e150]: - generic [ref=f30e151]: JAVA LABEL="컬렉션 둘을 같이 FETCH JOIN" - generic [ref=f30e152]: ·코드 - button "코드 복사" [ref=f30e153] [cursor=pointer]: 복사 - region "코드 코드" [ref=f30e154]: - code [ref=f30e155]: select distinct f from FeedItemJpaEntity f join fetch f.highlights join fetch f.mentions - figure "TEXT LABEL=\"예외 원인\" ·코드 코드 복사" [ref=f30e157]: - generic [ref=f30e158]: - generic [ref=f30e159]: TEXT LABEL="예외 원인" - generic [ref=f30e160]: ·코드 - button "코드 복사" [ref=f30e161] [cursor=pointer]: 복사 - region "코드 코드" [ref=f30e162]: - code [ref=f30e163]: java.lang.IllegalArgumentException <- org.hibernate.loader.MultipleBagFetchException - paragraph [ref=f30e165]: MultipleBagFetchException은 직접 발생하지 않았고 IllegalArgumentException에 감싸진 상태로 전달됐다. 전체 예외의 원인을 따라가면서 어떤 예외인지 확인했고 MultipleBagFetchException 해당 예외가 발생하는 것을 확인할 수 있었다. - region [ref=f30e166]: - heading [level=2] [ref=f30e167]: - link "한 컬렉션만 fetch join 바로가기" [ref=f30e168] [cursor=pointer]: - /url: "#한-컬렉션만-fetch-join" - text: 한 컬렉션만 fetch join - generic [aria-hidden] [ref=f30e169]: "#" - region "표" [ref=f30e170]: - table [ref=f30e171]: - caption [ref=f30e172] - rowgroup [ref=f30e173]: - row [ref=f30e174]: - columnheader "FeedItem 수" [ref=f30e175] - columnheader "조인된 행 수" [ref=f30e176] - columnheader "반환된 Feed Item 수" [ref=f30e177] - columnheader "Highlight 수" [ref=f30e178] - columnheader "FeedItem 대비 조인 행 수" [ref=f30e179] - rowgroup [ref=f30e180]: - row [ref=f30e181]: - cell "10" [ref=f30e182] - cell "1,285" [ref=f30e183] - cell "10" [ref=f30e184] - cell "1,285" [ref=f30e185] - cell "128.5×" [ref=f30e186] - row [ref=f30e187]: - cell "100" [ref=f30e188] - cell "1,961" [ref=f30e189] - cell "100" [ref=f30e190] - cell "1,961" [ref=f30e191] - cell "19.6×" [ref=f30e192] - row [ref=f30e193]: - cell "1,000" [ref=f30e194] - cell "2,917" [ref=f30e195] - cell "1,000" [ref=f30e196] - cell "2,917" [ref=f30e197] - cell "2.9×" [ref=f30e198] - paragraph [ref=f30e199]: highlights를 fetch join하자 조인된 행 수는 Highlight의 전체 개수와 같았다. FeedItem 하나에 Highlight가 여러 개 있으면 같은 FeedItem이 Highlight 수만큼 반복되기 때문이다. - paragraph [ref=f30e200]: Hibernate는 중복된 Feed Item을 제거해 최종 목록에는 각각 10개, 100개, 1000개만 반환했다. 하지만 DB에서 만들어지는 조인 결과까지 줄어드는 건 아니었다. - paragraph [ref=f30e201]: FeedItem이 늘어나면서 조인된 행 수는 1285개에서 2917개까지 계속 증가했다. - region [ref=f30e202]: - heading [level=2] [ref=f30e203]: - link "쿼리 수만 보면 개선처럼 보인다 바로가기" [ref=f30e204] [cursor=pointer]: - /url: "#쿼리-수만-보면-개선처럼-보인다" - text: 쿼리 수만 보면 개선처럼 보인다 - generic [aria-hidden] [ref=f30e205]: "#" - region "표" [ref=f30e206]: - table [ref=f30e207]: - caption [ref=f30e208] - rowgroup [ref=f30e209]: - row [ref=f30e210]: - columnheader "구분" [ref=f30e211] - columnheader "기존 조회" [ref=f30e212] - columnheader "highlights fetch join" [ref=f30e213] - columnheader "변화" [ref=f30e214] - rowgroup [ref=f30e215]: - row [ref=f30e216]: - cell "Feed Item 조회" [ref=f30e217] - cell "1" [ref=f30e218] - cell "1" [ref=f30e219] - cell "highlights를 같이 조회" [ref=f30e220] - row [ref=f30e221]: - cell "count 조회" [ref=f30e222] - cell "1" [ref=f30e223] - cell "0" [ref=f30e224] - cell "JPQL로 조회" [ref=f30e225] - row [ref=f30e226]: - cell "Highlight 조회" [ref=f30e227] - cell "100" [ref=f30e228] - cell "0" [ref=f30e229] - cell "개별 조회 제거" [ref=f30e230] - row [ref=f30e231]: - cell "User+Page 조회" [ref=f30e232] - cell "120" [ref=f30e233] - cell "120" [ref=f30e234] - cell "변화 없음" [ref=f30e235] - row [ref=f30e236]: - cell "전체" [ref=f30e237] - cell "222" [ref=f30e238] - cell "121" [ref=f30e239] - cell "101개 감소" [ref=f30e240] - paragraph [ref=f30e241]: 전체 쿼리는 222개가 121개로 줄었다. 하지만 User 와 Page를 조회하는 120개의 쿼리는 그대로 남아있어서 n+1은 highlights만 제거된 상태다. - region [ref=f30e242]: - heading [level=2] [ref=f30e243]: - link "조인이 행을 곱하는 것을 실행계획 바로가기" [ref=f30e244] [cursor=pointer]: - /url: "#조인이-행을-곱하는-것을-실행계획" - text: 조인이 행을 곱하는 것을 실행계획 - generic [aria-hidden] [ref=f30e245]: "#" - figure "TEXT LABEL=\"N=100일 때, FETCH JOIN EXPLAIN\" ·코드 코드 복사" [ref=f30e246]: - generic [ref=f30e247]: - generic [ref=f30e248]: TEXT LABEL="N=100일 때, FETCH JOIN EXPLAIN" - generic [ref=f30e249]: ·코드 - button "코드 복사" [ref=f30e250] [cursor=pointer]: 복사 - region "코드 코드" [ref=f30e251]: - code [ref=f30e252]: "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=f30e254]: Feed Item은 100개가 반환되지만 실행계획에서 조인 결과는 1,961행이었다. 쿼리는 한 번만 실행됐지만, 각 Feed Item이 Highlight 수만큼 반복되면서 실제로 처리하고 전달한 행은 훨씬 많았다. - paragraph [ref=f30e255]: Hibernate가 중복된 Feed Item을 제거해 최종 목록에는 100개만 남기 때문에 반환된 목록 크기만으로는 실제로 반환되는 행의 수를 알 수 없다. - paragraph [ref=f30e256]: 실행계획의 예상 행 수는 4,202행이었지만 실제로는 1,961행이었다. - region [ref=f30e257]: - paragraph [ref=f30e258]: Next - heading "다음에 읽을 것" [level=2] [ref=f30e259] - list [ref=f30e260]: - listitem [ref=f30e261]: - link "적용 기준 Fetch Join · Batch · Projection 선택 기준" [ref=f30e262] [cursor=pointer]: - /url: /references/fetch-strategy-selection - generic [ref=f30e263]: 적용 기준 - generic [ref=f30e264]: - strong [ref=f30e265]: Fetch Join · Batch · Projection 선택 기준 - paragraph [aria-hidden] [ref=f30e266]: 이 실패에서 나온 선택 기준이다. - generic [aria-hidden] [ref=f30e267]: ↗ - listitem [ref=f30e268]: - link "검증 기록 DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1" [ref=f30e269] [cursor=pointer]: - /url: /cases/collection-nplus1-dto-mapping - generic [ref=f30e270]: 검증 기록 - generic [ref=f30e271]: - strong [ref=f30e272]: DTO 변환 과정에서 발생한 Highlight 컬렉션 N+1 - paragraph [aria-hidden] [ref=f30e273]: 이 시도가 풀려던 문제다. - generic [aria-hidden] [ref=f30e274]: ↗ - listitem [ref=f30e275]: - link "검증 기록 Collection Fetch Join Pagination의 In-memory Paging" [ref=f30e276] [cursor=pointer]: - /url: /cases/collection-fetch-join-in-memory-paging - generic [ref=f30e277]: 검증 기록 - generic [ref=f30e278]: - strong [ref=f30e279]: Collection Fetch Join Pagination의 In-memory Paging - paragraph [aria-hidden] [ref=f30e280]: 한 bag만 fetch join한 상태에서 페이징을 적용한 다음 기록이다. - generic [aria-hidden] [ref=f30e281]: ↗ - paragraph [ref=f30e54]: 저장된 문서 검증이 완료되었습니다.