페이지를 열면 글자가 잠깐 사라졌다가 나타나거나, 처음엔 시스템 폰트로 떴다가 갑자기 다른 폰트로 바뀌며 줄이 밀린다. 둘 다웹 폰트가 늦게 도착해서 생기는 현상이다. 폰트 파일은 HTML → CSS → 그 폰트를 쓰는 element 의 layout 까지 끝나야 비로소 발견·다운로드되고, 그동안 브라우저는 텍스트를 어떻게 그릴지 결정을 미룬다. 이 가이드는 폰트가 왜 늦는지, FOIT / FOUT / FOFT 의 차이, font-display 값별 동작, swap 이 일으키는 CLS, 그리고 jank 를 없애는 최적화 레시피를 정리한다.
폰트는 왜 늦게 로드되나
브라우저가 폰트 파일을 알게 되는 시점이 늦다. 발견 경로:
1. HTML 다운로드 → 파싱
2. <link rel="stylesheet"> 발견 → CSS 다운로드 → 파싱
3. CSS 안의 @font-face 규칙 발견 (아직 다운로드 X)
4. 그 폰트를 실제로 쓰는 element 가 layout 됨
5. ↑ 이 시점에야 폰트 파일 요청 시작
6. 폰트 다운로드 완료 → 텍스트 (재)그리기핵심은 4번. @font-face 가 CSS 에 있다고 바로 받지 않는다. 그 폰트를 쓰는 텍스트가 화면에 그려질 게 확정돼야 받는다 — lazy. 덕분에 안 쓰는 weight 는 안 받지만, 대신 텍스트가 보일 때쯤 폰트가 아직 도착 안 했을 가능성이 크다. 이건 how-browsers-render-pages 에서 다룬 critical rendering path 위에 폰트라는 추가 round-trip 이 얹히는 구조다.
FOIT vs FOUT vs FOFT
폰트가 도착하기 전 브라우저가 텍스트를 어떻게 처리하느냐에 따라 세 가지 증상이 나뉜다:
- FOIT (Flash of Invisible Text) — 폰트가 올 때까지 텍스트를 아예 안 보여줌. 빈 공간. 폰트가 빨리 오면 모르고 지나가지만, 느린 네트워크에선 글자가 한참 사라져 있다.
- FOUT (Flash of Unstyled Text) — 먼저 fallback (시스템) 폰트로 즉시 보여주고, 웹 폰트가 도착하면 교체. 글자는 항상 읽히지만 폰트가 바뀌는 깜빡임이 있다.
- FOFT (Flash of Faux Text) — roman(기본) weight 만 먼저 받아 보여주고, bold / italic 은 합성(faux bold)으로 임시 처리하다가 진짜 파일이 오면 교체. 큰 폰트 패밀리를 단계적으로 로드하는 고급 전략.
무엇이 나타날지는 거의 전적으로 font-display 가 결정한다. 옛 브라우저의 default 는 FOIT (3초 invisible), 모던 default 는 그보다 짧지만 여전히 잠깐 invisible 이다.
font-display — 5개 값
브라우저는 폰트 로딩을 두 구간으로 나눠 처리한다:
- block period — 이 구간엔 텍스트를 invisible 로 둔다(공간은 차지). 폰트가 이 안에 오면 그걸로 그린다.
- swap period — 이 구간엔 fallback 폰트로 보여주다가, 웹 폰트가 도착하면 교체한다.
@font-face {
font-family: "Inter";
src: url(/fonts/inter.woff2) format("woff2");
font-display: swap; /* ← 여기 */
}각 값의 동작:
- auto — 브라우저 기본. 대부분 block 과 거의 같음(짧은 block + 무한 swap 비슷). 즉 약한 FOIT.
- block — block period ~3초 + 무한 swap. 3초간 invisible (FOIT). 폰트가 늦으면 글자가 한참 안 보임. 로고처럼 잘못된 폰트로 보이면 안 되는 곳에만.
- swap — block period 0(거의) + 무한 swap. fallback 으로 즉시 표시 후 교체 (FOUT). 가독성 최우선. 본문에 가장 무난하지만 swap 시 reflow(CLS) 주의.
- fallback — 아주 짧은 block(~100ms) + 짧은 swap(~3초). 100ms 안에 오면 웹 폰트, 그 사이 fallback, 3초 넘어 도착하면 교체 안 함(다음 페이지 캐시용으로만 보관). 짧은 FOIT + 제한된 FOUT.
- optional — 아주 짧은 block + swap 없음. 브라우저가 네트워크 상황을 보고 폰트를 쓸지 말지 결정. 느리면 이번 페이지엔 아예 fallback 으로 끝내고 폰트는 백그라운드로 받아 캐시. CLS 0 에 가장 유리. body text 에 추천되는 값.
값 block swap 결과 증상
─────────────────────────────────────────────────
auto 브라우저 브라우저 보통 약한 FOIT
block ~3s 무한 긴 FOIT
swap ~0 무한 FOUT (항상 교체)
fallback ~100ms ~3s 짧은 FOIT + 제한 FOUT
optional ~100ms 없음 FOIT 후 fallback 고정 (CLS 최소)실무 기본값: 브랜드 헤드라인엔 block 또는 swap, 본문엔 swap 또는 optional. CLS 가 신경 쓰이면 optional + fallback metric 매칭이 가장 안전하다.
swap 의 함정 — layout shift (CLS)
font-display: swap 은 가독성은 좋지만 대표적인 CLS 원인이다. fallback 폰트와 웹 폰트의 metric(글자 폭, x-height, line-height 계산)이 다르면, 폰트가 교체되는 순간 텍스트가 차지하는 공간이 달라지고 → 줄이 밀리고 → 아래 요소가 통째로 움직인다. 이게 how-core-web-vitals-work 에서 다루는 Cumulative Layout Shift 점수를 깎는다.
교체 전 (Arial fallback):
┌────────────────────────────┐
│ The quick brown fox jumps │ ← 3줄
│ over the lazy dog and then │
│ runs away quickly │
└────────────────────────────┘
교체 후 (웹 폰트, 글자가 더 넓음):
┌────────────────────────────┐
│ The quick brown fox jumps │ ← 4줄로 늘어남!
│ over the lazy dog and │
│ then runs away │
│ quickly │
└────────────────────────────┘
↑ 아래 모든 콘텐츠가 한 줄 높이만큼 밀림 = CLS해결 1 — fallback metric override
fallback 폰트의 metric 을 웹 폰트에 맞춰 강제로 조정하면, 교체 전후 텍스트가 차지하는 공간이 같아져 shift 가 사라진다. @font-face 에 네 가지 descriptor 를 쓴다:
/* 웹 폰트 metric 에 맞춘 "조정된 fallback" */
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107%; /* 글자 크기 배율 */
ascent-override: 90%; /* 윗부분 높이 */
descent-override: 22%; /* 아랫부분 높이 */
line-gap-override: 0%; /* 줄 간격 */
}
body {
font-family: "Inter", "Inter Fallback", sans-serif;
}- size-adjust — fallback 글리프 전체를 비율로 확대/축소해 평균 글자 폭을 웹 폰트와 맞춘다.
- ascent-override / descent-override — baseline 위/아래로 잡는 높이를 강제해 줄 높이를 맞춘다.
- line-gap-override — 줄 사이 추가 간격을 맞춘다.
값은 두 폰트의 metric 을 비교해 계산한다 — 손으로 하기 번거롭다. 그래서 도구가 대신 해준다.
해결 2 — 프레임워크가 자동으로 (next/font)
Next.js 의 next/font 는 이 작업을 빌드 타임에 자동화한다. 폰트를 self-host 하고(외부 요청 제거), 동시에 그 폰트의 실제 metric 을 읽어 위의 size-adjust / ascent-override 등을 박은조정된 fallback @font-face 를 자동 생성한다. 결과적으로 swap 이 일어나도 CLS 가 0 에 수렴한다. yutils 사이트 자체도 폰트를 self-host 하며 이 방식을 쓴다.
// next/font 사용 예 — fallback metric 자동 매칭
import {Inter} from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
// adjustFontFallback 가 기본 on → size-adjust fallback 자동 생성
});최적화 레시피
1. self-host vs Google Fonts
Google Fonts 의 <link> 방식은 편하지만 fonts.googleapis.com(CSS) + fonts.gstatic.com(폰트 파일) 두 개의 추가 도메인에 연결해야 한다. DNS + TCP + TLS round-trip 이 critical path 에 얹힌다. 폰트를 직접 호스팅하면 같은 origin 에서 받아 연결 overhead 가 사라지고 캐시·preload 통제권도 생긴다. 외부를 꼭 써야 한다면 how-resource-hints-work 에서 다루는 preconnect 로 연결을 미리 데워둔다.
2. WOFF2 만 제공
WOFF2 는 Brotli 압축으로 WOFF 보다 ~30%, TTF 보다 ~50% 작다. 모든 모던 브라우저가 지원하므로 레거시 fallback(TTF/EOT)을 줄 필요가 거의 없다. format("woff2") 하나로 충분하다.
3. 핵심 폰트는 preload
above-the-fold 에 반드시 쓰이는 폰트는 발견 시점을 앞당긴다. <head> 에 preload 힌트를 박으면 CSS 파싱을 기다리지 않고 곧장 받는다:
<link
rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossorigin
/>as="font"+type가 있어야 올바른 우선순위로 받는다.- crossorigin 필수 — 폰트는 same-origin 이어도 CORS-mode anonymous 로 요청된다. 빠지면 preload 가 무시되고 폰트를 두 번 받는다.
- 남발 금지 — 정말 첫 화면에 보이는 1~2개 weight 만. 너무 많이 preload 하면 더 중요한 리소스(LCP 이미지 등)와 대역폭을 경쟁한다.
4. subset — unicode-range
한 폰트 파일에 모든 글자가 들어 있을 필요는 없다. 라틴만 쓰는 페이지가 키릴·그리스·한자 글리프까지 받을 이유가 없다. unicode-range 로 글리프 범위를 쪼개면 브라우저가 실제로 그 범위의 글자가 페이지에 있을 때만 해당 subset 파일을 받는다:
@font-face {
font-family: "Inter";
src: url(/fonts/inter-latin.woff2) format("woff2");
unicode-range: U+0000-00FF; /* Basic Latin + Latin-1 만 */
}
@font-face {
font-family: "Inter";
src: url(/fonts/inter-cyrillic.woff2) format("woff2");
unicode-range: U+0400-04FF; /* 키릴 — 필요할 때만 다운로드 */
}한글처럼 글리프가 수천 개인 언어는 subset(자주 쓰는 음절만) 또는 동적 subsetting 으로 파일 크기를 크게 줄인다.
5. variable font — 파일 하나로 모든 weight
전통적으로는 Regular / Medium / Bold / Italic … weight 마다 별도 파일을 받는다. variable font 는 weight · width · slant 같은 축(axis)을 하나의 파일 안에 담아, font-weight: 100~900 무엇이든 한 파일로 커버한다. weight 를 3개 이상 쓴다면 보통 variable 쪽이 총 전송량이 작다.
@font-face {
font-family: "Inter";
src: url(/fonts/inter-var.woff2) format("woff2-variations");
font-weight: 100 900; /* 범위로 선언 */
font-display: swap;
}6. weight 욕심 줄이기
weight·style 하나하나가 별도 다운로드다(variable 이 아니라면). 디자인이 Regular + Bold 두 개면 충분한데 6개 weight 를 불러오면 그만큼 폰트가 늦고 데이터를 낭비한다. 실제로 쓰는 weight 만 선언한다.
CSS Font Loading API — JS 로 제어
선언적 font-display 로 부족할 때, JavaScript 로 로딩 시점을 직접 다룰 수 있다.
// 1. 모든 폰트 로드 완료를 기다리기
document.fonts.ready.then(() => {
document.documentElement.classList.add("fonts-loaded");
// 예: 폰트 준비 후에만 특정 애니메이션 시작
});
// 2. 폰트를 JS 로 정의하고 명시적으로 로드
const inter = new FontFace(
"Inter",
"url(/fonts/inter-var.woff2)",
{weight: "100 900", display: "swap"}
);
document.fonts.add(inter);
// 3. 특정 폰트가 준비됐는지 확인 후 처리
document.fonts.load('1rem "Inter"').then(() => {
// 이 폰트로 canvas 텍스트 그리기 등
});document.fonts.ready— 모든 폰트 로딩이 끝나면 resolve 되는 promise. "폰트 다 왔을 때" 훅.new FontFace(...)— CSS 없이 코드로 폰트를 정의. 조건부 로딩에 유용.document.fonts.load(...)— 특정 font/크기 조합의 로드를 트리거하고 promise 로 완료를 알린다. canvas 에 텍스트를 그리기 전 자주 쓴다.
흔한 패턴 — fonts.ready 후 html 에 클래스를 붙여, CSS 에서 "폰트 로딩 전엔 fallback 스타일, 후엔 진짜 스타일" 을 제어(이른바 FOUT 통제). 다만 대부분의 경우 font-display + fallback metric 매칭이면 JS 없이도 충분하다.
흔한 함정
1. preload 에 crossorigin 누락
가장 흔한 실수. crossorigin 없는 폰트 preload 는 CORS-mode 불일치로 무시되고, 실제 폰트는 따로 또 받힌다 → 이중 다운로드.
2. swap 만 켜고 fallback metric 방치
display: swap 으로 FOIT 는 없앴지만 CLS 를 새로 만들었다. fallback metric override(또는 next/font) 까지 같이 해야 완성이다.
3. preload 남발
모든 weight 를 preload 하면 LCP 이미지·핵심 CSS 와 대역폭을 다툰다. 첫 화면에 보이는 weight 만.
4. Google Fonts preconnect 누락
외부 폰트를 쓰면서 preconnect 를 안 걸면 폰트 파일 도메인 연결이 CSS 다운로드 후에야 시작된다. 연결을 미리 데워야 한다.
5. 너무 큰 폰트를 subset 없이
CJK 처럼 글리프 수천 개짜리 폰트를 통째로 받으면 수 MB. 라틴만 쓰는 페이지인데도 전부 받는 경우가 흔하다. unicode-range subset 으로 쪼갠다.
참고 자료
- web.dev — Optimize web font loading — web.dev
- MDN —
font-display— developer.mozilla.org - MDN — CSS Font Loading API — developer.mozilla.org
- Next.js — Font Optimization — nextjs.org
요약
- 폰트는 HTML → CSS → 그 폰트를 쓰는 element layout 까지 끝나야 발견·다운로드된다. 그래서 늦다.
- FOIT = 도착까지 텍스트 invisible. FOUT = fallback 즉시 표시 후 교체. FOFT = roman 먼저, bold/italic 은 합성 후 교체.
font-display— block(긴 FOIT) / swap(FOUT) / fallback(짧은 FOIT) / optional(CLS 최소) / auto(브라우저 기본).- swap 의 대가는 CLS — fallback 과 웹 폰트 metric 차이로 reflow.
size-adjust·ascent/descent/line-gap-override또는 next/font 의 자동 fallback 으로 해소. - 최적화 — self-host + WOFF2 + 핵심 폰트 preload(crossorigin 필수) + unicode-range subset + variable font + weight 절제.
- CSS Font Loading API —
document.fonts.ready,new FontFace(...),document.fonts.load()로 JS 제어. - Next.js
next/font는 self-host + size-adjust fallback 자동 — yutils 사이트도 이 방식.