289 lines
19 KiB
YAML
289 lines
19 KiB
YAML
- 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]: 저장된 문서 검증이 완료되었습니다. |