diff --git a/docs/troubleshooting/2026-08-12-font-loading-optimization.md b/docs/troubleshooting/2026-08-12-font-loading-optimization.md
new file mode 100644
index 0000000..383c38d
--- /dev/null
+++ b/docs/troubleshooting/2026-08-12-font-loading-optimization.md
@@ -0,0 +1,95 @@
+# Home 검색 결과의 손글씨 폰트가 늦게 바뀐다
+
+- 날짜: 2026-08-12
+- 관련 이슈키: Jira-S15P11A705-442
+
+## 증상
+
+Home에서 검색 결과 카드가 나타난 직후 Context가 폴백 서체로 먼저 보이고, 잠시 뒤 손글씨체로
+바뀔 수 있었다. 손글씨체 `nanum-geumeunbohwa.woff2`는 469,032B로 로컬 웹폰트 중 가장 크며,
+검색 전 화면에는 이 서체를 쓰는 요소가 없어서 결과 렌더가 시작된 뒤에야 요청됐다.
+
+## 계측 조건
+
+- Vite 프로덕션 빌드를 매 실행마다 새로운 로컬 포트에서 서빙해 브라우저 HTTP 캐시를 비웠다.
+- HTML은 `no-store`, 해시 자산과 폰트는 운영과 같은 장기 캐시 헤더를 사용했다.
+- 검색 API fixture는 응답을 600ms 지연시켰다.
+- Navigation/Paint/LCP/CLS/Resource Timing과 `document.fonts` 이벤트를 함께 기록했다.
+- 수치는 로컬 단일 실행의 절대 성능 점수가 아니라, 같은 조건에서 요청 시점과 중복 전송 여부를
+ 확인하기 위한 값이다.
+
+한 페이지 안에서는 정상 캐시 정책을 유지해야 한다. 모든 자산에 `no-store`를 붙인 초기 실험은
+React의 preload와 CSS `@font-face` 요청이 같은 URL을 각각 전송하는 것처럼 보이는 거짓
+중복을 만들었다. 올바른 조건에서는 두 번째 Resource Timing 항목의 `transferSize`가 0이었다.
+
+## 원인
+
+`font-display: swap`은 글자를 숨기지 않는 대신, 폰트 요청이 늦으면 폴백 서체가 먼저 그려진다.
+기존 정책은 첫 화면 비용을 줄이는 데는 맞았지만 검색 의도와 결과 렌더 사이의 약 600ms를
+활용하지 못했다.
+
+폰트 자산 전수 확인 결과는 다음과 같다.
+
+| 구분 | 파일 | 크기 | 로딩 정책 |
+| --------- | -------------------------- | -------: | -------------------------- |
+| 본문 | `jeju-gothic.woff2` | 91,552B | HTML 전역 preload |
+| Home 조판 | `jalnan2.woff2` | 186,356B | 실제 사용 시 요청 |
+| Home 표지 | `jeju-myeongjo.woff2` | 241,132B | 해당 컴포넌트 preload |
+| Context | `nanum-geumeunbohwa.woff2` | 469,032B | 기존에는 실제 사용 시 요청 |
+
+인증된 Home 첫 렌더에서는 제주고딕·잘난체2·제주명조 합계 519,040B(약 507KiB)가 사용된다.
+손글씨체까지 전역 preload하면 검색하지 않는 사용자도 추가 469,032B를 받게 된다.
+
+## 해결
+
+Home 검색 입력이 포커스를 받으면 React DOM `preload()`로 손글씨체를 준비한다.
+
+- 포커스를 검색 결과를 볼 가능성이 높다는 사용자 의도로 사용한다.
+- `fetchPriority: 'low'`로 초기 화면의 핵심 요청보다 우선하지 않게 한다.
+- `as: 'font'`, `type: 'font/woff2'`, `crossOrigin: 'anonymous'`를 `@font-face` 요청과 맞춰
+ 같은 URL의 캐시를 재사용한다.
+- 전역 HTML preload는 추가하지 않아 검색하지 않는 첫 화면의 네트워크 비용을 유지한다.
+
+| 지표 | 변경 전 | 변경 후 |
+| ------------------------- | -----------: | -----------: |
+| 검색 제출 | 302ms | 360ms |
+| 결과 렌더 | 909ms | 967ms |
+| 손글씨 폰트 네트워크 시작 | 913ms | 149ms |
+| 손글씨 폰트 네트워크 종료 | 약 920ms | 약 161ms |
+| 결과 렌더 대비 폰트 준비 | 약 11ms 뒤 | 약 806ms 전 |
+| 손글씨 폰트 실제 전송 | 469,332B 1회 | 469,332B 1회 |
+| CLS | 0.0005 | 0.0005 |
+
+변경 후 CSS가 폰트를 실제로 사용할 때 생긴 두 번째 조회는 `transferSize: 0`으로 캐시를
+재사용했다. 전송량은 늘지 않았고, 결과가 나타나기 전에 네트워크 다운로드가 끝났다.
+
+## 검토했지만 적용하지 않은 방법
+
+### 손글씨체를 HTML에서 전역 preload
+
+가장 이른 요청은 보장하지만 로그인 화면과 검색하지 않는 Home에도 469,032B를 강제로 받게 한다.
+초기 화면 핵심 폰트와 대역폭도 경쟁하므로 적용하지 않았다.
+
+### Home 표지의 제주명조 컴포넌트 preload 제거
+
+정상 캐시 조건에서 기존 preload는 제주명조 요청을 약 12ms 앞당겼고, 이후 CSS 조회는
+`transferSize: 0`이었다. 제거하면 요청만 늦어지고 전송량 이점은 없어 유지했다.
+
+### `unicode-range`로 손글씨체 분할
+
+현재 WOFF2를 한글 범위 두 덩어리로 나눈 실험 결과는 222,300B + 250,852B = 473,152B였다.
+기존 단일 파일 469,032B보다 4,120B 컸고, 대표 Context 문장은 두 범위의 글자를 모두 포함해
+두 요청을 전부 발생시켰다. 더 잘게 나누면 동적 사용자 문장의 조합을 예측하기 어렵고 파일·요청·
+유지보수 비용이 늘어난다. 실제 Context 말뭉치와 운영 RUM 없이 도입하지 않는다.
+
+## 재발 방지
+
+- 폰트 최적화는 파일 크기뿐 아니라 **언제 사용하는지**, 실제 `transferSize`, 폰트 교체 시점을
+ 함께 계측한다.
+- preload URL·CORS·type이 `@font-face`와 일치하는지 테스트한다.
+- 새 폰트를 전역 preload하기 전, 해당 폰트가 없는 첫 화면의 불필요한 전송량을 계산한다.
+- `fonts-src/README.md`의 자산 목록과 재생성 가능 여부를 `public/fonts/` 실제 파일과 맞춘다.
+
+## 관련 이슈키
+
+- S15P11A705-442
diff --git a/docs/troubleshooting/README.md b/docs/troubleshooting/README.md
index 2f15465..e75b5c2 100644
--- a/docs/troubleshooting/README.md
+++ b/docs/troubleshooting/README.md
@@ -52,3 +52,4 @@
- [스택 브랜치가 부모 squash 머지 후 CONFLICTING이 된다](2026-08-07-squash-merge-stacked-branch-rebase.md) — `git rebase --onto origin/dev <머지된 마지막 커밋>`으로 범위를 제외하면 충돌 0건
- ["무료 폰트"여도 서브셋·WOFF2 변환이 라이선스 위반일 수 있다](2026-08-07-free-font-license-blocks-subset-pipeline.md) — 교보·온글잎 반입 불가 판정 경위와 폰트 라이선스 3단계 검증 절차
- [병렬 dev 서버(5174·5175)에서 카카오맵이 안 뜬다](2026-08-07-kakao-sdk-401-on-parallel-dev-ports.md) — 카카오 콘솔에 5173만 등록돼 SDK 401, 코드 회귀로 오인 주의
+- [Home 검색 결과의 손글씨 폰트가 늦게 바뀐다](2026-08-12-font-loading-optimization.md) — 검색 의도 시점 선로딩으로 초기 비용 없이 Context 폰트를 결과보다 먼저 준비
diff --git a/fonts-src/README.md b/fonts-src/README.md
index 61e4e8b..38bee36 100644
--- a/fonts-src/README.md
+++ b/fonts-src/README.md
@@ -1,9 +1,10 @@
# 폰트 원본과 변환 절차
이 디렉터리는 **웹에 서빙되지 않는다.** 원본 TTF와 변환 스크립트만 들어 있다.
-실제로 브라우저가 받는 파일은 `public/fonts/*.woff2` 이고, 그건 여기서 만들어 낸 산출물이다.
+실제로 브라우저가 받는 파일은 `public/fonts/*.woff2`다. 이 디렉터리의 `build.sh`가 만드는
+파일은 아래 표의 **재생성 가능** 4종이며, 잘난체 2종은 원본 TTF 없이 WOFF2 산출물만 보관한다.
-근거: Jira 작업
+근거: Jira S15P11A705-357, S15P11A705-380, S15P11A705-414, S15P11A705-442
## 왜 원본을 그대로 쓰지 않나
@@ -12,23 +13,32 @@ CDN에서 받던 것보다 느려진다 — 폰트를 로컬 번들한 목적("
정반대로 뒤집힌다. 그래서 원본은 서빙되지 않는 이 디렉터리에 두고, 필요한 글자만 뽑아
WOFF2로 압축한 것만 `public/fonts/`로 내보낸다.
-| 원본 | → 산출물 | 크기 | 역할(tailwind 토큰) |
-| ------------------------------ | --------------------------------------- | -------- | ------------------------ |
-| `JejuGothic.ttf` (2.3M) | `public/fonts/jeju-gothic.woff2` | 92K | `font-sans` (본문 기본) |
-| `JejuHallasan.ttf` (6.4M) | `public/fonts/jeju-hallasan.woff2` | 195K | `font-display` (표제) |
-| `JejuMyeongjo.ttf` (9.1M) | `public/fonts/jeju-myeongjo.woff2` | 241K | `font-serif` (인용·서브) |
-| `NanumGeumEunBoHwa.ttf` (4.6M) | `public/fonts/nanum-geumeunbohwa.woff2` | 469K | `font-hand` (Context) |
-| | **합계** | **997K** | |
-
-> 380에서 본문·표제 서체를 제주 3종으로 교체했다(잘난체 2·잘난고딕 → 제주고딕·제주한라산·
-> 제주명조). 구 원본과 산출물, `@font-face`, preload, LICENSE 항목은 전부 제거했다.
+| 원본/재생성 상태 | → 브라우저 산출물 | 정확한 크기 | 역할 |
+| ------------------------------------- | --------------------------------------- | --------------: | ------------------------ |
+| 원본 없음, 사전 생성 WOFF2만 보관 | `public/fonts/jalnan-gothic.woff2` | 153,840 B | 종이 무대 조판 폴백 |
+| 원본 없음, 사전 생성 WOFF2만 보관 | `public/fonts/jalnan2.woff2` | 186,356 B | 종이 무대 조판 |
+| `JejuGothic.ttf` (재생성 가능) | `public/fonts/jeju-gothic.woff2` | 91,552 B | `font-sans` (본문 기본) |
+| `JejuHallasan.ttf` (재생성 가능) | `public/fonts/jeju-hallasan.woff2` | 194,508 B | `font-display` (표제) |
+| `JejuMyeongjo.ttf` (재생성 가능) | `public/fonts/jeju-myeongjo.woff2` | 241,132 B | `font-serif` (인용·서브) |
+| `NanumGeumEunBoHwa.ttf` (재생성 가능) | `public/fonts/nanum-geumeunbohwa.woff2` | 469,032 B | `font-hand` (Context) |
+| | **합계** | **1,336,420 B** | 약 1.27 MiB |
+
+> 380에서 본문·표제 서체를 제주 3종으로 교체하며 잘난체 산출물을 제거했지만, 414에서 종이 무대
+> 조판만 원본 디자인의 잘난체 스택으로 되돌렸다. 이때 WOFF2 산출물은 다시 들어왔으나 원본 TTF와
+> `build.sh` 생성 단계는 복원되지 않았다.
>
> 손글씨체(금은보화)는 교보 손글씨 2025로 바꾸려다 **보류**했다 — 그 폰트의 라이선스가 이
> 디렉터리의 변환 절차 자체(서브셋·포맷 변환·재배포)를 금지한다. 근거는 `public/fonts/LICENSE`
> 하단 "반입 보류" 항목에 있다.
>
-> 첫 화면이 실제로 받는 것은 **본문 서체 1개(92K)** 뿐이다 — 나머지 셋은 그 서체를 쓰는 요소가
-> 화면에 나타날 때만 받는다(`index.html`의 preload 주석 참고).
+> 로그인 화면의 첫 렌더는 본문 서체 1개(91,552B)만 받는다. 인증된 Home 첫 렌더는 현재 조판에
+> 제주고딕·잘난체2·제주명조를 사용하므로 합계 519,040B(약 507KiB)를 받는다. 제주명조는 Home
+> 종이 표지가 보일 때 컴포넌트에서 선로딩한다.
+>
+> 손글씨체 469,032B는 Context가 실제로 표시될 때 요청한다. Home 검색에서는 검색창 포커스를
+> 사용자 의도로 보고 낮은 우선순위로 미리 받지만, 검색하지 않는 첫 화면에는 포함하지 않는다.
+> 계측 근거와 결정 과정은
+> `docs/troubleshooting/2026-08-12-font-loading-optimization.md`에 기록했다.
>
> 서브셋 글리프 커버리지: 제주 3종 모두 완성형 한글 2350자를 전부 포함한다. 요청한 2524자 중
> `®`·`✓`는 원본 폰트에 글리프가 없어 빠진다 — 이 두 글자는 폴백 폰트로 그려진다.
@@ -40,8 +50,9 @@ pip install fonttools brotli # brotli 없이는 --flavor=woff2가 실패한다
./fonts-src/build.sh
```
-산출물은 `public/fonts/`에 덮어쓴다. 결과 파일도 저장소에 커밋한다 — 빌드 파이프라인에
-파이썬 의존을 넣지 않기 위해서다(폰트는 거의 바뀌지 않는다).
+산출물은 `public/fonts/`의 제주고딕·제주한라산·제주명조·금은보화 4개 파일에 덮어쓴다.
+잘난고딕·잘난체2는 원본이 없어 이 스크립트로 재생성되지 않는다. 결과 파일도 저장소에
+커밋한다 — 빌드 파이프라인에 파이썬 의존을 넣지 않기 위해서다(폰트는 거의 바뀌지 않는다).
## 서브셋 범위를 바꾸려면
@@ -60,7 +71,7 @@ pip install fonttools brotli # brotli 없이는 --flavor=woff2가 실패한다
## 라이선스
-`public/fonts/LICENSE`에 세 폰트의 저작권·라이선스 고지를 모아 두었다. **폰트를 추가하거나
+`public/fonts/LICENSE`에 폰트의 저작권·라이선스 고지를 모아 두었다. **폰트를 추가하거나
교체하면 그 파일도 함께 갱신한다.**
`build.sh`는 `--name-IDs`로 저작권(0)·상표(7)·라이선스(13/14) 등을 서브셋 결과물에도
diff --git a/index.html b/index.html
index fc90aaa..8901a0d 100644
--- a/index.html
+++ b/index.html
@@ -8,7 +8,7 @@
하단 고정 탭바(AppLayout)가 홈 인디케이터에 가리지 않으려면 반드시 필요하다. 근거: S15P11A705-330 -->
diff --git a/src/features/home/components/HomeSearchDock.test.tsx b/src/features/home/components/HomeSearchDock.test.tsx
index f314f1f..7ad8443 100644
--- a/src/features/home/components/HomeSearchDock.test.tsx
+++ b/src/features/home/components/HomeSearchDock.test.tsx
@@ -3,6 +3,13 @@ import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { HomeSearchDock, type HomeSearchDockHandle } from './HomeSearchDock';
+const { preloadMock } = vi.hoisted(() => ({ preloadMock: vi.fn() }));
+
+vi.mock('react-dom', async (importOriginal) => ({
+ ...(await importOriginal()),
+ preload: preloadMock,
+}));
+
let container: HTMLDivElement;
let root: Root;
@@ -15,6 +22,7 @@ beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
+ preloadMock.mockClear();
});
afterEach(() => {
@@ -41,5 +49,11 @@ describe('HomeSearchDock', () => {
act(() => ref.current?.focus());
expect(document.activeElement).toBe(container.querySelector('#home-search'));
+ expect(preloadMock).toHaveBeenCalledExactlyOnceWith('/fonts/nanum-geumeunbohwa.woff2', {
+ as: 'font',
+ type: 'font/woff2',
+ crossOrigin: 'anonymous',
+ fetchPriority: 'low',
+ });
});
});
diff --git a/src/features/home/components/HomeSearchDock.tsx b/src/features/home/components/HomeSearchDock.tsx
index 50cc7cb..5423a13 100644
--- a/src/features/home/components/HomeSearchDock.tsx
+++ b/src/features/home/components/HomeSearchDock.tsx
@@ -6,8 +6,25 @@ import {
useState,
type FormEvent,
} from 'react';
+import { preload } from 'react-dom';
import { SEARCH_PLACEHOLDERS } from '../lib/paperAperture';
+const CONTEXT_FONT_URL = '/fonts/nanum-geumeunbohwa.woff2';
+
+/**
+ * 검색창 포커스는 Context 결과를 곧 볼 가능성이 높다는 사용자 의도다. 첫 화면에서는 469KB를
+ * 받지 않고, 입력을 시작할 때 검색 API와 경쟁하지 않도록 낮은 우선순위로 준비한다.
+ * 같은 href의 실제 @font-face 요청은 브라우저 캐시를 재사용한다.
+ */
+function preloadContextFont() {
+ preload(CONTEXT_FONT_URL, {
+ as: 'font',
+ type: 'font/woff2',
+ crossOrigin: 'anonymous',
+ fetchPriority: 'low',
+ });
+}
+
interface HomeSearchDockProps {
query: string;
onQueryChange: (query: string) => void;
@@ -71,6 +88,7 @@ export const HomeSearchDock = forwardRef onQueryChange(event.target.value)}
/>
{/* 사용자가 한 글자라도 치면 사라진다. aria-hidden이라 스크린리더는 라벨만 읽는다. */}