feat: port TechLog document screens
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import type { PublicRecord } from "../../../application/ports/public-content-queries.ts";
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
|
||||
const kindLabels = {
|
||||
CASE: "Case",
|
||||
REFERENCE: "Reference",
|
||||
QUESTION: "Open Question",
|
||||
} as const;
|
||||
|
||||
export function PublicDocumentHeader({ record }: { record: PublicRecord }) {
|
||||
return (
|
||||
<header className="public-document-header">
|
||||
<nav aria-label="문서 경로">
|
||||
<Link
|
||||
to={`/explore/${record.kind === "CASE" ? "cases" : record.kind === "REFERENCE" ? "references" : "questions"}`}
|
||||
>
|
||||
{kindLabels[record.kind]}
|
||||
</Link>
|
||||
<span aria-hidden="true">/</span>
|
||||
<Link to={`/topics/${record.topicSlug}`}>{record.topic}</Link>
|
||||
<span aria-hidden="true">/</span>
|
||||
<Link to={`/projects/${record.projectSlug}`}>
|
||||
{record.projectTitle}
|
||||
</Link>
|
||||
</nav>
|
||||
<h1>{record.title}</h1>
|
||||
<p>{record.summary}</p>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>유형</dt>
|
||||
<dd>{kindLabels[record.kind]}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>프로젝트</dt>
|
||||
<dd>{record.projectTitle}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>게시</dt>
|
||||
<dd>{record.publishedLabel}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function publicRenderModelBase(
|
||||
record: PublicRecord,
|
||||
): components["schemas"]["PublicRenderModelBase"] {
|
||||
return {
|
||||
kind: record.kind,
|
||||
slug: record.slug,
|
||||
title: record.title,
|
||||
summary: record.summary,
|
||||
publicPath: record.path,
|
||||
topic: {
|
||||
id: `topic-${record.topicSlug}`,
|
||||
label: record.topic,
|
||||
publicPath: `/topics/${record.topicSlug}`,
|
||||
},
|
||||
project: {
|
||||
id: `project-${record.projectSlug}`,
|
||||
label: record.projectTitle,
|
||||
publicPath: `/projects/${record.projectSlug}`,
|
||||
},
|
||||
relations: record.relations.map((relation, index) => ({
|
||||
id: `public-relation-${index + 1}-${relation.path}`,
|
||||
targetId: relation.path,
|
||||
targetKind: relation.path.startsWith("/cases/")
|
||||
? "CASE"
|
||||
: relation.path.startsWith("/references/")
|
||||
? "REFERENCE"
|
||||
: relation.path.startsWith("/questions/")
|
||||
? "QUESTION"
|
||||
: relation.path.includes("/decisions#")
|
||||
? "PROJECT_DECISION"
|
||||
: "PROJECT",
|
||||
title: relation.title,
|
||||
publicPath: relation.path,
|
||||
reason: relation.reason,
|
||||
order: index + 1,
|
||||
})),
|
||||
renderContext: {
|
||||
generatedAt: record.publishedAt,
|
||||
dependencyRevision: "public-v1",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
PublicDocumentRelations,
|
||||
type PublicRelation,
|
||||
} from "../../shared/public-render/public-document-relations.tsx";
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { QuestionRecord } from "../../../application/ports/public-content-queries.ts";
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
|
||||
import { publicRenderModelBase } from "./public-document-header.tsx";
|
||||
|
||||
function orderedText(
|
||||
record: QuestionRecord,
|
||||
items: ReadonlyArray<string>,
|
||||
key: string,
|
||||
): components["schemas"]["OrderedText"][] {
|
||||
return items.map((text, index) => ({
|
||||
id: `${record.slug}-${key}-${index + 1}`,
|
||||
text,
|
||||
order: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
function questionStatus(record: QuestionRecord): "OPEN" | "RESOLVED" {
|
||||
if (record.questionStatus === "OPEN" || record.questionStatus === "RESOLVED") {
|
||||
return record.questionStatus;
|
||||
}
|
||||
throw new Error(`Unsupported public Question status: ${record.questionStatus}`);
|
||||
}
|
||||
|
||||
export function QuestionDocumentPage({ record }: { record: QuestionRecord }) {
|
||||
const model: components["schemas"]["QuestionPublicRenderModel"] = {
|
||||
...publicRenderModelBase(record),
|
||||
kind: "QUESTION",
|
||||
status: questionStatus(record),
|
||||
facts: orderedText(record, record.facts, "fact"),
|
||||
assumptions: orderedText(record, record.assumptions, "assumption"),
|
||||
unknowns: orderedText(record, record.unknowns, "unknown"),
|
||||
constraints: orderedText(record, record.constraints, "constraint"),
|
||||
options: record.options.map((option, index) => ({
|
||||
id: `${record.slug}-option-${index + 1}`,
|
||||
title: option.title,
|
||||
description: option.description,
|
||||
order: index + 1,
|
||||
})),
|
||||
nextValidation: record.nextValidation,
|
||||
resolution: record.resolution
|
||||
? {
|
||||
summary: record.resolution.summary,
|
||||
evidenceTarget: {
|
||||
id: record.resolution.path,
|
||||
label: record.resolution.linkLabel,
|
||||
publicPath: record.resolution.path,
|
||||
},
|
||||
linkLabel: record.resolution.linkLabel,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
return (
|
||||
<PublicRecordRenderer
|
||||
model={model}
|
||||
resolveEvidenceAsset={missingQuestionEvidence}
|
||||
resolvePublishedLabel={(path) =>
|
||||
path === record.path ? record.publishedLabel : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function missingQuestionEvidence(key: string): never {
|
||||
throw new Error(`Question document cannot render evidence: ${key}`);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ReferenceRecord } from "../../../application/ports/public-content-queries.ts";
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import { PublicRecordRenderer } from "../../shared/public-render/public-record-renderer.tsx";
|
||||
import { publicRenderModelBase } from "./public-document-header.tsx";
|
||||
|
||||
function orderedText(
|
||||
record: ReferenceRecord,
|
||||
items: ReadonlyArray<string>,
|
||||
key: string,
|
||||
): components["schemas"]["OrderedText"][] {
|
||||
return items.map((text, index) => ({
|
||||
id: `${record.slug}-${key}-${index + 1}`,
|
||||
text,
|
||||
order: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
export function ReferenceDocumentPage({ record }: { record: ReferenceRecord }) {
|
||||
const model: components["schemas"]["ReferencePublicRenderModel"] = {
|
||||
...publicRenderModelBase(record),
|
||||
kind: "REFERENCE",
|
||||
purpose: record.purpose,
|
||||
rules: record.rules.map((rule, index) => ({
|
||||
id: `${record.slug}-rule-${index + 1}`,
|
||||
title: rule.title,
|
||||
body: rule.body,
|
||||
order: index + 1,
|
||||
})),
|
||||
applyWhen: orderedText(record, record.applyWhen, "apply"),
|
||||
exceptions: orderedText(record, record.exceptions, "exception"),
|
||||
examples: orderedText(record, record.examples, "example"),
|
||||
verifiedOn: record.verifiedAt,
|
||||
};
|
||||
|
||||
return (
|
||||
<PublicRecordRenderer
|
||||
model={model}
|
||||
resolveEvidenceAsset={missingReferenceEvidence}
|
||||
resolvePublishedLabel={(path) =>
|
||||
path === record.path ? record.publishedLabel : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function missingReferenceEvidence(key: string): never {
|
||||
throw new Error(`Reference document cannot render evidence: ${key}`);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import {
|
||||
RegisteredNotFoundRoute,
|
||||
useRouteInput,
|
||||
} from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { CaseDocumentPage } from "../components/case-document-page.tsx";
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
export function CasePage() {
|
||||
const { params, search } = useRouteInput<"TECH_LOG_CASE">();
|
||||
const slug = optionalString(params.slug);
|
||||
const requestedState = optionalString(search.state);
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const record = slug ? publicContent.getRecord("CASE", slug) : undefined;
|
||||
|
||||
if (!record) return <RegisteredNotFoundRoute />;
|
||||
|
||||
return (
|
||||
<CaseDocumentPage
|
||||
record={
|
||||
requestedState === "relations-empty"
|
||||
? { ...record, relations: [] }
|
||||
: record
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import {
|
||||
RegisteredNotFoundRoute,
|
||||
useRouteInput,
|
||||
} from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { QuestionDocumentPage } from "../components/question-document-page.tsx";
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
export function QuestionPage() {
|
||||
const { params } = useRouteInput<"TECH_LOG_QUESTION">();
|
||||
const slug = optionalString(params.slug);
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const record = slug ? publicContent.getRecord("QUESTION", slug) : undefined;
|
||||
|
||||
return record ? (
|
||||
<QuestionDocumentPage record={record} />
|
||||
) : (
|
||||
<RegisteredNotFoundRoute />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import {
|
||||
RegisteredNotFoundRoute,
|
||||
useRouteInput,
|
||||
} from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { ReferenceDocumentPage } from "../components/reference-document-page.tsx";
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
export function ReferencePage() {
|
||||
const { params } = useRouteInput<"TECH_LOG_REFERENCE">();
|
||||
const slug = optionalString(params.slug);
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const record = slug ? publicContent.getRecord("REFERENCE", slug) : undefined;
|
||||
|
||||
return record ? (
|
||||
<ReferenceDocumentPage record={record} />
|
||||
) : (
|
||||
<RegisteredNotFoundRoute />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import {
|
||||
RegisteredNotFoundRoute,
|
||||
useRouteInput,
|
||||
} from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { PublicRecordList } from "../components/public-record-list.tsx";
|
||||
|
||||
const topics = {
|
||||
jpa: {
|
||||
title: "JPA",
|
||||
description: "목록 조회, 연관 로딩과 페이지 경계를 함께 검증한 기록입니다.",
|
||||
},
|
||||
authentication: {
|
||||
title: "Authentication",
|
||||
description:
|
||||
"브라우저와 Edge, Resource Server 사이의 인증 책임과 신뢰 경계를 검증한 기록입니다.",
|
||||
},
|
||||
redis: {
|
||||
title: "Redis",
|
||||
description:
|
||||
"애플리케이션 정책과 Redis 저장 명령의 책임 경계를 검증한 기록입니다.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
function topicConfig(value: unknown) {
|
||||
if (
|
||||
value === "jpa" ||
|
||||
value === "authentication" ||
|
||||
value === "redis"
|
||||
) {
|
||||
return topics[value];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function TopicPage() {
|
||||
const { params } = useRouteInput<"TECH_LOG_TOPIC">();
|
||||
const topic = topicConfig(params.slug);
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
|
||||
if (!topic) return <RegisteredNotFoundRoute />;
|
||||
|
||||
const records = publicContent.listRecords({ topic: topic.title });
|
||||
return (
|
||||
<main id="main-content" className="shell public-index-page">
|
||||
<header className="public-page-header">
|
||||
<p className="section-kicker">Topic</p>
|
||||
<h1>{topic.title}</h1>
|
||||
<p>{topic.description}</p>
|
||||
</header>
|
||||
<div className="public-result-heading">
|
||||
<h2>관련 기록</h2>
|
||||
<p>{records.length}개의 관련 기록</p>
|
||||
</div>
|
||||
<PublicRecordList records={records} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { type ComponentType } from "react";
|
||||
import {
|
||||
createMemoryRouter,
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
} from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
||||
import {
|
||||
TECH_LOG_ROUTE_REGISTRY,
|
||||
TECH_LOG_ROUTE_RUNTIME_CONTRACT,
|
||||
} from "../../../src/features/tech-log/contracts/tech-log-route-contract.ts";
|
||||
import { CasePage } from "../../../src/features/tech-log/presentation/public/pages/case-page.tsx";
|
||||
import { QuestionPage } from "../../../src/features/tech-log/presentation/public/pages/question-page.tsx";
|
||||
import { ReferencePage } from "../../../src/features/tech-log/presentation/public/pages/reference-page.tsx";
|
||||
import { TopicPage } from "../../../src/features/tech-log/presentation/public/pages/topic-page.tsx";
|
||||
import { PublicShell } from "../../../src/features/tech-log/presentation/public/public-shell.tsx";
|
||||
import { TECH_LOG_ROUTE_CODECS } from "../../../src/features/tech-log/presentation/tech-log-route-codecs.ts";
|
||||
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
||||
import NotFoundPage from "../../../src/presentation/pages/not-found-page.tsx";
|
||||
import { createGroupedRouteObjects } from "../../../src/presentation/routes/app-router.tsx";
|
||||
import { PLATFORM_ROUTE_CODECS } from "../../../src/presentation/routes/platform-route-codecs.ts";
|
||||
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
||||
|
||||
const routeCodecs = Object.freeze({
|
||||
...PLATFORM_ROUTE_CODECS,
|
||||
...TECH_LOG_ROUTE_CODECS,
|
||||
});
|
||||
|
||||
const routeComponents = {
|
||||
TECH_LOG_CASE: CasePage,
|
||||
TECH_LOG_REFERENCE: ReferencePage,
|
||||
TECH_LOG_QUESTION: QuestionPage,
|
||||
TECH_LOG_TOPIC: TopicPage,
|
||||
} as const satisfies Record<string, ComponentType>;
|
||||
|
||||
type DocumentRouteId = keyof typeof routeComponents;
|
||||
|
||||
class NoopIntersectionObserver implements IntersectionObserver {
|
||||
readonly root = null;
|
||||
readonly rootMargin = "0px";
|
||||
readonly scrollMargin = "0px";
|
||||
readonly thresholds = [0];
|
||||
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return [];
|
||||
}
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("IntersectionObserver", NoopIntersectionObserver);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderDocumentRoute(routeId: DocumentRouteId, initialEntry: string) {
|
||||
const definition = TECH_LOG_ROUTE_REGISTRY[routeId];
|
||||
const runtime = TECH_LOG_ROUTE_RUNTIME_CONTRACT[routeId];
|
||||
const Component = routeComponents[routeId];
|
||||
const router = createMemoryRouter(
|
||||
createGroupedRouteObjects(
|
||||
{
|
||||
[routeId]: definition,
|
||||
NOT_FOUND: TECH_LOG_ROUTE_REGISTRY.NOT_FOUND,
|
||||
},
|
||||
{
|
||||
[routeId]: { moduleId: runtime.moduleId, Component },
|
||||
NOT_FOUND: {
|
||||
moduleId: TECH_LOG_ROUTE_RUNTIME_CONTRACT.NOT_FOUND.moduleId,
|
||||
Component: NotFoundPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
PUBLIC: (
|
||||
<PublicShell>
|
||||
<Outlet />
|
||||
</PublicShell>
|
||||
),
|
||||
STUDIO: <Outlet />,
|
||||
},
|
||||
"task-8-test-build",
|
||||
routeCodecs,
|
||||
),
|
||||
{ initialEntries: [initialEntry] },
|
||||
);
|
||||
const techLog = createTechLogFeatureInstalledInput().input;
|
||||
const view = render(
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
featureInputs: { "tech-log": techLog },
|
||||
})}
|
||||
>
|
||||
<RouterProvider router={router} />
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
return { ...view, router };
|
||||
}
|
||||
|
||||
describe("TechLog canonical Public documents", () => {
|
||||
it.each([
|
||||
{
|
||||
routeId: "TECH_LOG_CASE" as const,
|
||||
path: "/cases/collection-fetch-join-pagination",
|
||||
title: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
||||
kind: "Case",
|
||||
topic: "JPA",
|
||||
project: "Backend Skeleton",
|
||||
published: "2026.08.11",
|
||||
evidence: "Join 결과 행 1,961개",
|
||||
relations: [
|
||||
"/questions/collection-fetch-join-with-pagination",
|
||||
"/projects/backend-skeleton/decisions#feed-pagination-boundary",
|
||||
"/references/jpa-list-fetch-strategy",
|
||||
],
|
||||
},
|
||||
{
|
||||
routeId: "TECH_LOG_CASE" as const,
|
||||
path: "/cases/redis-adapter-ttl-boundary",
|
||||
title: "Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유",
|
||||
kind: "Case",
|
||||
topic: "Redis",
|
||||
project: "Backend Skeleton",
|
||||
published: "2026.08.07",
|
||||
evidence: "정책과 저장 명령의 주인을 구분하기",
|
||||
relations: [
|
||||
"/projects/backend-skeleton",
|
||||
"/projects/backend-skeleton/decisions#storage-port-unification",
|
||||
],
|
||||
},
|
||||
{
|
||||
routeId: "TECH_LOG_REFERENCE" as const,
|
||||
path: "/references/state-and-nonce-boundary",
|
||||
title: "Authorization Code Flow에서 state와 nonce의 경계",
|
||||
kind: "Reference",
|
||||
topic: "Authentication",
|
||||
project: "Auth Lab",
|
||||
published: "2026.08.09",
|
||||
evidence: "state는 요청과 콜백을 연결합니다",
|
||||
relations: [
|
||||
"/questions/validate-edge-token-again",
|
||||
"/projects/auth-lab",
|
||||
],
|
||||
},
|
||||
{
|
||||
routeId: "TECH_LOG_REFERENCE" as const,
|
||||
path: "/references/jpa-list-fetch-strategy",
|
||||
title: "JPA 목록 조회에서 Fetch 전략을 선택하는 기준",
|
||||
kind: "Reference",
|
||||
topic: "JPA",
|
||||
project: "Backend Skeleton",
|
||||
published: "2026.08.10",
|
||||
evidence: "부모 페이지 경계를 먼저 고정합니다",
|
||||
relations: [
|
||||
"/cases/collection-fetch-join-pagination",
|
||||
"/questions/collection-fetch-join-with-pagination",
|
||||
],
|
||||
},
|
||||
{
|
||||
routeId: "TECH_LOG_QUESTION" as const,
|
||||
path: "/questions/validate-edge-token-again",
|
||||
title: "oauth2-proxy가 전달한 토큰을 다시 검증해야 하는가?",
|
||||
kind: "Open Question",
|
||||
topic: "Authentication",
|
||||
project: "Auth Lab",
|
||||
published: "2026.08.08",
|
||||
evidence: "토큰 전달안과 신뢰 헤더안을 위협 모델로 비교하고",
|
||||
relations: [
|
||||
"/references/state-and-nonce-boundary",
|
||||
"/projects/auth-lab/activity#edge-trust-boundary",
|
||||
],
|
||||
},
|
||||
{
|
||||
routeId: "TECH_LOG_QUESTION" as const,
|
||||
path: "/questions/collection-fetch-join-with-pagination",
|
||||
title: "컬렉션 Fetch Join을 유지하면서 페이징할 수 있는가?",
|
||||
kind: "Open Question",
|
||||
topic: "JPA",
|
||||
project: "Backend Skeleton",
|
||||
published: "2026.08.05",
|
||||
evidence: "해결 과정을 Case로 읽기",
|
||||
relations: [
|
||||
"/cases/collection-fetch-join-pagination",
|
||||
"/references/jpa-list-fetch-strategy",
|
||||
],
|
||||
},
|
||||
])(
|
||||
"renders $path with exact metadata, evidence, and ordered relations",
|
||||
({ routeId, path, title, kind, topic, project, published, evidence, relations }) => {
|
||||
const { container } = renderDocumentRoute(routeId, path);
|
||||
const main = screen.getByRole("main");
|
||||
|
||||
expect(within(main).getByRole("heading", { level: 1, name: title })).toBeVisible();
|
||||
expect(within(main).getByText(evidence, { exact: false })).toBeVisible();
|
||||
expect(within(main).getByRole("navigation", { name: "문서 경로" })).toHaveTextContent(
|
||||
`${kind}/${topic}/${project}`,
|
||||
);
|
||||
|
||||
if (path === "/cases/collection-fetch-join-pagination") {
|
||||
expect(within(main).getByText(`게시 ${published} · 마지막 검증 2026.08.11`)).toBeVisible();
|
||||
} else {
|
||||
const metadata = container.querySelector(".public-document-header dl");
|
||||
expect(metadata).toHaveTextContent(`유형${kind}`);
|
||||
expect(metadata).toHaveTextContent(`프로젝트${project}`);
|
||||
expect(metadata).toHaveTextContent(`게시${published}`);
|
||||
}
|
||||
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".document-relations li a"), (link) =>
|
||||
link.getAttribute("href"),
|
||||
),
|
||||
).toEqual(relations);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves the specialized Fetch Join layout, TOC, rendered blocks, anchors, and evidence media", () => {
|
||||
const { container } = renderDocumentRoute(
|
||||
"TECH_LOG_CASE",
|
||||
"/cases/collection-fetch-join-pagination",
|
||||
);
|
||||
const main = screen.getByRole("main");
|
||||
|
||||
expect(main).toHaveClass("case-page");
|
||||
expect(main.querySelector("header.shell.case-header")).not.toBeNull();
|
||||
expect(screen.getByRole("region", { name: "문제와 결론" })).toHaveTextContent(
|
||||
"FeedItem 20건을 요청했지만",
|
||||
);
|
||||
expect(screen.getByText("Fetch 전략별 페이징 경계 관찰")).toBeVisible();
|
||||
expect(screen.getByText("firstResult/maxResults specified with collection fetch; applying in memory")).toBeVisible();
|
||||
expect(screen.getAllByText("MultipleBagFetchException")).toHaveLength(2);
|
||||
expect(screen.getByRole("link", { name: "관찰한 값 바로가기" })).toHaveAttribute(
|
||||
"href",
|
||||
"#observed-values",
|
||||
);
|
||||
expect(screen.getAllByRole("navigation", { name: "문서 목차" })).toHaveLength(1);
|
||||
expect(container.querySelectorAll('[aria-label="코드 복사"]')).toHaveLength(3);
|
||||
|
||||
const image = screen.getByRole("img", {
|
||||
name: "Fetch Join은 전체 조인 결과를 읽은 뒤 메모리에서 20개를 고르고, Batch Fetch는 부모 20개를 먼저 고른 뒤 해당 ID의 컬렉션만 조회한다.",
|
||||
});
|
||||
expect(image).toHaveAttribute("src", "/media/fetch-strategy-boundary.svg");
|
||||
expect(image).toHaveAttribute("width", "1080");
|
||||
expect(image).toHaveAttribute("height", "420");
|
||||
expect(image).toHaveAttribute("loading", "lazy");
|
||||
});
|
||||
|
||||
it("uses the generic Case markup and omits source relations for the canonical empty state", () => {
|
||||
const generic = renderDocumentRoute(
|
||||
"TECH_LOG_CASE",
|
||||
"/cases/redis-adapter-ttl-boundary",
|
||||
);
|
||||
expect(screen.getByRole("main")).toHaveClass("shell", "public-document-page");
|
||||
expect(generic.container.querySelector("#ownership")).toHaveTextContent(
|
||||
"정책과 저장 명령의 주인을 구분하기",
|
||||
);
|
||||
generic.unmount();
|
||||
|
||||
const empty = renderDocumentRoute(
|
||||
"TECH_LOG_CASE",
|
||||
"/cases/collection-fetch-join-pagination?state=relations-empty",
|
||||
);
|
||||
expect(empty.container.querySelector(".document-relations")).toBeNull();
|
||||
expect(screen.queryByText("Explicit relations")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { name: "이 기록의 연결" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves Reference rule order and resolved Question evidence and empty copy", () => {
|
||||
const reference = renderDocumentRoute(
|
||||
"TECH_LOG_REFERENCE",
|
||||
"/references/state-and-nonce-boundary",
|
||||
);
|
||||
expect(
|
||||
Array.from(reference.container.querySelectorAll(".reference-rules h3"), (heading) =>
|
||||
heading.textContent,
|
||||
),
|
||||
).toEqual([
|
||||
"state는 요청과 콜백을 연결합니다",
|
||||
"nonce는 인증 결과와 ID Token을 연결합니다",
|
||||
"PKCE는 Code를 교환할 클라이언트를 증명합니다",
|
||||
]);
|
||||
expect(screen.getByText("마지막 검증 2026.08.09")).toBeVisible();
|
||||
reference.unmount();
|
||||
|
||||
renderDocumentRoute(
|
||||
"TECH_LOG_QUESTION",
|
||||
"/questions/collection-fetch-join-with-pagination",
|
||||
);
|
||||
expect(screen.getByText("RESOLVED")).toHaveClass(
|
||||
"question-status",
|
||||
"question-status--resolved",
|
||||
);
|
||||
expect(screen.getByText("해결 과정에서 남은 미지수가 없습니다.")).toBeVisible();
|
||||
expect(screen.getByRole("link", { name: "해결 과정을 Case로 읽기" })).toHaveAttribute(
|
||||
"href",
|
||||
"/cases/collection-fetch-join-pagination",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TechLog topics and Public not-found routing", () => {
|
||||
it.each([
|
||||
["jpa", "JPA", "목록 조회, 연관 로딩과 페이지 경계를 함께 검증한 기록입니다.", "3개의 관련 기록"],
|
||||
["authentication", "Authentication", "브라우저와 Edge, Resource Server 사이의 인증 책임과 신뢰 경계를 검증한 기록입니다.", "2개의 관련 기록"],
|
||||
["redis", "Redis", "애플리케이션 정책과 Redis 저장 명령의 책임 경계를 검증한 기록입니다.", "1개의 관련 기록"],
|
||||
])("aggregates /topics/%s with exact copy and count", (slug, title, description, count) => {
|
||||
renderDocumentRoute("TECH_LOG_TOPIC", `/topics/${slug}`);
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1, name: title })).toBeVisible();
|
||||
expect(screen.getByText(description)).toBeVisible();
|
||||
expect(screen.getByText(count)).toBeVisible();
|
||||
});
|
||||
|
||||
it("keeps JPA topic records in canonical published order", () => {
|
||||
const { container } = renderDocumentRoute("TECH_LOG_TOPIC", "/topics/jpa");
|
||||
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".public-record-list > li > a"), (link) =>
|
||||
link.getAttribute("href"),
|
||||
),
|
||||
).toEqual([
|
||||
"/cases/collection-fetch-join-pagination",
|
||||
"/references/jpa-list-fetch-strategy",
|
||||
"/questions/collection-fetch-join-with-pagination",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["TECH_LOG_CASE" as const, "/cases/not-registered"],
|
||||
["TECH_LOG_REFERENCE" as const, "/references/not-registered"],
|
||||
["TECH_LOG_QUESTION" as const, "/questions/not-registered"],
|
||||
["TECH_LOG_TOPIC" as const, "/topics/not-registered"],
|
||||
])("uses the registered in-shell Public not-found for %s", (routeId, path) => {
|
||||
const { container, router } = renderDocumentRoute(routeId, path);
|
||||
|
||||
expect(router.state.location.pathname).toBe(path);
|
||||
expect(screen.getByRole("heading", { name: "페이지를 찾을 수 없습니다." })).toBeVisible();
|
||||
expect(screen.queryByRole("heading", { name: "화면을 표시하지 못했습니다." })).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".site-frame")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user