Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ jobs:
- run: pnpm type-check
- run: pnpm test:post-dates
- run: pnpm build
- run: pnpm test:loading
- run: pnpm test:sitemap-lastmod
13 changes: 9 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ React + TypeScript + Vite로 구축된 개인 기술 블로그. GitHub Pages 커
│ ├── main.tsx # 엔트리포인트 (ThemeProvider > LanguageProvider > App)
│ ├── app-shell.tsx # SSR/CSR 공통 Provider shell
│ ├── App.tsx # RouterProvider 생성
│ ├── routes.tsx # 클라이언트 라우트 + 현재 경로 preload
│ ├── routes.tsx # 클라이언트 라우트 (lib/route-modules.ts에서 preload 공유)
│ ├── routes.server.tsx # SSG용 정적 라우트
│ ├── entry-server.tsx # React SSR 렌더링 + prerender route metadata
│ ├── layouts/
Expand Down Expand Up @@ -122,9 +122,11 @@ publishDate: "2025-12-01" # optional - 예약 발행
- 컬러 테마: `document.documentElement.dataset.color`로 관리

### Post Data
- `content/posts/*.md` 파일을 `import.meta.glob`으로 eager 로드
- `scripts/post-assets.ts`의 Vite 플러그인으로 목록 메타데이터(`?post-meta`)만 eager 로드
- 글 본문(`?post-body`)은 필요할 때 로드하고, 본문 검색 인덱스는 검색 시 해당 언어만 로드
- `src/lib/posts.ts`에서 frontmatter 파싱, 정렬, 필터링, 검색 제공
- draft/scheduled 글은 production 빌드에서 자동 필터링
- `usePostContent`, `usePostSearchIndex`로 비동기 로드 상태와 데이터 갱신 구독
- draft/scheduled 글은 production 메타데이터·본문·검색 인덱스에서 자동 필터링

### SEO
- `useMetaTags()` 훅으로 페이지별 title, description, OG 태그 설정
Expand All @@ -135,7 +137,9 @@ publishDate: "2025-12-01" # optional - 예약 발행
### Hydrated SSG
- 서버 렌더는 `src/routes.server.tsx`의 정적 라우트로 실제 React UI HTML을 생성
- 글 상세와 프로젝트 상세 경로는 빌드 시 개별 HTML로 프리렌더
- 클라이언트는 `src/routes.tsx`에서 현재 경로 컴포넌트만 hydration 전에 preload하고, 나머지 라우트는 lazy 유지
- `src/lib/route-modules.ts`에서 현재 경로 컴포넌트를 hydration 전에 preload하고, 메뉴 의도/idle 시 다른 라우트를 사전 로드
- 글 상세는 빌드 시 본문을 준비하고 해당 글 하나의 JSON을 HTML에 넣어 hydration 시 본문을 다시 다운로드하지 않음
- 분석 차트는 hydration 이후 로드해 차트 다운로드가 화면 전체 진입을 막지 않음
- `PostPage` 코드블록은 서버에서 안정적인 `<pre>` fallback을 렌더하고 hydration 후 `CodeBlock`/Shiki를 로드
- hydration 관련 변경 후에는 `pnpm build`, `pnpm preview`로 `Loading...`, Suspense hidden segment, React hydration warning이 없는지 확인

