diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 272da2f..d0b7b40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 599b41f..f81ca26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/ @@ -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 태그 설정 @@ -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` 코드블록은 서버에서 안정적인 `
` fallback을 렌더하고 hydration 후 `CodeBlock`/Shiki를 로드
- hydration 관련 변경 후에는 `pnpm build`, `pnpm preview`로 `Loading...`, Suspense hidden segment, React hydration warning이 없는지 확인
@@ -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
diff --git a/docs/navigation-performance.md b/docs/navigation-performance.md
new file mode 100644
index 0000000..53a4590
--- /dev/null
+++ b/docs/navigation-performance.md
@@ -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개).
diff --git a/package.json b/package.json
index bf1f14c..cb645dc 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/scripts/async-loader.test.mjs b/scripts/async-loader.test.mjs
new file mode 100644
index 0000000..ce4e7d3
--- /dev/null
+++ b/scripts/async-loader.test.mjs
@@ -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")
+})
diff --git a/scripts/loading.test.mjs b/scripts/loading.test.mjs
new file mode 100644
index 0000000..969ba3c
--- /dev/null
+++ b/scripts/loading.test.mjs
@@ -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, / 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 = {}
+ 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" })
+ },
+ }
+}
diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs
index f6b853a..6ad0526 100644
--- a/scripts/prerender.mjs
+++ b/scripts/prerender.mjs
@@ -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, "..")
@@ -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("