185 lines
10 KiB
TypeScript
185 lines
10 KiB
TypeScript
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<FeedItem> findFeed(
|
|
@Param("visibility") Visibility visibility,
|
|
Pageable pageable
|
|
);`;
|
|
|
|
const splitQueryCode = `Page<FeedItemRow> page = feedItemQuery.findPage(
|
|
Visibility.PUBLIC,
|
|
PageRequest.of(0, 20, Sort.by(
|
|
Sort.Order.desc("firstHighlightedAt"),
|
|
Sort.Order.desc("id")
|
|
))
|
|
);
|
|
|
|
List<UUID> feedItemIds = page.getContent().stream()
|
|
.map(FeedItemRow::id)
|
|
.toList();
|
|
|
|
Map<UUID, List<HighlightRow>> 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 (
|
|
<PublicRecordRenderer
|
|
model={model}
|
|
resolveEvidenceAsset={resolvePublicEvidenceAsset}
|
|
resolvePublishedLabel={(path) =>
|
|
path === record.path ? record.publishedLabel : undefined
|
|
}
|
|
/>
|
|
);
|
|
}
|