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
95 changes: 95 additions & 0 deletions docs/troubleshooting/2026-08-12-font-loading-optimization.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions docs/troubleshooting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 폰트를 결과보다 먼저 준비
45 changes: 28 additions & 17 deletions fonts-src/README.md
Original file line number Diff line number Diff line change
@@ -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

## 왜 원본을 그대로 쓰지 않나

Expand All @@ -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자 중
> `®`·`✓`는 원본 폰트에 글리프가 없어 빠진다 — 이 두 글자는 폴백 폰트로 그려진다.
Expand All @@ -40,8 +50,9 @@ pip install fonttools brotli # brotli 없이는 --flavor=woff2가 실패한다
./fonts-src/build.sh
```

산출물은 `public/fonts/`에 덮어쓴다. 결과 파일도 저장소에 커밋한다 — 빌드 파이프라인에
파이썬 의존을 넣지 않기 위해서다(폰트는 거의 바뀌지 않는다).
산출물은 `public/fonts/`의 제주고딕·제주한라산·제주명조·금은보화 4개 파일에 덮어쓴다.
잘난고딕·잘난체2는 원본이 없어 이 스크립트로 재생성되지 않는다. 결과 파일도 저장소에
커밋한다 — 빌드 파이프라인에 파이썬 의존을 넣지 않기 위해서다(폰트는 거의 바뀌지 않는다).

## 서브셋 범위를 바꾸려면

Expand All @@ -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) 등을 서브셋 결과물에도
Expand Down
10 changes: 6 additions & 4 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
하단 고정 탭바(AppLayout)가 홈 인디케이터에 가리지 않으려면 반드시 필요하다. 근거: S15P11A705-330 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<!-- 357: 외부 폰트 <link>(jsDelivr Pretendard, Google Fonts 손글씨체)와 preconnect를 모두
제거했다. 세 서체 모두 public/fonts/의 서브셋 WOFF2로 번들해 같은 오리진에서 받는다.
제거했다. 웹폰트는 public/fonts/의 로컬 WOFF2로 번들해 같은 오리진에서 받는다.
@font-face 선언은 src/index.css에 있다.

본문 서체만 preload한다. 폰트는 CSS를 받아 파싱하고, 그 폰트를 쓰는 요소가 실제로
Expand All @@ -17,9 +17,11 @@
crossorigin이 빠지면 폰트를 두 번 받는다(preload는 CORS 모드로 요청되므로 같은 오리진
이어도 반드시 붙여야 캐시가 재사용된다).

나머지 셋은 일부러 preload하지 않는다 — 표제용 제주한라산(195K)·인용용 제주명조(241K)는
쓰는 화면에서만 요청하고, 손글씨체(469K)는 포스트잇 전용이라 첫 화면에 없는 경우가 많다.
전부 preload하면 정작 필요한 본문 서체와 대역폭을 다투어 초기 렌더가 밀린다.
나머지는 전역 preload하지 않는다. 제주한라산·잘난체·제주명조는 사용하는 화면에서만
요청한다(Home 표지의 제주명조는 해당 컴포넌트가 선로딩한다). 손글씨체(469K)는 Context
전용이라 첫 화면에 없는 경우가 많고, Home에서는 검색창 포커스 때 낮은 우선순위로
준비한다. 전부 전역 preload하면 정작 필요한 본문 서체와 대역폭을 다투어 초기 렌더가
밀린다. 계측 근거: docs/troubleshooting/2026-08-12-font-loading-optimization.md.

380: 본문 서체가 잘난고딕(154K)에서 제주고딕(92K)으로 바뀌면서 이 preload 한 건의
전송량도 그만큼 줄었다. -->
Expand Down
14 changes: 14 additions & 0 deletions src/features/home/components/HomeSearchDock.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('react-dom')>()),
preload: preloadMock,
}));

let container: HTMLDivElement;
let root: Root;

Expand All @@ -15,6 +22,7 @@ beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
preloadMock.mockClear();
});

afterEach(() => {
Expand All @@ -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',
});
});
});
18 changes: 18 additions & 0 deletions src/features/home/components/HomeSearchDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -71,6 +88,7 @@ export const HomeSearchDock = forwardRef<HomeSearchDockHandle, HomeSearchDockPro
autoComplete="off"
spellCheck={false}
value={query}
onFocus={preloadContextFont}
onChange={(event) => onQueryChange(event.target.value)}
/>
{/* 사용자가 한 글자라도 치면 사라진다. aria-hidden이라 스크린리더는 라벨만 읽는다. */}
Expand Down
Loading