타이포그래피 스킬 신설 + 폴백 메트릭 보정

'폰트 관련된 것도 스킬이 좀 잘 해줘야 하는데 그렇지 못한 것 같다'는 지적.
점검해보니 size-adjust, ascent-override, unicode-range, OpenType 기능,
FOUT/FOIT, 폰트 라이선스가 스킬 어디에도 없었다. font-display 와 서브셋은
tokens.md 에 한 줄씩뿐이었다.

references/typography.md 신설
- 고르는 절차: 개수를 먼저 정하고, 성격을 브리프에서 끌어내고,
  x-height·대비·웨이트 범위·숫자 형태를 실제로 재서 검증한다
- 가변 폰트: 축, 표준 속성 우선, 가변 축 애니메이션이 왜 게이트 8 위반인지
- 로딩: font-display 값별 선택 기준, preload 조건, unicode-range
- 폴백 메트릭: size-adjust 와 override 3종. 재는 코드 포함
- OpenType: tabular-nums 등. font-variant-* 를 font-feature-settings 보다 먼저
- 한글: 동적 서브셋 원리, 웨이트가 라틴과 다르게 보이는 이유, 스택 순서
- 라이선스: 웹 임베딩 허용 여부가 상업 이용 허용과 다르다는 것

그리고 이 페이지가 그 규칙을 어기고 있었다
- 실측: 맑은 고딕이 Pretendard 보다 13.0% 넓다. 교체 순간 줄바꿈이 다시 잡힌다
- fonts.css 신설. OS 별로 폴백을 따로 정의하고 size-adjust 로 맞췄다
- 실측 차이 13.0% -> 0.0%

preflight.md 에 폰트 검사 6개를 추가했다. 지금까지 하나도 없었다.
This commit is contained in:
Yun Chan 2026-08-20 12:42:59 +09:00
parent 5f8316f336
commit eb3ca69b5d
9 changed files with 389 additions and 7 deletions

View file

