import type { CaseRecord } from "../../../application/ports/public-content-queries.ts"; import type { components } from "../../../contracts/studio/generated.ts"; import { parseCaseContent } from "../../../domain/content-format/parse-case-content.ts"; import type { EvidenceAsset } from "../../../domain/public-render-content.ts"; import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx"; import { publicRenderModelBase } from "./public-document-header.tsx"; const failedQueryCode = `@Query(""" select distinct fi from FeedItem fi join fetch fi.user join fetch fi.page left join fetch fi.highlights h where fi.visibility = :visibility order by fi.firstHighlightedAt desc, fi.id desc """) List findFeed( @Param("visibility") Visibility visibility, Pageable pageable );`; const splitQueryCode = `Page page = feedItemQuery.findPage( Visibility.PUBLIC, PageRequest.of(0, 20, Sort.by( Sort.Order.desc("firstHighlightedAt"), Sort.Order.desc("id") )) ); List feedItemIds = page.getContent().stream() .map(FeedItemRow::id) .toList(); Map> highlights = highlightQuery.findLatestByFeedItemIds(feedItemIds, 3);`; const fetchJoinBody = `## 문제를 고정하기 {#fix-the-problem} 피드 목록에는 FeedItem과 작성자, 페이지, 하이라이트, 멘션이 함께 필요했다. 화면은 공개된 FeedItem을 최초 하이라이트 시각의 역순으로 20개씩 보여주고, 각 항목에는 최신 하이라이트를 최대 3개까지 붙인다. 처음에는 한 번의 쿼리로 필요한 연관 데이터를 가져오면 N+1을 없앨 수 있다고 판단했다. \`user\`, \`page\`, \`highlights\`를 Fetch Join하고 \`PageRequest.of(0, 20)\`을 넘겼다. 반환값만 보면 기대한 20개가 나왔다. 이 결과만 확인하면 페이징도 동작하고 N+1도 사라진 것처럼 보인다. 문제는 20개를 어디에서 잘랐는지였다. Hibernate 로그에는 다음 경고가 남았다. \`\`\`text label="Hibernate 경고" firstResult/maxResults specified with collection fetch; applying in memory \`\`\` 데이터베이스가 20개를 고른 것이 아니었다. Join 결과를 읽고 Hibernate가 부모 엔티티를 복원한 다음, 메모리에서 FeedItem 20개만 남겼다. ## 첫 번째 시도: 컬렉션 Fetch Join {#fetch-join-attempt} 실험에 사용한 조회의 핵심 형태는 다음과 같다. \`\`\`java label="실패한 목록 조회" ${failedQueryCode} \`\`\` \`distinct\`는 같은 FeedItem 객체가 결과 목록에 반복되는 문제를 줄인다. 그러나 데이터베이스가 읽는 물리적인 Join 행까지 20개로 줄이지는 않는다. FeedItem 하나에 Highlight가 여러 개면 부모 행이 자식 수만큼 반복된다. 여기에 Mention 같은 두 번째 컬렉션까지 함께 Fetch Join하면 행 수는 곱으로 증가하고, 두 컬렉션이 모두 bag이면 \`MultipleBagFetchException\`도 별도로 발생할 수 있다. 이 Case에서 확인하려는 문제는 \`MultipleBagFetchException\` 자체가 아니다. 컬렉션 하나만 Fetch Join해도 목록 페이징이 데이터베이스에서 적용되지 않는다는 점이다. 두 문제는 원인과 해결 지점이 다르므로 같은 이름으로 묶지 않았다. ## 관찰한 값 {#observed-values} 테스트 데이터는 FeedItem 100개와 Zipf 형태로 편중된 Highlight·Mention으로 구성했다. 소수의 FeedItem에 자식이 몰리도록 해 평균값만으로 문제가 가려지지 않게 했다. :::table id="fetch-strategy-observation" caption="Fetch 전략별 페이징 경계 관찰" rowHeaderColumn="1" | 전략 | 반환 FeedItem | DB LIMIT | 별도로 기록한 관찰값 | | --- | ---: | :---: | --- | | Collection Fetch Join + paging API | 20 | :status[없음]{tone="warning"} | Join 결과 행 1,961개 | | Parent paging + Batch Fetch | 20 | :status[적용]{tone="evidence"} | Hibernate가 로드한 전체 엔티티 1,569개 | ::: 두 수치는 같은 단위의 전후 비교값이 아니다. \`1,961\`은 Fetch Join 쿼리에서 관찰한 결과 행 수다. \`1,569\`는 Batch Fetch 실험에서 집계한 전체 엔티티 로드 수다. 두 번째 수치가 더 작다는 이유만으로 개선율을 계산하면 안 된다. 이 실험에서 확정할 수 있는 사실은 부모 목록 쿼리에 \`LIMIT 20\`이 적용됐고, 컬렉션을 부모 조회와 분리했다는 점이다. :::callout tone="warning" label="주의" 반환된 Java 목록의 크기가 20이라는 사실만으로 데이터베이스 페이징을 확인할 수 없다. 실행 SQL의 LIMIT, 전송 행 수, 로드 엔티티 수를 따로 기록해야 한다. ::: ## 페이징과 컬렉션 로딩을 분리하기 {#separate-loading} 최종 구조는 두 단계다. 먼저 목록 정렬에 필요한 부모를 데이터베이스에서 20개로 고정한다. 그다음 현재 페이지의 부모 ID에 대해서만 필요한 컬렉션을 가져온다. \`\`\`java label="부모 페이징과 연관 조회 분리" ${splitQueryCode} \`\`\` 첫 쿼리는 화면의 기준 목록과 페이지 경계를 책임진다. 두 번째 조회는 그 페이지를 꾸미는 연관 데이터만 책임진다. Hibernate Batch Fetch를 사용한다면 LAZY 컬렉션을 현재 페이지의 부모 키 \`IN (...)\`으로 묶을 수 있다. 최신 3개처럼 부모별 제한이 필요하면 단순 Batch Fetch만으로는 부족하므로 Window Function이나 별도 Projection 쿼리가 필요하다. 이 구조에서는 \`FeedItemRow\`가 목록의 기본 틀을 가진다. User와 Page처럼 목록에서 바로 비교할 값은 DTO Projection으로 읽는다. Highlight와 Mention은 현재 페이지의 ID 집합을 기준으로 별도 조회해 조립한다. FeedItem 엔티티 전체를 먼저 로드한 뒤 화면 DTO로 바꾸는 방식은 기본 경로로 사용하지 않는다. :::evidence key="fetch-strategy-boundary" alt="Fetch Join은 전체 조인 결과를 읽은 뒤 메모리에서 20개를 고르고, Batch Fetch는 부모 20개를 먼저 고른 뒤 해당 ID의 컬렉션만 조회한다." caption="페이징이 적용된 지점이 부모 조회 앞으로 이동한다." zoom="true" ::: ## 왜 한 번의 쿼리를 포기했는가 {#give-up-one-query} 쿼리 수만 보면 한 번의 Fetch Join이 가장 단순해 보인다. 목록에서는 쿼리 수보다 페이지 경계가 먼저다. 데이터베이스가 20개 부모를 확정하지 못하면 자식 분포가 바뀔 때마다 읽는 행 수와 메모리 사용량이 흔들린다. 반환 개수는 같아도 비용을 예측할 수 없다. 분리 조회는 네트워크 왕복을 하나 이상 추가한다. 대신 각 쿼리의 책임과 최대 범위를 설명할 수 있다. 1. 부모 목록 쿼리는 정렬과 \`LIMIT 20\`을 보장한다. 2. 연관 조회는 부모 ID 20개 안에서만 실행한다. 3. 부모별 최신 3개 제한은 쿼리에서 명시한다. 4. 화면 조립 단계는 누락된 연관 데이터를 빈 목록으로 처리한다. 이 경계 덕분에 페이지 크기, 연관 데이터 상한, 정렬 인덱스를 각각 검증할 수 있다. ## 남은 비용과 적용 범위 {#remaining-cost} Batch Fetch는 컬렉션 N+1을 줄이지만 필요한 자식만 자동으로 골라 주지는 않는다. 한 FeedItem에 Highlight가 매우 많다면 현재 페이지의 모든 Highlight가 로드될 수 있다. \`최신 3개\`가 계약이면 부모별 제한 쿼리를 별도로 두어야 한다. 또한 첫 목록 쿼리의 정렬이 느리면 Fetch 전략을 바꿔도 전체 응답은 느리다. \`visibility\`, \`firstHighlightedAt\`, \`id\`의 필터·정렬 순서에 맞는 인덱스와 실행 계획을 따로 확인해야 한다. 이 Case는 컬렉션 로딩 경계만 결정하며 인덱스 설계의 결론을 대신하지 않는다. > 컬렉션이 포함된 목록에서는 부모 페이지를 먼저 데이터베이스에서 고정한다. 연관 데이터는 현재 페이지의 부모 키로 제한해 별도 조회한다. 반환 개수만 보지 말고 SQL LIMIT, 전송 행, 로드 엔티티를 각각 측정한다.`; function genericCaseBlocks( record: CaseRecord, ): components["schemas"]["CaseRenderBlock"][] { return record.sections.flatMap((section) => { const blocks: components["schemas"]["CaseRenderBlock"][] = [ { type: "HEADING", id: section.id, level: 2, content: [{ type: "TEXT", text: section.title }], }, ...section.paragraphs.map( (paragraph): components["schemas"]["CaseRenderBlock"] => ({ type: "PARAGRAPH", content: [{ type: "TEXT", text: paragraph }], }), ), ]; if (section.bullets) { blocks.push({ type: "UNORDERED_LIST", items: section.bullets.map((text, index) => ({ id: `${section.id}-item-${index + 1}`, content: [{ type: "TEXT", text }], })), }); } return blocks; }); } function resolvePublicEvidenceAsset(key: string): EvidenceAsset { if (key !== "fetch-strategy-boundary") { throw new Error(`Unknown local evidence asset: ${key}`); } return { src: "/media/fetch-strategy-boundary.svg", width: 1080, height: 420, triggerLabel: "Fetch Join과 Batch Fetch 비교 다이어그램 크게 보기", dialogLabel: "Fetch Join과 Batch Fetch의 페이징 경계 확대", }; } export function CaseDocumentPage({ record }: { record: CaseRecord }) { const model: components["schemas"]["CasePublicRenderModel"] = { ...publicRenderModelBase(record), kind: "CASE", problem: record.problem, conclusion: record.conclusion, environment: record.environment, reproduction: record.verification, lastVerifiedOn: record.lastVerifiedLabel.replaceAll(".", "-"), bodyBlocks: record.slug === "collection-fetch-join-pagination" ? parseCaseContent(fetchJoinBody) : genericCaseBlocks(record), }; return ( path === record.path ? record.publishedLabel : undefined } /> ); }