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이라 스크린리더는 라벨만 읽는다. */}