Expand All @@ -156,6 +160,7 @@ pnpm build # 프로덕션 빌드 (tsc + client build + SSR build + hydrated
pnpm preview # 빌드 결과 미리보기
pnpm lint # ESLint 실행
pnpm type-check # TypeScript 타입 체크
pnpm test:loading # 빌드 후 로딩 분리, 재시도, SSG 회귀 검사
```

## Deployment
Expand Down
61 changes: 61 additions & 0 deletions docs/navigation-performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# 화면 전환 성능 개선과 검증 기준

## 기준

사용자가 확인한 첫 메뉴 이동 지연을 개선한다. 기존 레이아웃과 기능, 한·영 라우트, 검색 범위, 정적 HTML 본문을 유지한다. 차트 파일 수신 지연이 분석 페이지 전체 진입을 막지 않게 하고, 전환 요청의 진행·실패 상태를 알 수 있게 한다.

## UI 계약

| 대상 | 동작과 검증 기준 |
|---|---|
| 분석 화면 | 제목, 기간 선택, 요약과 글 목록은 차트 모듈을 기다리지 않고 표시한다. 차트 영역의 높이·순서는 유지한다. |
| 차트 로딩 | 기존 Skeleton과 색상 토큰을 사용한다. 한국어 `차트를 불러오는 중…`, 영어 `Loading chart…`를 접근 가능한 상태로 제공한다. |
| 차트 실패 | 차트 영역에 오류와 `다시 시도` / `Try again`을 표시한다. 메뉴 이동과 다른 데이터 조회는 계속 가능해야 한다. |
| 사전 로딩 | 메뉴에 포인터·키보드 포커스가 오면 해당 화면을 준비한다. 요청은 중복하지 않고, 절전·느린 연결에서는 불필요한 백그라운드 다운로드를 줄인다. |
| 전환 대기 | 상단 진행 표시와 스크린리더 상태를 제공한다. 빠른 전환에는 깜박임을 피한다. 오래 걸리면 해당 경로를 새로 열 수 있다. |
| 전환 실패 | 새로 고침 재시도와 홈 이동을 제공한다. 자동 새로 고침 반복은 하지 않는다. |
| 모바일 | 메뉴 닫힘 후 대상 화면 이동, 키보드·터치 동작, 390px 폭에서 가로 넘침을 확인한다. |
| 모션 | 사이드바 메뉴는 스냅샷 전환 없이 즉시 갱신한다. 본문 링크의 짧은 전환 효과는 유지하고 reduced-motion에서는 새 진행 애니메이션을 끈다. |
| 글 데이터 | 목록용 메타데이터와 본문 로드를 분리한다. 본문 검색, 번역 유무, draft·예약 발행 필터, 글 상세 SSG를 보존한다. |

## 검증

1. 타입·lint·기존 날짜/사이트맵/링크 검사를 실행한다.
2. 로드 중복, 실패 후 재시도, 시간 초과를 단위 테스트한다.
3. 프로덕션 빌드에서 분석 라우트의 초기 의존성에 차트 라이브러리가 없는지 확인한다.
4. Chrome의 로컬 preview에서 차트 파일을 20초 지연시키고 그동안 제목·메뉴·본문이 동작하는지 확인한다.
5. 라우트·차트 요청 실패, 복구, 연속 클릭, 뒤로가기, 한국어·영어, 모바일, reduced-motion을 확인한다.
6. 초기 로드의 글 본문 요청 수와 정적 HTML의 글 본문·메타데이터를 비교한다.
7. CDN은 실제 정상 요청의 Cache-Control·HIT/MISS·응답 시간을 읽기 전용으로 점검한다. 확인되지 않은 관리 권한이나 캐시 규칙 변경을 완료로 주장하지 않는다.

## 구현 메모

- 차트 모듈은 화면이 렌더링된 다음 불러온다. 30초 시간 초과는 같은 문서에서 재시도하고, 브라우저가 실패를 캐시하는 모듈 다운로드 오류는 현재 URL을 새로 열어 복구한다.
- 글 상세의 정적 HTML에는 해당 글 하나의 hydration 데이터만 안전하게 직렬화한다. 직접 진입에서 본문을 다시 다운로드할 필요가 없고, 다른 글의 본문은 포함하지 않는다.
- 본문 검색 인덱스는 검색이 필요할 때 해당 언어만 불러온다. 실패하면 제목·설명·태그 기준 결과임을 표시한다.
- 운영 응답 점검: HTML `max-age=600`, Cloudflare `DYNAMIC`; 해시 자산 `max-age=14400`, `REVALIDATED`. 이 관측만으로 과거 21~28초 지연의 원인을 특정할 수 없으며 원격 캐시 정책은 변경하지 않았다.

## 확인 결과 (2026-09-22)

초기 HTML의 module script와 modulepreload JS 파일을 중복 없이 합산했다. 비교 기준은 변경 전 `1d45d565aff349f375c19e5f9d7a7bf3c68380a7`의 Vite 프로덕션 빌드이며, 압축 전 파일 크기다. 실제 네트워크 전송량이나 로딩 시간의 감소율로 해석하지 않는다.

| 항목 | 변경 전 | 변경 후 |
|---|---:|---:|
| 초기 JS 파일 | 85 | 15 |
| 초기 JS 크기 | 1,350,376 bytes | 579,536 bytes |

초기 JS 크기는 57.1% 줄었다. 차트와 글 본문·검색 인덱스는 초기 정적 의존성에서 제외됐다.

Chrome에서 로컬 production preview로 아래 시나리오를 확인했다.

- 차트 파일 요청을 20초 지연: 분석 제목·요약·기간 선택이 표시되고, 7일 선택이 즉시 반영됐다. 요청을 풀면 차트 3개가 표시됐다.
- 차트 실제 다운로드 실패 및 30초 시간 초과: 오류와 재시도를 표시하고 복구했다.
- 메뉴 이동 및 첫 진입의 라우트 파일 실패: 오류 화면에서 마우스로 재시도해 소개 화면으로 복구했다.
- 라우트 대기: 진행 표시, 5초 이후 지연 안내와 대상 경로 링크를 확인했다. 다른 메뉴로 이동하면 이전 진행 표시가 사라졌다.
- 연속 메뉴 클릭: 분석 → 시리즈 → 소개 순서에서 마지막 대상인 소개로 이동했다.
- 본문에만 있는 `Executors.newVirtualThreadPerTaskExecutor`로 한국어·영어 목록 검색과 빠른 검색 결과를 확인했다.
- 글 상세 SPA 이동 시 본문과 목차를 표시했다. 직접 새로고침 시 해당 글의 hydration JSON을 사용하고 본문 JS를 추가 요청하지 않았다. 뒤로가기 시 검색어를 유지했다.
- 영어 번역이 없는 글은 한국어 원문 링크를 안내했다.
- 모바일 390px에서 메뉴를 닫고 글 목록으로 이동했다. 문서 폭도 390px로 가로 넘침이 없었다.
- `prefers-reduced-motion: reduce`에서 진행 표시의 animation이 `none`이었다.
- 빌드로 116개 정적 페이지를 생성했다. 로딩 테스트 7개, 날짜 테스트 6개, 사이트맵 테스트 6개와 내부 글 링크 검사가 통과했다. 타입 검사 통과, lint 오류 0개(기존 경고 16개).
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"type-check": "tsc --noEmit",
"check:internal-links": "node scripts/check-internal-post-links.mjs",
"test:post-dates": "node --test scripts/post-dates.test.mjs",
"test:loading": "node --test scripts/async-loader.test.mjs scripts/loading.test.mjs",
"test:sitemap-lastmod": "node --test scripts/sitemap-lastmod.test.mjs"
},
"dependencies": {
Expand Down
37 changes: 37 additions & 0 deletions scripts/async-loader.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import assert from "node:assert/strict"
import test from "node:test"
import { createRetryableLoader } from "../src/lib/async-loader.ts"

test("prefetch and navigation share one request and reuse the result", async () => {
let calls = 0
let resolve
const load = createRetryableLoader(() => {
calls++
return new Promise((done) => { resolve = done })
})
const prefetch = load()
const navigation = load()
assert.equal(prefetch, navigation)
await Promise.resolve()
resolve("ready")
assert.equal(await navigation, "ready")
assert.equal(await load(), "ready")
assert.equal(calls, 1)
})

test("a rejected loader promise is cleared before retry", async () => {
let calls = 0
const load = createRetryableLoader(async () => {
if (++calls === 1) throw new Error("offline")
return "recovered"
})
await assert.rejects(load(), /offline/)
assert.equal(await load(), "recovered")
})

test("a stalled request times out and can be retried", async () => {
let calls = 0
const load = createRetryableLoader(() => ++calls === 1 ? new Promise(() => {}) : Promise.resolve("ready"), 20)
await assert.rejects(load(), /timed out/)
assert.equal(await load(), "ready")
})
57 changes: 57 additions & 0 deletions scripts/loading.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import assert from "node:assert/strict"
import fs from "node:fs"
import test from "node:test"
import { isPublished, postMetadataSource } from "./post-assets.ts"
import { parseFrontmatter } from "../src/lib/post-frontmatter.ts"

test("metadata keeps headers and reading time without article content", () => {
const body = "본문에만있는검색어 ".repeat(450)
const raw = `---\ntitle: "A title"\ndate: "2026-09-01"\nupdated: "2026-09-02"\ntags: ["a"]\n---\n${body}`
const metadata = postMetadataSource(raw)
const parsed = parseFrontmatter(metadata)
assert.equal(parsed.data.title, "A title")
assert.deepEqual(parsed.data.tags, ["a"])
assert.equal(parsed.data.readingMinutes, "3")
assert.equal(parsed.content, "")
assert.ok(!metadata.includes("본문에만있는검색어"))
})

test("production indices exclude drafts and future posts", () => {
const raw = (extra) => `---\ntitle: "Post"\n${extra}\n---\ncontent`
assert.equal(isPublished(raw('draft: true'), "2026-09-22"), false)
assert.equal(isPublished(raw('draft: "true"'), "2026-09-22"), false)
assert.equal(isPublished(raw('publishDate: "2026-09-23"'), "2026-09-22"), false)
assert.equal(isPublished(raw('publishDate: "2026-09-22"'), "2026-09-22"), true)
assert.equal(isPublished(raw('draft: false'), "2026-09-22"), true)
})

const manifest = JSON.parse(fs.readFileSync(new URL("../dist/.vite/manifest.json", import.meta.url), "utf8"))
function staticImports(key, visited = new Set()) {
if (visited.has(key)) return visited
assert.ok(manifest[key], `Missing build manifest entry: ${key}`)
visited.add(key)
for (const dependency of manifest[key].imports ?? []) staticImports(dependency, visited)
return visited
}

test("initial page and analytics shell do not wait for chart or body/search chunks", () => {
for (const entry of ["index.html", "src/pages/AnalyticsPage.tsx"]) {
const imports = [...staticImports(entry)]
assert.deepEqual(imports.filter((key) => /AnalyticsCharts|post-body|post-search|_chart-/.test(key)), [])
}
assert.ok(manifest["src/components/AnalyticsCharts.tsx"]?.isDynamicEntry)
assert.ok(manifest["virtual:post-search/ko"]?.isDynamicEntry)
assert.ok(manifest["virtual:post-search/en"]?.isDynamicEntry)
})

test("prerendered article keeps its body while analytics has stable chart placeholders", () => {
const article = fs.readFileSync(new URL("../dist/posts/spring-ai-cs-automation/index.html", import.meta.url), "utf8")
assert.match(article, /<h1/)
assert.match(article, /class="prose/)
assert.match(article, /배경과 운영 경계/)
assert.match(article, /id="post-hydration-data"/)
assert.ok(!article.includes("본문을 불러오는 중"))
const analytics = fs.readFileSync(new URL("../dist/analytics/index.html", import.meta.url), "utf8")
assert.match(analytics, /차트를 불러오는 중/)
assert.ok(!analytics.includes('id="S:'))
})
61 changes: 61 additions & 0 deletions scripts/post-assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import fs from "node:fs"
import path from "node:path"
import type { Plugin } from "vite"
import { parseFrontmatter } from "../src/lib/post-frontmatter.ts"
import { getReadingMinutes } from "../src/lib/reading-time.ts"

export function isPublished(raw: string, today: string) {
const { data } = parseFrontmatter(raw)
return data.draft !== "true" && data.draft !== true && !(data.publishDate && String(data.publishDate) > today)
}

export function postMetadataSource(raw: string) {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/)
if (!match) return raw
const { content } = parseFrontmatter(raw)
return `---\n${match[1]}\nreadingMinutes: ${getReadingMinutes(content)}\n---\n`
}

export function postAssetsPlugin(): Plugin {
let root = ""
let production = false
return {
name: "post-metadata-and-search",
enforce: "pre",
configResolved(config) { root = config.root; production = config.isProduction },
resolveId(id) {
if (/^virtual:post-search\/(ko|en)$/.test(id)) return `\0${id}`
},
load(id) {
const language = id.match(/^\0virtual:post-search\/(ko|en)$/)?.[1]
const today = new Date().toISOString().slice(0, 10)
if (language) {
const folder = path.join(root, "content/posts", language)
const texts: Record<string, string> = {}
for (const file of fs.readdirSync(folder).filter((file) => file.endsWith(".md"))) {
const filename = path.join(folder, file)
this.addWatchFile(filename)
const raw = fs.readFileSync(filename, "utf8")
if (!production || isPublished(raw, today)) texts[file.slice(0, -3)] = parseFrontmatter(raw).content.toLowerCase()
}
return `export default ${JSON.stringify(texts)}`
}
const [filename, query = ""] = id.split("?")
if (!filename?.endsWith(".md")) return
const params = new URLSearchParams(query)
if (!params.has("post-meta") && !params.has("post-body")) return
this.addWatchFile(filename)
const raw = fs.readFileSync(filename, "utf8")
const result = production && !isPublished(raw, today) ? "" : params.has("post-meta") ? postMetadataSource(raw) : raw
return `export default ${JSON.stringify(result)}`
},
handleHotUpdate(context) {
if (!context.file.endsWith(".md")) return
for (const language of ["ko", "en"]) {
const module = context.server.moduleGraph.getModuleById(`\0virtual:post-search/${language}`)
if (module) context.server.moduleGraph.invalidateModule(module)
}
context.server.ws.send({ type: "full-reload" })
},
}
}
9 changes: 7 additions & 2 deletions scripts/prerender.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { getPrerenderRoutes, render } from "../dist-ssr/entry-server.js"
import { getPrerenderRoutes, render, preparePostContentForPrerender, getPostHydrationData } from "../dist-ssr/entry-server.js"

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(__dirname, "..")
Expand Down Expand Up @@ -95,11 +95,16 @@ function injectAppHtml(templateHtml, appHtml) {
)
}

await preparePostContentForPrerender()
const routes = getPrerenderRoutes()

for (const route of routes) {
const appHtml = await render(route.path)
const html = injectAppHtml(withHead(template, route), appHtml)
let html = injectAppHtml(withHead(template, route), appHtml)
const postData = getPostHydrationData(route.path)
if (postData) {
html = html.replace("</body>", `<script id="post-hydration-data" type="application/json">${safeJsonLd(postData)}</script>\n</body>`)
}
const outPath = outputPathFor(route.path)

fs.mkdirSync(path.dirname(outPath), { recursive: true })
Expand Down
54 changes: 54 additions & 0 deletions src/components/AnalyticsCharts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Bar, BarChart, CartesianGrid, Label, Pie, PieChart, XAxis, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart"
import { useT } from "@/i18n"

export { DailyVisitsChart } from "@/components/DailyVisitsChart"

export function TagDistributionChart({ data }: { data: { tag: string; count: number; fill: string }[] }) {
const t = useT()
const config: ChartConfig = { count: { label: t.analytics.postsCount } }
for (const item of data) config[item.tag] = { label: item.tag, color: item.fill }

return (
<ChartContainer config={config} className="mx-auto aspect-square max-h-[300px]">
<PieChart>
<ChartTooltip content={<ChartTooltipContent nameKey="tag" hideLabel />} />
<Pie data={data} dataKey="count" nameKey="tag" innerRadius={60} strokeWidth={2} stroke="var(--background)">
<Label content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
<tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-3xl font-bold">
{data.reduce((sum, item) => sum + item.count, 0)}
</tspan>
<tspan x={viewBox.cx} y={(viewBox.cy ?? 0) + 24} className="fill-muted-foreground text-sm">
{t.analytics.postsCount}
</tspan>
</text>
)
}
}} />
</Pie>
</PieChart>
</ChartContainer>
)
}

export function MonthlyPostsChart({ data }: { data: { month: string; count: number }[] }) {
const t = useT()
const config = { count: { label: t.analytics.postsCount, color: "var(--primary)" } } satisfies ChartConfig
return (
<ChartContainer config={config} className="h-[200px] w-full">
<BarChart data={data} margin={{ left: 0, right: 0, top: 4, bottom: 0 }}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="month" tickFormatter={(value: string) => {
const [year, month] = value.split("-")
return `${year!.slice(2)}/${month}`
}} tickLine={false} axisLine={false} tickMargin={8} minTickGap={30} />
<YAxis allowDecimals={false} tickLine={false} axisLine={false} tickMargin={4} width={24} />
<ChartTooltip content={<ChartTooltipContent hideLabel={false} />} labelFormatter={(value) => value} />
<Bar dataKey="count" fill="var(--color-count)" radius={[4, 4, 0, 0]} maxBarSize={60} />
</BarChart>
</ChartContainer>
)
}
Loading
Loading