Files
document-haness/.playwright-mcp/page-2026-09-04T05-07-53-576Z.yml
T

579 lines
46 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
- 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]