@ -258,6 +258,11 @@ export const ko: Content = {
h2: "이 페이지를 만들며 실제로 걸린 것", h2: "이 페이지를 만들며 실제로 걸린 것",
lead: "남을 검사한 결과가 아니라 자신을 검사한 결과다.", lead: "남을 검사한 결과가 아니라 자신을 검사한 결과다.",
items: [ items: [
{
caught: "폰트가 교체되는 순간 글자 폭이 13% 튀고 있었다",
fixed:
"웹폰트가 오기 전에는 시스템 폰트로 그려진다. 맑은 고딕은 Pretendard 보다 13% 넓어서 교체되는 순간 줄바꿈이 다시 잡힌다. 폴백 폰트에 size-adjust 로 치수를 맞췄다. 실측 차이 0.0%. OS 마다 시스템 폰트가 다르므로 폴백도 따로 정의했다.",
},
{ {
caught: "유리 카드를 29개 뿌렸다. 재질만 바뀌었을 뿐 구조는 카드 나열 그대로였다", caught: "유리 카드를 29개 뿌렸다. 재질만 바뀌었을 뿐 구조는 카드 나열 그대로였다",
fixed: fixed:
@ -297,7 +302,7 @@ export const ko: Content = {
fixed: "그리드의 본문 폭을 토큰에 묶었다. 스킬 문서의 예시 코드도 같이 고쳤다.", fixed: "그리드의 본문 폭을 토큰에 묶었다. 스킬 문서의 예시 코드도 같이 고쳤다.",
}, },
], ],
note: "여덟 건 모두 만들면서 걸린 것이고, 걸린 뒤에 고쳤다. 마지막 한 건은 기계가 아니라 사람이 잡았다. 카드가 전형적이라는 말 한 줄이었다.", note: "아홉 건 모두 만들면서 걸린 것이고, 걸린 뒤에 고쳤다. 사람이 잡은 것이 둘이다. 카드가 전형적이라는 말과, 폰트를 제대로 다루지 못한다는 말.",
}, },
typography: { typography: {
eyebrow: "한글 조판", eyebrow: "한글 조판",
@ -452,6 +457,11 @@ export const en: Content = {
h2: "What this page actually got caught on", h2: "What this page actually got caught on",
lead: "Not the result of auditing someone else. The result of auditing itself.", lead: "Not the result of auditing someone else. The result of auditing itself.",
items: [ items: [
{
caught: "Text width jumped 13% at the moment the font swapped",
fixed:
"Before the web font arrives the system font draws instead, and Malgun Gothic runs 13% wider than Pretendard, so line breaks are recalculated on swap. Matched the fallback with size-adjust. Measured difference is now 0.0%. System fonts differ per OS, so each gets its own fallback.",
},
{ {
caught: "29 glass cards. Only the material had changed. The structure was still a row of cards", caught: "29 glass cards. Only the material had changed. The structure was still a row of cards",
fixed: fixed:
@ -492,7 +502,7 @@ export const en: Content = {
"Bound the grid's main column to the token. The skill's own example code was wrong too, so that got fixed as well.", "Bound the grid's main column to the token. The skill's own example code was wrong too, so that got fixed as well.",
}, },
], ],
note: "All eight were caught while building this page, and fixed after being caught. The last one came from a person, not a checker. One line: the cards look generic.", note: "All nine were caught while building this page, and fixed after being caught. Two came from a person rather than a checker: the cards look generic, and the typography is not handled well.",
}, },
typography: { typography: {
eyebrow: "Your language is probably not Latin", eyebrow: "Your language is probably not Latin",

View file

@ -1,4 +1,5 @@
--- ---
import "../styles/fonts.css";
import "../styles/tokens.css"; import "../styles/tokens.css";
import "../styles/base.css"; import "../styles/base.css";

View file

@ -0,0 +1,48 @@
/* ============================================================
폰트 폴백 메트릭 보정
웹폰트가 늦게 오면 브라우저는 시스템 폰트로 먼저 그리고 나중에 교체한다.
폰트의 폭과 높이가 다르면 순간 레이아웃이 튄다. CLS 흔한 원인이고
font-display: swap 쓰는 반드시 일어난다.
그래서 폴백 폰트를 실제 폰트의 치수에 맞춘다.
아래 숫자는 페이지에서 직접 값이다. 추정치가 아니다.
한글 Pretendard 1434px vs 맑은 고딕 1620px -> 88.5%
Apple SD Gothic Neo 1510px -> 94.9%
영문 Switzer 1580px vs Arial 1501px -> 105.3%
재는 법은 references/typography.md §4 있다.
OS 마다 시스템 폰트가 다르므로 폴백도 따로 정의한다. 하나로 뭉치면
한쪽 OS 에서는 보정이 틀린다.
============================================================ */
/* ---- 한글: Windows ---------------------------------------- */
@font-face {
font-family: "KO Fallback Win";
src: local("Malgun Gothic"), local("맑은 고딕");
size-adjust: 88.5%;
ascent-override: 108%;
descent-override: 27%;
line-gap-override: 0%;
}
/* ---- 한글: macOS ------------------------------------------ */
@font-face {
font-family: "KO Fallback Mac";
src: local("Apple SD Gothic Neo"), local("AppleSDGothicNeo-Regular");
size-adjust: 94.9%;
ascent-override: 100%;
descent-override: 25%;
line-gap-override: 0%;
}
/* ---- 라틴 -------------------------------------------------- */
@font-face {
font-family: "EN Fallback";
src: local("Arial"), local("Helvetica Neue"), local("Helvetica");
size-adjust: 105.3%;
ascent-override: 110%;
descent-override: 28%;
line-gap-override: 0%;
}

View file

@ -95,7 +95,8 @@
/* ---- 타입: 한국어판 --------------------------------------- */ /* ---- 타입: 한국어판 --------------------------------------- */
[lang="ko"] { [lang="ko"] {
--font-sans: "Pretendard Variable", Pretendard, system-ui, sans-serif; --font-sans: "Pretendard Variable", Pretendard,
"KO Fallback Win", "KO Fallback Mac", system-ui, sans-serif;
--step-0: 1.0625rem; --step-0: 1.0625rem;
--step-1: 1.328rem; --step-1: 1.328rem;
@ -119,7 +120,7 @@
/* ---- 타입: 영어판 ------------------------------------------ */ /* ---- 타입: 영어판 ------------------------------------------ */
[lang="en"] { [lang="en"] {
--font-sans: "Switzer", system-ui, -apple-system, sans-serif; --font-sans: "Switzer", "EN Fallback", system-ui, -apple-system, sans-serif;
--step-0: 1rem; --step-0: 1rem;
--step-1: 1.25rem; --step-1: 1.25rem;

View file

@ -134,6 +134,7 @@ R1과 R2를 같은 업종에서 고르면 결과는 그 업종의 평균이 된
구현 전에 **숫자를 먼저 정한다.** 코드를 쓰면서 색을 고르면 매번 다른 색이 나온다. 구현 전에 **숫자를 먼저 정한다.** 코드를 쓰면서 색을 고르면 매번 다른 색이 나온다.
- 타입 스케일 · 색 역할 · 간격 리듬 · 모션 문법 → `references/tokens.md` - 타입 스케일 · 색 역할 · 간격 리듬 · 모션 문법 → `references/tokens.md`
- **폰트를 고르고 싣는 법**`references/typography.md`. 폰트는 값이 아니라 결정이다. 로딩·폴백 메트릭·라이선스가 여기 있다
- 한글이 들어가면 → `references/antipatterns.md` 의 한글 조판 섹션을 **반드시** 읽어라. 서구 레퍼런스에는 이 정보가 없다 - 한글이 들어가면 → `references/antipatterns.md` 의 한글 조판 섹션을 **반드시** 읽어라. 서구 레퍼런스에는 이 정보가 없다
**성능 예산도 여기서 정한다.** 나중에 정하면 이미 늦는다. 전체 표는 `references/tokens.md` §5 하나뿐이다 — 다른 문서에 예산 표를 만들지 마라. **성능 예산도 여기서 정한다.** 나중에 정하면 이미 늦는다. 전체 표는 `references/tokens.md` §5 하나뿐이다 — 다른 문서에 예산 표를 만들지 마라.
@ -149,7 +150,7 @@ R1과 R2를 같은 업종에서 고르면 결과는 그 업종의 평균이 된
순서가 있다. **레이아웃 → 재질 → 모션.** 거꾸로 가면 화려한데 읽을 수 없는 페이지가 나온다. 순서가 있다. **레이아웃 → 재질 → 모션.** 거꾸로 가면 화려한데 읽을 수 없는 페이지가 나온다.
**4-1. 레이아웃과 타이포그래피** → `references/layout.md` **4-1. 레이아웃과 타이포그래피** → `references/layout.md` (+ 폰트 적용은 `references/typography.md` §5, §6)
그리드, 여백 리듬, 시선 흐름. 3단계의 토큰을 그대로 쓴다. 이 단계가 끝나면 **아무 이펙트 없이도 완성된 페이지**여야 한다. 이것이 모든 폴백의 기반이다. 그리드, 여백 리듬, 시선 흐름. 3단계의 토큰을 그대로 쓴다. 이 단계가 끝나면 **아무 이펙트 없이도 완성된 페이지**여야 한다. 이것이 모든 폴백의 기반이다.
**4-2. 재질(surface)** → `references/svg-filters.md` **4-2. 재질(surface)** → `references/svg-filters.md`
@ -245,6 +246,7 @@ HTML-in-Canvas(`drawElementImage`)는 **폴백을 완성한 뒤에만** 얹는
| `references/reference-method.md` | 1단계 — 레퍼런스를 뜯어볼 때 | | `references/reference-method.md` | 1단계 — 레퍼런스를 뜯어볼 때 |
| `references/presets/README.md` | 2단계 — 미학 방향을 고를 때 (고른 프리셋 하나만 추가로 읽는다) | | `references/presets/README.md` | 2단계 — 미학 방향을 고를 때 (고른 프리셋 하나만 추가로 읽는다) |
| `references/tokens.md` | 3단계 — 토큰을 정할 때 | | `references/tokens.md` | 3단계 — 토큰을 정할 때 |
| `references/typography.md` | 3단계 — 폰트를 고를 때 / 4-1 — 적용할 때 |
| `references/layout.md` | 4-1 — 그리드와 타이포 | | `references/layout.md` | 4-1 — 그리드와 타이포 |
| `references/svg-filters.md` | 4-2 — 재질을 만들 때 | | `references/svg-filters.md` | 4-2 — 재질을 만들 때 |
| `references/three.md` | 4-3 — 입체가 필요할 때 | | `references/three.md` | 4-3 — 입체가 필요할 때 |

View file

@ -60,6 +60,7 @@
## 3. 타이포그래피 ## 3. 타이포그래피
토큰은 3단계에서 정했다. 여기서는 **적용**이다. 토큰은 3단계에서 정했다. 여기서는 **적용**이다.
폰트 자체의 로딩·기능·폴백은 `references/typography.md` 를 보라.
### 위계는 3단계면 충분하다 ### 위계는 3단계면 충분하다
디스플레이 / 섹션 제목 / 본문. h4, h5, h6 까지 시각적으로 구분하려 들면 위계가 무너진다. 필요하면 **웨이트나 색**으로 구분해라, 새 크기를 만들지 말고. 디스플레이 / 섹션 제목 / 본문. h4, h5, h6 까지 시각적으로 구분하려 들면 위계가 무너진다. 필요하면 **웨이트나 색**으로 구분해라, 새 크기를 만들지 말고.

View file

@ -74,6 +74,15 @@ canvas { display: none !important; }
**측정하지 않았으면 통과가 아니다.** 예산을 넘겼다면 둘 중 하나다: 이펙트를 빼거나, 예산을 올린 이유를 명시하거나. **측정하지 않았으면 통과가 아니다.** 예산을 넘겼다면 둘 중 하나다: 이펙트를 빼거나, 예산을 올린 이유를 명시하거나.
### 폰트 (`typography.md` §8 이 전체 목록)
- [ ] **폴백 메트릭을 보정했거나 `font-display: optional` 을 골랐다.** 둘 다 안 했으면 폰트가 교체되는 순간 레이아웃이 튄다
- [ ] preload 한 파일이 첫 화면에 실제로 쓰인다. `crossorigin` 이 있다
- [ ] 수치를 나열하는 곳에 `tabular-nums` 가 있다
- [ ] 한글 폰트를 명시했고 서브셋을 쓴다
- [ ] 웹폰트 임베딩 라이선스를 확인해 `design.md` 에 적었다
- [ ] **폰트를 못 받은 상태로 열어봤다.** 그 상태로도 읽힌다
### 자주 걸리는 것 ### 자주 걸리는 것
- 폰트 서브셋 안 함 (한글 전체 폰트는 수 MB) - 폰트 서브셋 안 함 (한글 전체 폰트는 수 MB)
- 첫 화면 밖 이미지에 `loading="lazy"` 누락 - 첫 화면 밖 이미지에 `loading="lazy"` 누락

View file

@ -50,7 +50,10 @@
| `--measure` | 한 줄 길이. 라틴 60~75자, **한글 25~40자** | | `--measure` | 한 줄 길이. 라틴 60~75자, **한글 25~40자** |
| 자간 | 큰 글자에만 음수(`-0.02em` 정도). **한글에는 음수 자간 금지** | | 자간 | 큰 글자에만 음수(`-0.02em` 정도). **한글에는 음수 자간 금지** |
**폰트는 최대 2종.** 세 번째 폰트를 넣고 싶다면 그건 위계를 웨이트로 못 만들고 있다는 신호다. **폰트는 최대 2종**(+ 코드용 모노 1). 세 번째 텍스트 폰트를 넣고 싶다면 위계를 웨이트로 못 만들고 있다는 신호다.
> **어떤 폰트를 어떻게 고르고 싣는지는 `typography.md` 가 정본이다.** 여기서는 스케일 값만 정한다.
> 로딩 전략, 폴백 메트릭 보정, OpenType 기능, 한글 서브셋, 라이선스 확인이 그 문서에 있다.
--- ---
@ -212,7 +215,7 @@ Lighthouse 를 쓸 수 있으면 그쪽이 더 정확하다 — 단 모바일
### 예산을 지키는 기본 수단 ### 예산을 지키는 기본 수단
- 폰트: `font-display: swap`, 서브셋(한글은 필수 — 전체 한글 폰트는 수 MB다), `preload` 는 실제로 첫 화면에 쓰는 것만 - 폰트: 상세는 `typography.md` §3~§4. 요약하면 `font-display` 를 의식적으로 고르고, preload 는 첫 화면에 실제로 쓰는 파일만, 폴백 메트릭을 보정하거나 `optional` 을 쓴다
- 이미지: 실제 표시 크기의 2배까지만, AVIF/WebP, 첫 화면 밖은 `loading="lazy"` - 이미지: 실제 표시 크기의 2배까지만, AVIF/WebP, 첫 화면 밖은 `loading="lazy"`
- JS: 첫 화면에 필요 없는 것은 전부 지연 로드. 3D는 뷰포트 진입 시 동적 import - JS: 첫 화면에 필요 없는 것은 전부 지연 로드. 3D는 뷰포트 진입 시 동적 import
- **측정하지 않은 최적화는 하지 마라.** 대신 예산을 넘겼는지는 반드시 측정해라 - **측정하지 않은 최적화는 하지 마라.** 대신 예산을 넘겼는지는 반드시 측정해라

View file

@ -0,0 +1,307 @@
# typography — 폰트를 고르고 싣는 법
3단계에서 폰트를 정할 때, 4-1에서 적용할 때 읽는다.
**타입 스케일과 간격은 `tokens.md` 가 다룬다.** 이 문서는 **어떤 글자를 쓰고 그것을 어떻게 화면에 올리는가**만 다룬다.
## 이 문서를 읽는 법
| 상황 | 읽을 곳 |
|---|---|
| 폰트를 아직 안 골랐다 | §1 선택 절차. 대부분 여기서 끝난다 |
| 웨이트를 두 개 이상 쓴다 | §2 가변 폰트 |
| 로딩이 느리거나 글자가 늦게 뜬다 | §3 로딩 |
| 폰트 로드 전후로 레이아웃이 흔들린다 | §4 폴백 메트릭. **가장 자주 빠뜨리는 곳이다** |
| 숫자가 흔들리거나 자간이 이상하다 | §5 OpenType |
| 한글이 들어간다 | §6. 조판 규칙 자체는 `antipatterns.md` §8 |
| 배포 전 | §7 라이선스, §8 체크리스트 |
---
## 1. 고르는 절차
### 1-1. 먼저 개수를 정한다
**두 벌이면 충분하다.** 디스플레이 하나, 본문 하나. 코드나 수치가 있으면 모노 하나를 더한다.
세 번째 텍스트 폰트를 넣고 싶다면 **위계를 웨이트로 못 만들고 있다는 신호다.** 가변 폰트 한 벌의 300~700 구간이 서로 다른 폰트 두 벌보다 넓은 표현을 준다.
### 1-2. 성격을 브리프에서 끌어낸다
폰트를 고르기 전에 **한 줄로 답한다**: 이 글자는 무엇처럼 보여야 하는가.
| 브리프가 요구하는 것 | 글자에서 찾을 성질 |
|---|---|
| 정확함, 계기, 데이터 | 좁은 폭, 낮은 대비, 열린 카운터, 모노와 잘 붙는 것 |
| 편집, 읽는 시간, 권위 | 세리프 또는 고대비 산세, 넉넉한 x-height |
| 도구, 중립, 신뢰 | 그로테스크. 성격은 웨이트와 여백이 만든다 |
| 인상, 기억 | 디스플레이 전용 서체. **본문에는 절대 쓰지 않는다** |
### 1-3. 후보를 실제 값으로 검증한다
이름과 인상으로 고르지 마라. 다음 넷을 본다.
- **x-height 비율** — 소문자 높이 ÷ 대문자 높이. 0.52 미만이면 작은 크기에서 흐려진다
- **대비** — 획 굵기 차이. 큰 크기에서만 살아나는 폰트가 있다
- **웨이트 범위** — 필요한 웨이트가 실제로 있는가. 없는 웨이트를 브라우저가 합성하면 뭉개진다
- **숫자 형태** — 라이닝인가 올드스타일인가, tabular 가 있는가
브라우저에서 재는 법:
```js
// 실제 렌더된 폰트가 무엇인지부터 확인한다. 폴백으로 떨어졌는데 모르는 경우가 많다
document.fonts.check('16px "Pretendard Variable"');
[...document.fonts].map(f => `${f.family} ${f.weight} ${f.status}`);
```
### 1-4. 기본값을 피한다
`Inter` 는 금지가 아니라 **기본값 금지**다. 다국어 UI나 초고밀도 대시보드처럼 이유가 있으면 쓴다. 이유 없이 쓰면 그건 고른 게 아니다.
같은 이유로 `Fraunces``Instrument Serif` 도 기본 선택에서 뺀다. 생성 도구가 가장 자주 뱉는 두 서체다.
> **못 대면 기본값이다.** "왜 이 폰트인가"에 한 문장으로 답하지 못하면 아직 고른 것이 아니다.
---
## 2. 가변 폰트
웨이트를 **두 개 이상** 쓰면 거의 항상 가변이 유리하다. 정적 폰트 두 벌보다 가변 한 벌이 작고, 중간 웨이트를 공짜로 얻는다.
### 축
| 축 | 뜻 | 쓸 곳 |
|---|---|---|
| `wght` | 굵기 | 위계. 가장 많이 쓴다 |
| `opsz` | 광학 크기 | 큰 글자와 작은 글자의 획 대비를 자동 조정 |
| `wdth` | 폭 | 좁은 컬럼, 긴 제목 |
| `slnt` / `ital` | 기울기 | 강조 |
### 쓰는 법
```css
/* 표준 속성을 쓴다. font-variation-settings 는 마지막 수단이다 */
h1 { font-weight: 640; } /* 100 단위가 아니어도 된다 */
.lead { font-weight: 450; }
/* opsz 가 있으면 자동 적용된다. 끄지 마라 */
h1 { font-optical-sizing: auto; }
/* 표준 속성으로 못 여는 축만 이걸로 */
.wide { font-variation-settings: "wdth" 112; }
```
**`font-variation-settings``wght` 를 지정하지 마라.** 상속이 끊기고 `font-weight` 와 충돌한다.
### 애니메이션
가변 축은 애니메이션할 수 있지만 **레이아웃을 다시 계산한다.** 하드 게이트 8이 금지하는 종류다. 글자 굵기를 움직이고 싶으면 `opacity` 로 두 겹을 교차시켜라.
---
## 3. 로딩
### 3-1. `font-display` 를 고른다
| 값 | 동작 | 언제 |
|---|---|---|
| `swap` | 즉시 폴백, 로드되면 교체 | **본문 기본값.** 글자가 먼저 읽힌다 |
| `optional` | 아주 짧게 기다리고, 늦으면 이번 방문은 폴백으로 끝 | CLS 를 0으로 만들고 싶을 때. 첫 방문에 브랜드 서체가 안 보일 수 있다 |
| `fallback` | 짧은 블록 + 짧은 교체 창 | 절충안 |
| `block` | 최대 3초 블록 | **아이콘 폰트 외에는 쓰지 마라** |
### 3-2. `preload` 는 조건부다
**첫 화면에 실제로 그려지는 폰트 파일만** preload 한다. 그 외에는 다른 자원의 대역을 뺏을 뿐이다.
```html
<!-- 본문 한 벌만. crossorigin 을 빠뜨리면 두 번 받는다 -->
<link rel="preload" as="font" type="font/woff2" crossorigin
href="/fonts/body-var.woff2" />
```
모노는 보통 코드 블록에만 쓰이므로 preload 하지 않는다.
### 3-3. 서브셋
라틴은 `unicode-range` 로 쪼갠다. 브라우저가 **실제로 쓰인 범위만** 받는다.
```css
@font-face {
font-family: "Body";
src: url("/fonts/body-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+2000-206F, U+2190-21BB;
font-display: swap;
}
```
**한글은 §6 을 보라.** 범위가 아니라 글자 단위로 쪼개야 한다.
### 3-4. 셀프 호스팅 vs CDN
| | 장점 | 대가 |
|---|---|---|
| 셀프 호스팅 | 요청 도메인 하나, 캐시 통제, 서드파티 장애 무관 | 업데이트를 직접 한다 |
| CDN | 설치가 한 줄 | 도메인이 늘고(각각 DNS+TLS), 그 서비스가 죽으면 글자가 죽는다 |
CDN 을 쓴다면 `preconnect` 를 반드시 건다. 셋 이상의 폰트 도메인은 그 자체가 결함이다.
---
## 4. 폴백 메트릭 — 가장 자주 빠뜨리는 곳
폰트가 늦게 오면 브라우저는 폴백으로 먼저 그리고 나중에 교체한다. 두 폰트의 **행 높이와 글자 폭이 다르면 그 순간 레이아웃이 튄다.** 이것이 CLS 의 흔한 원인이고, `font-display: swap` 을 쓰는 한 반드시 일어난다.
해결은 **폴백 폰트를 실제 폰트의 치수에 맞추는 것**이다.
```css
/* 실제 폰트 */
@font-face {
font-family: "Body";
src: url("/fonts/body-var.woff2") format("woff2-variations");
font-weight: 300 800;
font-display: swap;
}
/* 같은 치수로 보정한 폴백. 이름을 따로 준다 */
@font-face {
font-family: "Body Fallback";
src: local("Arial");
size-adjust: 107%; /* 글자 크기를 맞춘다 */
ascent-override: 92%; /* 행 위 여백 */
descent-override: 24%; /* 행 아래 여백 */
line-gap-override: 0%;
}
body {
font-family: "Body", "Body Fallback", sans-serif;
}
```
**숫자는 폰트마다 다르다.** 재는 방법:
```js
// 두 폰트로 같은 문자열을 그려 폭을 비교한다. 비율이 size-adjust 값이다
const measure = (family) => {
const el = document.createElement("span");
el.style.cssText = `position:absolute;visibility:hidden;font:100px ${family};white-space:nowrap`;
el.textContent = "가나다ABCabc0123";
document.body.appendChild(el);
const w = el.getBoundingClientRect().width;
el.remove();
return w;
};
(measure("Arial") / measure('"Pretendard Variable"') * 100).toFixed(1) + "%";
```
`ascent-override` / `descent-override` 는 실제 폰트의 `hhea` 또는 `OS/2` 값을 `unitsPerEm` 으로 나눈 백분율이다. 도구가 없으면 **행 높이가 눈에 띄게 튀지 않을 때까지 조정**하고 그 값을 `design.md` 에 적어라.
> `font-display: optional` 을 쓰면 이 문제가 사라진다. 대신 첫 방문자가 브랜드 서체를 못 볼 수 있다. **둘 중 하나는 골라야 한다.** 아무것도 안 하는 것이 최악이다.
---
## 5. OpenType 기능
### 숫자
```css
/* 표에 들어가는 수치. 자리가 흔들리지 않는다 */
.nums { font-variant-numeric: tabular-nums; }
/* 본문 속 숫자. 소문자 높이에 맞는다 */
.prose { font-variant-numeric: oldstyle-nums; }
/* 분수, 서수 */
.frac { font-variant-numeric: diagonal-fractions ordinal; }
```
**수치를 나열하는 곳에 `tabular-nums` 가 없으면 숫자가 춤춘다.** 대시보드·가격표·측정값 전부 해당한다.
### 그 외
```css
/* 커닝과 리가처는 기본으로 켜져 있다. 끄지 마라 */
body { font-kerning: normal; }
/* 대문자 사이 여백 보정. 전부 대문자인 짧은 라벨에만 */
.caps { font-variant-caps: all-small-caps; letter-spacing: 0.06em; }
/* 폰트가 제공하는 대체 글자. 무엇이 있는지 확인하고 쓴다 */
.alt { font-feature-settings: "ss01" 1; }
```
`font-variant-*` 표준 속성이 있으면 그걸 쓴다. `font-feature-settings` 는 표준 속성이 없는 기능에만 쓴다 — 한 번 쓰면 다른 기본 기능이 꺼지기 때문이다.
---
## 6. 한글
조판 규칙(줄바꿈·행간·자간·한 줄 길이)은 **`antipatterns.md` §8 이 정본**이다. 여기서는 **폰트 자체**만 다룬다.
### 6-1. 서브셋은 선택이 아니다
한글 완성형은 11,172자다. 전체 폰트는 수 MB 다. 반드시 쪼갠다.
**쪼개는 단위가 라틴과 다르다.** 라틴은 `unicode-range` 로 블록을 나누면 되지만, 한글은 자주 쓰는 글자가 블록에 흩어져 있다. 그래서 **글자 빈도로 나눈 수십 개 조각**을 만들고 각각에 `unicode-range` 를 건다. 브라우저가 페이지에 실제로 쓰인 조각만 받는다.
이 방식을 **동적 서브셋**이라 부른다. Pretendard 의 `dynamic-subset` 빌드가 그것이고, 파일이 수십 개로 보이는 이유다.
> **파일 개수로 폰트 예산을 세지 마라.** 한글 웹폰트가 파일 40개인 것은 정상이고 올바른 최적화다. 세야 할 것은 **패밀리 수와 실제 전송량**이다(`tokens.md` §5).
### 6-2. 웨이트가 라틴과 다르게 보인다
같은 700 이라도 한글은 획이 많아 더 무겁게 보인다. **라틴 기준으로 고른 웨이트를 한글에 그대로 쓰면 본문이 답답해진다.** 한글 본문은 400~500, 제목은 600~700 정도에서 시작해 눈으로 맞춘다.
한글 폰트에는 x-height 개념이 없다. 대신 **글자틀 대비 속공간**을 본다. 속공간이 좁은 서체는 작은 크기에서 막힌다.
### 6-3. 라틴과 섞을 때 순서
```css
/* 라틴 폰트를 앞에, 한글 폰트를 뒤에 둔다.
순서를 바꾸면 숫자와 영문까지 한글 폰트로 그려져 조악해진다 */
font-family: "Switzer", "Pretendard Variable", system-ui, sans-serif;
```
두 폰트의 크기감이 다르면 `size-adjust` 를 쓴 별도 `@font-face` 로 맞춘다. §4 와 같은 기법이다.
### 6-4. 한글 폰트를 지정하지 않는 것
그 자체가 완성도 미달 신호다. 지정하지 않으면 맑은 고딕이나 애플 SD 산돌고딕으로 떨어지고, 그 둘은 서로 크게 달라서 **화면마다 다른 디자인이 된다.**
---
## 7. 라이선스
**배포 전에 확인한다.** 폰트 라이선스 위반은 조용히 있다가 청구서로 온다.
| 확인할 것 | 왜 |
|---|---|
| **웹폰트 임베딩이 허용되는가** | 데스크톱 설치는 되는데 웹 임베딩은 금지인 폰트가 많다 |
| 페이지뷰 상한이 있는가 | 상업 폰트에 흔하다 |
| 도메인이 묶여 있는가 | 스테이징·프리뷰 도메인이 빠질 수 있다 |
| 수정·서브셋이 허용되는가 | 서브셋도 파생물이다 |
| 크레딧이 필요한가 | CC-BY 계열 |
**한국 무료 폰트의 함정**: "상업적 이용 무료"가 곧 "웹폰트 임베딩 무료"는 아니다. 눈누(noonnu.cc)는 허용 범위를 표로 보여주니 **웹폰트 항목을 직접 확인**해라. SIL OFL 은 임베딩·수정·재배포가 모두 허용되지만 **폰트 자체를 판매할 수 없고**, 개명 조항이 있는 경우가 있다.
확인 결과를 `design.md` 에 한 줄로 남긴다: `본문 Pretendard(SIL OFL, 웹 임베딩 가능) / 디스플레이 ___`.
---
## 8. 배포 전 체크리스트
- [ ] 폰트 패밀리 수가 예산 안이다(`tokens.md` §5). 텍스트 두 벌 + 모노 하나가 상한
- [ ] **"왜 이 폰트인가"에 한 문장으로 답할 수 있다**
- [ ] 웨이트를 두 개 이상 쓰는데 정적 폰트를 여러 벌 받고 있지 않다
- [ ] 없는 웨이트를 브라우저가 합성하고 있지 않다(`font-synthesis-weight: none` 으로 확인)
- [ ] `font-display` 를 의식적으로 골랐다
- [ ] preload 한 파일이 첫 화면에 실제로 쓰인다. `crossorigin` 이 있다
- [ ] **폴백 메트릭을 보정했거나, `optional` 을 골랐다.** 둘 중 하나는 했다
- [ ] 수치를 나열하는 곳에 `tabular-nums` 가 있다
- [ ] 한글이 있으면 서브셋을 쓴다. 파일 개수가 아니라 전송량으로 쟀다
- [ ] 라틴 폰트가 폴백 스택 앞에 있다
- [ ] 한글 폰트를 명시했다
- [ ] 웹폰트 임베딩 라이선스를 확인하고 `design.md` 에 적었다
- [ ] 폰트를 못 받은 상태로 페이지를 열어봤다. 그 상태로도 읽힌다
> 근거: research/references/03-trends-2026.md §7, 04-ai-slop-signatures.md §8 (조사일 2026-08-20)