02 — Web Fonts (@font-face·display·subset)
폰트를 어디서 받아 어떻게 표시하느냐는 성능·디자인·가시성의 트라일레마다. 한 번에 다 만족할 수 없으니, 어느 축을 양보할지 정해야 한다.
한 문장 답 (Pyramid Top)
Web font 로딩은 **
@font-face(어디서 받나) +font-display(언제 보여주나) +subset(얼마만 받나) +preload(언제부터 받나)** 의 4축 결정이다.swap은 *"보이게 하되 점프하라"*,optional`은 “점프하지 말고 web font를 포기하라” — 둘은 정반대 트레이드오프다.
Why — 왜 어려운가
폰트 파일은 작지 않다.
- 한 weight의 서구 라틴 WOFF2 = 25-50KB.
- 한 weight의 한국어 풀셋 WOFF2 = 1.2-3MB.
- 9 weight × 3 style (regular/italic/…) = 27 파일 = 다 받으면 80MB.
그리고 폰트는 렌더링 블로커다 — font-face 선언이 있어도 브라우저는 해당 글자가 그려져야 할 때에야 폰트를 요청한다 (lazy). 그리고 받는 동안 텍스트가 숨겨지거나(FOIT) 튀거나(FOUT) 한다.
이 챕터는 그 트레이드오프를 명시적으로 조정하는 CSS의 도구를 다룬다.
How — @font-face의 5개 속성
1) 가장 단순한 형태
@font-face {
font-family: "Inter";
src: url("/fonts/inter-regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}이 선언 자체가 폰트를 받는 것은 아니다. font-family: Inter를 사용하는 요소가 그려질 때 비로소 다운로드 시작.
2) Variable font 등록
@font-face {
font-family: "Inter";
src: url("/fonts/inter-var.woff2") format("woff2-variations");
font-weight: 100 900; /* 한 파일이 100..900 전체 weight 커버 */
font-style: normal;
font-display: swap;
}font-weight: 100 900(범위) — 이 한 선언으로 모든 weight을 표현.- format:
"woff2-variations"(구) 또는"woff2" tech("variations")(신).
3) 여러 format fallback
@font-face {
font-family: "MyFont";
src: local("MyFont"), /* 1순위: 설치된 폰트 */
url("/fonts/myfont.woff2") format("woff2"),/* 2순위: WOFF2 */
url("/fonts/myfont.woff") format("woff"); /* 3순위: WOFF */
}2024년 기준 모든 모던 브라우저가 WOFF2를 지원 — WOFF/TTF/EOT는 더 이상 필요 없다.
4) Unicode range로 subset 분리
/* 라틴 */
@font-face {
font-family: "Pretendard";
src: url("/fonts/pretendard-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153;
}
/* 한글 */
@font-face {
font-family: "Pretendard";
src: url("/fonts/pretendard-korean.woff2") format("woff2");
unicode-range: U+AC00-D7A3, U+3130-318F;
}브라우저는 페이지에 그 unicode range가 있을 때만 해당 파일을 받는다 — 영문만 있는 페이지에서 한글 파일은 다운로드하지 않는다.
How — font-display의 5가지 모드
폰트 로드 동안 무엇을 보여줄까를 결정한다.
타임라인: [요청]──[Block 기간]──[Swap 기간]──[Fail 기간]
0ms ~100ms ~3000ms ∞| 값 | Block 기간 | Swap 기간 | 동작 |
|---|---|---|---|
auto | 짧음 | 무한 | 브라우저 기본 (보통 block처럼) |
block | ~3s | 무한 | 3초간 텍스트 숨김, 이후 fallback → 로드되면 swap |
swap | 0 | 무한 | 즉시 fallback 표시, 로드되면 swap (가장 흔함) |
fallback | ~100ms | ~3s | 100ms 숨김 → fallback → 3초 후 로드 안 되면 web font 포기 |
optional | ~100ms | 0 | 100ms 안에 로드 안 되면 완전히 포기 (이번 페이지에서만) |
swap vs optional — 정반대 트레이드오프
swap (가시성 우선)
- 즉시 fallback으로 텍스트 표시 → 사용자가 빠르게 읽을 수 있음.
- 폰트가 늦게 도착하면 텍스트가 재그려진다 → CLS 발생.
- 적합: 콘텐츠 중심 사이트 (블로그, 뉴스). “글자를 보는 것”이 최우선.
optional (안정성 우선)
- 100ms 안에 로드되지 않으면 fallback으로 끝까지 그린다 → CLS 0.
- 사용자가 디자인된 폰트를 못 볼 수도 있음.
- 적합: 디자인이 절대적인 사이트가 아닌, 성능·안정성을 우선하는 곳. Vercel·Next.js의 기본 추천.
- CLS 점수가 Core Web Vitals에 큰 영향 → SEO에 유리.
결정 트리
디자인이 절대 — 텍스트가 늦게 보여도 됨 → block
디자인이 중요 — 빠르게 표시되고 swap 허용 → swap
성능이 중요 — CLS 0, 디자인은 fallback도 OK → optional한 줄 추천: 본문은 optional 또는 swap + size-adjust, 헤더는 swap.
How — Self-host vs CDN
CDN (Google Fonts, Adobe Fonts)
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400..700&display=swap" rel="stylesheet">장점:
- 동적 서브셋팅 (Google Fonts CSS API가 unicode-range 자동 생성).
- 캐싱 (사용자가 다른 사이트에서 이미 받았을 수도) — 2018년 이후 사라짐 (브라우저별 캐시 격리).
- 인프라 부담 0.
단점:
- 3rd party 요청 = DNS + TCP + TLS handshake (200-400ms).
- 개인정보(IP·UA·Referer)가 Google로 전송 → GDPR 위반 판결 (독일 법원, 2022).
- 광고/추적 차단기에 의해 차단될 수 있음.
Self-host
@font-face {
font-family: "Inter";
src: url("/fonts/inter-var.woff2") format("woff2");
font-display: swap;
}장점:
- 같은 origin → DNS·handshake 비용 0.
- 개인정보 노출 없음 (GDPR 안전).
- HTTP/2 push,
preload, Service Worker 캐시 통제 가능. - 차단 위험 없음.
단점:
- 서브셋팅을 직접 해야 함 (
pyftsubset,glyphhanger,subfont). - 폰트 파일을 git 또는 CDN에 두는 인프라 결정 필요.
2024 현재 BP
Next.js / Vite / 모던 프레임워크는 self-host를 기본으로 한다.
next/font/google— Google Fonts를 빌드 타임에 다운로드해 self-host로 변환.unfonts.css— 자동 서브셋팅·preload·size-adjust 생성.
How — Preload로 한 박자 빠르게
<link rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossorigin>- 폰트는 보통 CSS 파싱 후에 발견되어 requestor 까지 시간 = CSS 다운 + 파싱 + 글리프 매칭.
preload는 HTML 파싱 시점에 바로 다운로드 시작.- 주의: 모든 폰트에 preload를 걸면 안 된다 — 본문 폰트만, Critical font에만.
crossorigin필수 — 안 적으면 두 번 다운받는다 (anonymous vs CORS).
What — 한글 서브셋팅 필수
왜 필수인가
- 한글 풀셋 = 11,172자 (KS X 1001 완성형 2,350자 + KS X 1002 확장 + 호환성).
- Pretendard Variable 풀셋 = 약 3.2MB.
- 한 페이지에서 실제 쓰는 한글 = 보통 200-500자 (KS X 1001 빈도 상위 20%).
정적 서브셋팅 (빌드 타임)
# pyftsubset (fonttools)
pyftsubset Pretendard-Variable.woff2 \
--unicodes="U+0020-007E,U+AC00-D7A3" \
--layout-features="kern,liga" \
--flavor=woff2 \
--output-file=Pretendard-Variable.subset.woff2U+AC00-D7A3= 한글 음절 11,172자 전체.- 빈도 기반으로 더 줄이려면 대표 음절 2,350자만 (
U+AC00-D7A3중 빈도 상위) → 약 800KB.
동적 서브셋팅 (Google Fonts 방식)
Google Fonts는 unicode-range를 자동으로 80-100개의 chunk로 나눈다:
/* korean chunk 1 */
@font-face {
font-family: "Noto Sans KR";
src: url("...chunk1.woff2") format("woff2");
unicode-range: U+AC00-AC1F;
}
/* korean chunk 2 */
@font-face {
font-family: "Noto Sans KR";
src: url("...chunk2.woff2") format("woff2");
unicode-range: U+AC20-AC3F;
}
/* ... 100여 개 */브라우저는 페이지에 등장하는 음절의 chunk만 받는다.
Pretendard self-host BP
1. Pretendard-Variable.subset.woff2 (필수 글자만, ~400KB) — preload
2. Pretendard-Variable.full.woff2 (전체) — 나중에 lazy
3. fallback: Pretendard local + Apple SD Gothic Neo / Malgun Gothic
4. size-adjust로 metric 맞춤 → CLS 0What-if — 잘못 쓰면
1) font-display 미설정
@font-face {
font-family: "MyFont";
src: url("...");
/* font-display 없음 → 기본 auto → 대부분 block 동작 */
}→ 3초간 텍스트 안 보임 (FOIT). 사용자가 흰 화면을 보고 떠난다.
모든 @font-face에 font-display를 명시한다.
2) 한글 풀셋 다운로드
@font-face {
font-family: "Pretendard";
src: url("/fonts/pretendard-full.woff2") format("woff2");
/* 3MB 다운로드 — 모바일 LTE에서 5초 */
}→ 서브셋팅 또는 unicode-range chunk 분리.
3) 모든 폰트 preload
<link rel="preload" href="/fonts/inter-100.woff2" as="font" crossorigin>
<link rel="preload" href="/fonts/inter-200.woff2" as="font" crossorigin>
<link rel="preload" href="/fonts/inter-300.woff2" as="font" crossorigin>
<link rel="preload" href="/fonts/inter-400.woff2" as="font" crossorigin>
{/* ... 9 weights × 2 styles = 18개 preload */}→ 대역폭 폭발, 진짜 critical resource가 늦어진다. Variable font 하나만 preload.
4) crossorigin 누락
<link rel="preload" href="/fonts/inter.woff2" as="font">
{/* crossorigin 없음 → 두 번 다운로드 */}폰트는 항상 CORS 요청으로 가져온다 — preload에도 crossorigin이 필수.
5) FOUT 받아들이지 못하는 디자인
swap을 썼는데 fallback과 web font의 글자 폭이 너무 다르면 — 텍스트가 다른 줄로 점프, 카드 높이가 변함, 버튼 길이가 변함.
→ size-adjust/ascent-override로 fallback metric을 web font에 맞춘다 (다음 챕터에서 자세히).
Insight — 흥미로운 이야기
“WOFF2는 한국인의 발명”
2010년 WOFF (Web Open Font Format) 1.0이 표준화되며 웹 폰트의 압축 표준이 정해졌다. 2014년 WOFF2가 발표됐는데 — 압축 알고리즘 Brotli의 핵심 개발자 중 하나가 Google의 Jyrki Alakuijala, 글리프 변환의 핵심 알고리즘이 Yannis Haralambous의 작업을 기반. WOFF2는 WOFF보다 30-40% 더 작다. 한글처럼 글리프 수가 많은 폰트는 압축률이 더 높아져, Pretendard Variable이 3MB → 2.1MB → WOFF2로 1.5MB까지 줄었다. WOFF2는 모든 모던 브라우저가 지원하니 더 이상 TTF·OTF·WOFF는 웹에 둘 필요가 없다.
“font-display 등장 전 — 25줄짜리 JS hack의 시대”
2012-2017년 사이, FOIT을 피하려면 FontFaceObserver 같은 라이브러리로 폰트 로드를 자바스크립트로 감시하고, 로드되면
<html>에class="fonts-loaded"를 추가해서 CSS로 적용 시점을 통제했다. 25줄짜리 JS hack이 모든 사이트의 head에 들어갔다. 2018년font-display표준이 모든 브라우저에 도착하며 이 hack은 deprecated by browser가 됐다. CSS의 진화는 자바스크립트로 했던 일을 회수하는 역사다.
요약 + Mermaid
@font-face는 폰트 등록만 한다 — 다운로드는 사용 시점에.font-display로 swap (가시성) vs optional (안정성) 의 결정을 외부화.- Self-host + WOFF2 + subset + preload + variable = 모던 BP.
- 한글은 반드시 서브셋팅 — unicode-range 또는 정적 분리.
- Google Fonts는 GDPR 우려로 self-host 변환 (Next.js의 기본).
다음 문서 → 03-font-metrics: 그 size-adjust가 정확히 무엇을 조정하는가.