feat: 문서 구조 변경 및 tech-visual 스킬 추가

This commit is contained in:
DongHyeonka
2026-09-04 18:20:00 +09:00
parent 43901f0abf
commit 2efb7ee1f2
683 changed files with 61180 additions and 10479 deletions
@@ -0,0 +1,289 @@
- 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]: 저장된 문서 검증이 완료되었습니다.