🎨 Frontend CSS4. 타이포그래피02 — Web Fonts (@font-face·display·subset)

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
swap0무한즉시 fallback 표시, 로드되면 swap (가장 흔함)
fallback~100ms~3s100ms 숨김 → fallback → 3초 후 로드 안 되면 web font 포기
optional~100ms0100ms 안에 로드 안 되면 완전히 포기 (이번 페이지에서만)

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.woff2
  • U+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 0

What-if — 잘못 쓰면

1) font-display 미설정

@font-face {
  font-family: "MyFont";
  src: url("...");
  /* font-display 없음 → 기본 auto → 대부분 block 동작 */
}

→ 3초간 텍스트 안 보임 (FOIT). 사용자가 흰 화면을 보고 떠난다. 모든 @font-facefont-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-overridefallback 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-displayswap (가시성) vs optional (안정성) 의 결정을 외부화.
  • Self-host + WOFF2 + subset + preload + variable = 모던 BP.
  • 한글은 반드시 서브셋팅 — unicode-range 또는 정적 분리.
  • Google Fonts는 GDPR 우려로 self-host 변환 (Next.js의 기본).

다음 문서 → 03-font-metrics: 그 size-adjust가 정확히 무엇을 조정하는가.