앞선 가이드에서 CLS(레이아웃 시프트), LCP(로딩 성능), 리소스 힌트, 서드파티 스크립트 최적화를 차례로 다뤘습니다. 이번에는 많은 개발자가 간과하지만 Core Web Vitals에 막대한 영향을 미치는 요소, 바로 웹 폰트를 깊이 파헤쳐 보겠습니다.
솔직히, 웹 폰트 하나가 성능에 얼마나 영향을 줄까 싶으시죠? 저도 예전엔 그렇게 생각했습니다. 그런데 2025년 Web Almanac 데이터를 보면 상당히 충격적입니다. 분석 대상 웹사이트의 82%가 웹 폰트를 사용하고, 단일 폰트 패밀리 하나가 500KB 이상인 경우도 허다합니다. 특히 한국어 폰트는 11,172자의 한글 조합을 포함해야 하기 때문에, 최적화 없이는 15MB~17MB에 달하는 폰트 파일을 브라우저가 통째로 다운로드해야 합니다.
15메가바이트요. 이미지도 아니고 폰트 하나가요.
이건 단순히 "좀 느려지는" 수준이 아닙니다. LCP를 수백 밀리초에서 수 초까지 지연시키고, 폰트 스왑 과정에서 CLS를 발생시키며, 메인 스레드를 차단해 INP까지 악화시킬 수 있습니다. 2026년 현재 Google이 페이지 경험 신호의 가중치를 높인 상태라서, 웹 폰트 최적화는 선택이 아니라 필수입니다.
웹 폰트가 Core Web Vitals에 미치는 영향
웹 폰트가 성능에 미치는 영향을 제대로 이해하려면, 브라우저가 폰트를 어떻게 처리하는지부터 알아야 합니다. HTML을 파싱하고 CSS를 발견한 브라우저는 폰트 파일을 요청하는데, 이 과정에서 두 가지 렌더링 문제가 발생합니다.
FOIT(Flash of Invisible Text)와 FOUT(Flash of Unstyled Text)
FOIT는 웹 폰트가 로딩될 때까지 텍스트를 아예 보여주지 않는 현상입니다. Chromium 기반 브라우저와 Firefox는 기본적으로 최대 3초간 텍스트 렌더링을 차단하고, Safari는 폰트가 로딩될 때까지 무한정 차단합니다. 사용자 입장에서는 빈 화면을 멍하니 보고 있는 거죠.
FOUT는 폴백 폰트로 먼저 텍스트를 보여준 뒤, 웹 폰트가 로딩되면 교체하는 현상입니다. 텍스트는 즉시 보이지만, 폰트가 바뀌면서 글자 크기와 줄 간격이 달라져 레이아웃이 흔들립니다. 개인적으로 이 "덜컹" 하는 경험이 사용자 체감 품질을 꽤 떨어뜨린다고 봅니다.
이 두 현상이 Core Web Vitals에 미치는 영향을 정리하면 이렇습니다:
- LCP 악화: 텍스트가 LCP 요소인 경우(히어로 제목, 본문 등), FOIT로 인해 텍스트 렌더링이 지연되면 LCP가 직접적으로 늦어집니다
- CLS 발생: FOUT로 폰트가 스왑될 때, 폴백 폰트와 웹 폰트의 메트릭(글자 크기, 줄 높이, 자간)이 다르면 텍스트가 리플로우되면서 주변 요소가 밀려납니다
- FCP 지연: 폰트가 렌더 블로킹 리소스로 작동하면 첫 번째 콘텐츠 페인트 자체가 늦어집니다
font-display: 폰트 렌더링 전략의 핵심
CSS의 font-display 속성은 웹 폰트 로딩 중 브라우저의 동작을 제어하는 가장 기본적이면서 강력한 도구입니다. 각 값의 동작 방식과 성능 영향을 하나씩 살펴보겠습니다.
font-display 속성값 비교
auto는 브라우저 기본 동작에 맡기는 것으로, 대부분의 브라우저에서 FOIT처럼 동작합니다. 예측 불가능하므로 쓸 이유가 없습니다.
block은 최대 3초간 텍스트를 숨기고 그 후에 폴백 폰트로 전환합니다. FOIT를 유발하므로 LCP에 매우 불리합니다.
swap은 즉시 폴백 폰트로 텍스트를 보여주고, 웹 폰트가 로딩되면 교체합니다. LCP에는 유리하지만 CLS 위험이 있습니다.
fallback은 약 100ms간 텍스트를 숨기고, 폴백 폰트로 전환한 뒤 약 3초 내에 웹 폰트가 로딩되면 교체합니다. swap과 optional의 중간 지점이라고 보면 됩니다.
optional은 약 100ms간 텍스트를 숨기고, 그 시간 내에 웹 폰트가 로딩되지 않으면 현재 페이지에서는 폴백 폰트만 사용합니다. CLS가 거의 없는 대신, 첫 방문 시 커스텀 폰트가 안 보일 수 있습니다.
/* 권장: 본문 텍스트에는 optional, 제목에는 swap */
@font-face {
font-family: 'Noto Sans KR';
src: url('/fonts/NotoSansKR-Regular.woff2') format('woff2');
font-weight: 400;
font-display: optional; /* CLS 방지 우선 */
}
@font-face {
font-family: 'Noto Sans KR';
src: url('/fonts/NotoSansKR-Bold.woff2') format('woff2');
font-weight: 700;
font-display: swap; /* 제목은 스왑해도 CLS 영향 적음 */
}
실전 팁 하나 드리자면, 본문 텍스트처럼 긴 텍스트 블록에는 font-display: optional을 사용하세요. 폰트가 스왑되면 여러 줄에 걸쳐 리플로우가 발생해 CLS 영향이 생각보다 큽니다. 반면 제목이나 한 줄짜리 요소에는 swap을 써도 레이아웃 시프트가 거의 없습니다.
한국어 폰트의 특수한 도전 과제
한국어 웹 폰트 최적화가 영문 폰트보다 훨씬 까다로운 이유는 명확합니다. 영문 알파벳은 26자(대소문자 포함 52자)이지만, 한글은 자음 14개 × 모음 10개 × 받침 28개(+없음)의 조합으로 총 11,172자가 됩니다.
이 차이가 파일 크기에 고스란히 반영됩니다:
- 영문 폰트(예: Inter): WOFF2 기준 약 20~100KB
- 한국어 폰트(예: Noto Sans KR): 최적화 없이 4.7MB~17MB
- 차이: 50배~170배
3G 네트워크(약 1.5Mbps)에서 5MB 폰트를 다운로드하려면 약 26초가 걸립니다. 4G에서도 2~3초고요. 이러면 아무리 서버 사이드를 최적화해도 의미가 없어집니다.
서브셋 폰트: 용량을 90% 이상 줄이는 핵심 전략
서브셋(Subset) 폰트는 전체 글리프 중 실제로 필요한 문자만 추출한 경량 폰트입니다. 한국어 폰트 최적화에서 가장 효과가 큰 기법이고, 솔직히 이것 하나만 적용해도 체감 차이가 큽니다.
KS X 1001 기반 서브셋
한국산업표준 KS X 1001에는 자주 사용되는 한글 2,350자가 정의되어 있습니다. 11,172자에서 2,350자로 줄이면 파일 크기가 극적으로 감소합니다:
- Noto Sans KR Regular 원본: 4.7MB (OTF)
- KS X 1001 서브셋 + WOFF2: 200~400KB
- 절감률: 약 92~95%
4.7MB가 200KB로 줄어드는 겁니다. 이 정도면 안 할 이유가 없죠.
pyftsubset으로 서브셋 만들기
fontTools의 pyftsubset은 폰트 서브셋 생성에 가장 널리 쓰이는 도구입니다:
# fontTools 설치
pip install fonttools brotli
# KS X 1001 기반 서브셋 + WOFF2 변환
pyftsubset NotoSansKR-Regular.otf \
--text-file=korean-chars.txt \
--output-file=NotoSansKR-Regular-subset.woff2 \
--flavor=woff2 \
--layout-features='*' \
--desubroutinize
--desubroutinize 옵션은 WOFF2 압축 효율을 높여주는데, 이거 빼먹으면 좀 아깝습니다. 이 옵션만으로 파일 크기가 5~15% 추가로 줄어듭니다.
unicode-range로 온디맨드 로딩
unicode-range는 CSS @font-face에서 폰트가 적용될 유니코드 범위를 지정하는 속성입니다. 여기서 핵심은 페이지에 해당 범위의 문자가 없으면 브라우저가 폰트 파일을 아예 다운로드하지 않는다는 점입니다. 꽤 똑똑하죠?
/* 한글 음절만 포함하는 서브셋 */
@font-face {
font-family: 'Noto Sans KR';
src: url('/fonts/NotoSansKR-subset-hangul.woff2') format('woff2');
unicode-range: U+AC00-D7AF; /* 한글 음절 블록 */
font-display: swap;
}
/* 라틴 문자용 서브셋 */
@font-face {
font-family: 'Noto Sans KR';
src: url('/fonts/NotoSansKR-subset-latin.woff2') format('woff2');
unicode-range: U+0020-007E; /* 기본 라틴 */
font-display: swap;
}
Google Fonts는 이 원리를 극대화합니다. 머신러닝으로 한국어 웹 문서의 글자 사용 패턴을 분석한 뒤, 17,388개 글리프를 약 120개 그룹으로 나눠서 unicode-range 기반으로 제공하거든요. 페이지에 실제 사용된 글자 그룹만 다운로드되니까 전체 폰트 대비 약 90%의 전송량 절감 효과가 있습니다.
WOFF2: 2026년의 표준 폰트 포맷
2026년 기준으로 WOFF2는 전 세계 브라우저의 97% 이상에서 지원됩니다. IE11 지원을 완전히 종료한 지금, WOFF2 하나만 써도 됩니다. 진지하게, 다른 포맷은 이제 필요 없습니다.
WOFF2가 다른 포맷보다 나은 이유:
- Brotli 기반 압축으로 WOFF 대비 약 30% 더 작은 파일 크기
- OTF/TTF 대비 50~70% 용량 절감
- 브라우저 디코딩 속도도 빠름
/* 2026년 권장: WOFF2 단일 포맷 */
@font-face {
font-family: 'Noto Sans KR';
src: local('Noto Sans KR'),
url('/fonts/NotoSansKR-Regular.woff2') format('woff2');
font-weight: 400;
font-display: swap;
}
/* WOFF 폴백이 필요한 경우 (매우 드묾) */
@font-face {
font-family: 'Noto Sans KR';
src: local('Noto Sans KR'),
url('/fonts/NotoSansKR-Regular.woff2') format('woff2'),
url('/fonts/NotoSansKR-Regular.woff') format('woff');
font-weight: 400;
font-display: swap;
}
local()을 먼저 선언하면, 사용자 시스템에 해당 폰트가 이미 설치되어 있을 때 네트워크 요청 자체를 건너뜁니다. 작은 디테일이지만 효과적입니다.
가변 폰트(Variable Fonts)로 HTTP 요청 줄이기
전통적인 방식에서는 Regular, Bold, Light 등 각 굵기(weight)마다 별도의 폰트 파일이 필요합니다. 4개 굵기를 쓴다면 HTTP 요청 4번에 다운로드 용량도 4배죠.
가변 폰트(Variable Font)는 이 문제를 근본적으로 해결합니다. 하나의 파일에 모든 굵기·너비·기울기 변형이 포함되어 있어서, 파일 하나로 거의 무한한 스타일을 표현할 수 있습니다.
가변 폰트의 성능 이점
- HTTP 요청 감소: 4~6개 파일 → 1개 파일
- 총 용량 절감: 개별 파일 합계보다 가변 폰트 1개가 더 작은 경우가 많음
- 디자인 유연성: font-weight를 100 단위가 아닌 1 단위로 세밀하게 조절 가능
/* 가변 폰트 선언 */
@font-face {
font-family: 'Noto Sans KR Variable';
src: url('/fonts/NotoSansKR-VF.woff2') format('woff2-variations');
font-weight: 100 900; /* 가변 범위 지정 */
font-display: swap;
}
/* 사용 시 원하는 굵기를 자유롭게 지정 */
body {
font-family: 'Noto Sans KR Variable', sans-serif;
font-weight: 350; /* 전통 폰트에서는 불가능한 세밀한 조절 */
}
h1 {
font-weight: 800;
}
/* font-variation-settings로 더 세밀한 제어 */
.custom-text {
font-variation-settings: 'wght' 450, 'wdth' 95;
}
다만, 한국어 가변 폰트는 영문 가변 폰트보다 파일 크기가 여전히 크다는 점을 기억하세요. 그래서 서브셋 + 가변 폰트 조합이 현실적으로 가장 효과적입니다.
size-adjust와 폰트 메트릭 오버라이드: CLS 제로를 향해
font-display: swap을 쓰면 텍스트가 즉시 보여서 좋은데, 문제가 하나 있습니다. 폴백 폰트에서 웹 폰트로 교체될 때 두 폰트의 메트릭 차이 때문에 레이아웃이 덜컹거립니다. 이걸 해결하는 게 CSS 폰트 메트릭 디스크립터입니다.
핵심 디스크립터 4가지
size-adjust: 글리프의 전체 크기를 스케일링하는 백분율. 폴백 폰트의 글자 크기를 웹 폰트에 맞춥니다ascent-override: 베이스라인 위 글리프 높이를 재정의. 웹 폰트의 ascender 값에 맞춥니다descent-override: 베이스라인 아래 글리프 높이를 재정의line-gap-override: 줄 간격(line gap)을 재정의
/* 웹 폰트 선언 */
@font-face {
font-family: 'Noto Sans KR';
src: url('/fonts/NotoSansKR-Regular.woff2') format('woff2');
font-weight: 400;
font-display: swap;
}
/* 폴백 폰트에 메트릭 오버라이드 적용 */
@font-face {
font-family: 'Noto Sans KR Fallback';
src: local('Apple SD Gothic Neo'), local('Malgun Gothic');
size-adjust: 100.5%;
ascent-override: 105%;
descent-override: 30%;
line-gap-override: 0%;
}
/* 폰트 스택에서 함께 사용 */
body {
font-family: 'Noto Sans KR', 'Noto Sans KR Fallback', sans-serif;
}
이렇게 하면 폴백 폰트(Apple SD Gothic Neo 또는 맑은 고딕)의 메트릭이 Noto Sans KR과 거의 일치하게 조정되어, 폰트 스왑 시 레이아웃 시프트가 크게 줄어듭니다.
정확한 size-adjust 값을 구하려면 두 폰트의 unitsPerEm과 ascender/descender 값을 비교해야 합니다. 예를 들어 웹 폰트의 unitsPerEm = 1000이고 ascender = 1068이면, ascent-override: 106.8%로 설정하면 됩니다. (수학이 좀 필요하지만 한 번만 계산하면 되니까 참으세요.)
폰트 프리로딩: LCP를 수백 밀리초 단축하기
<link rel="preload">는 브라우저에게 "이 리소스를 최우선으로 다운로드하라"고 지시합니다. 폰트는 보통 CSS가 파싱된 후에야 발견되는데, 프리로딩을 사용하면 HTML 파싱 단계에서 즉시 다운로드를 시작하니까 꽤 유의미한 시간을 아낄 수 있습니다.
<!-- 가장 중요한 폰트만 프리로드 (1~2개 권장) -->
<link
rel="preload"
href="/fonts/NotoSansKR-Regular.woff2"
as="font"
type="font/woff2"
crossorigin
/>
여기서 꼭 주의할 점들:
- crossorigin 속성을 반드시 추가하세요. 폰트는 항상 CORS 모드로 요청되는데, 이 속성이 빠지면 폰트가 두 번 다운로드되는 황당한 상황이 발생합니다
- 프리로드는 가장 중요한 1~2개 폰트에만 적용하세요. 욕심부려서 이것저것 프리로드하면 오히려 다른 중요 리소스의 로딩이 밀립니다
- ATF(Above-The-Fold) 콘텐츠에 사용되는 폰트를 우선적으로 프리로드하세요
셀프 호스팅 vs Google Fonts: 2026년의 최적 선택
Google Fonts를 CDN에서 바로 쓰는 것과 셀프 호스팅하는 것, 어떤 게 나을까요? 결론부터 말하면, 상황에 따라 다릅니다. (뻔한 답처럼 들리겠지만, 이번엔 진짜 그렇습니다.)
셀프 호스팅이 유리한 이유
- 연결 비용 제거: Google Fonts 서버(fonts.googleapis.com, fonts.gstatic.com)에 대한 DNS 조회, TCP 연결, TLS 핸드셰이크 비용이 사라집니다. 이것만으로 200~500ms를 절약할 수 있습니다
- HTTP/2·HTTP/3 활용: 같은 도메인에서 폰트를 제공하면 기존 연결을 재사용할 수 있습니다
- 캐싱 제어: 브라우저 캐시 정책을 직접 설정할 수 있습니다.
Cache-Control: public, max-age=31536000, immutable로 1년간 캐싱이 가능하죠 - 프라이버시: 사용자 데이터가 Google 서버로 전송되지 않습니다. GDPR 준수 측면에서 훨씬 편합니다
Google Fonts API가 유리한 경우
- 한국어 동적 서브셋: Google Fonts는 머신러닝 기반으로 한국어 폰트를 약 120개 그룹으로 분할합니다. 이 수준의 정밀한 unicode-range 분할을 직접 구현하는 건 솔직히 꽤 고된 작업입니다
- Incremental Font Transfer: Google Fonts 팀이 W3C와 함께 개발 중인 차세대 기술로, 화면에 표시되는 글리프만 스트리밍 방식으로 로드합니다. 이 기술이 브라우저에 탑재되면 Google Fonts API 사용자가 자동으로 혜택을 받게 됩니다
제가 실무에서 추천하는 전략은 이렇습니다. 영문 폰트는 셀프 호스팅, 한국어 폰트는 Google Fonts CSS API v2를 사용하되 font-display=swap 파라미터를 추가하는 하이브리드 접근법입니다.
<!-- Google Fonts CSS API v2 + font-display 설정 -->
<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=Noto+Sans+KR:wght@400;700&display=swap"
rel="stylesheet"
/>
셀프 호스팅을 선택한다면 korsubset이나 font-range 같은 오픈소스 도구로 Google Fonts와 비슷한 수준의 unicode-range 분할을 구현할 수 있습니다.
프레임워크별 폰트 최적화 자동화
Next.js (next/font)
Next.js의 next/font는 2026년 현재 가장 진보된 폰트 최적화 시스템이라고 해도 과언이 아닙니다. 빌드 타임에 Google Fonts를 다운로드해서 셀프 호스팅하고, size-adjust를 자동으로 계산하며, 폰트 서브셋도 알아서 적용합니다. 개발자가 할 일이 거의 없어요.
// app/layout.tsx
import { Noto_Sans_KR } from 'next/font/google';
const notoSansKR = Noto_Sans_KR({
subsets: ['latin'], // 한국어는 자동 dynamic subset 적용
weight: ['400', '700'],
display: 'swap',
adjustFontFallback: true, // size-adjust 자동 계산
preload: true,
});
export default function RootLayout({ children }) {
return (
<html lang="ko" className={notoSansKR.className}>
<body>{children}</body>
</html>
);
}
adjustFontFallback: true가 핵심입니다. 이 한 줄이 폴백 폰트의 size-adjust, ascent-override, descent-override를 자동으로 계산해서 CLS를 거의 제로로 만들어 줍니다.
Nuxt 3
Nuxt 3에서는 @nuxtjs/fontaine 모듈이 비슷한 역할을 합니다:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/fontaine'],
fontMetrics: {
fonts: ['Noto Sans KR'],
},
});
실전 체크리스트: 지금 바로 적용하세요
자, 여기까지 읽으셨다면 이론은 충분합니다. 이제 실제로 뭘 해야 하는지 우선순위대로 정리해 드리겠습니다:
- WOFF2 포맷 사용: 아직 TTF나 OTF, WOFF를 쓰고 계신다면 지금 바로 WOFF2로 바꾸세요. 이것만으로 30~50% 용량이 줄어듭니다
- font-display 설정: 모든
@font-face에font-display: swap또는optional을 추가하세요 - 서브셋 적용: 한국어 폰트는 반드시 서브셋을 만들거나 Google Fonts API v2의 동적 서브셋을 활용하세요
- 필요한 굵기만 로드: 실제 사용하는 font-weight만 선언하세요. 안 쓰는 굵기를 로드하는 건 대역폭 낭비입니다
- 프리로드 적용: ATF 콘텐츠에 사용되는 폰트 1~2개를
<link rel="preload">로 프리로드하세요 - 폰트 메트릭 매칭:
size-adjust와ascent-override로 폴백 폰트의 메트릭을 웹 폰트에 맞추세요 - 캐싱 설정: 폰트 파일에
Cache-Control: public, max-age=31536000, immutable헤더를 설정하세요
1번부터 3번까지만 적용해도 체감 성능이 확연히 달라질 겁니다.
성능 측정과 모니터링
최적화를 했으면 효과를 숫자로 확인해야겠죠. 다음 도구들을 활용해 보세요:
- Chrome DevTools: Network 탭에서 폰트 파일의 크기와 로딩 시간을 확인합니다. Performance 패널에서는 CLS와 LCP에 대한 폰트의 영향을 직접 볼 수 있습니다
- Lighthouse: "Ensure text remains visible during webfont load" 경고가 뜨면
font-display가 빠져 있는 겁니다 - WebPageTest: Waterfall 차트에서 폰트 다운로드 타이밍과 텍스트 렌더링 시점을 시각적으로 비교할 수 있습니다
- CrUX(Chrome User Experience Report): 실제 사용자 데이터 기반의 CLS, LCP 점수를 확인합니다. PageSpeed Insights에서도 같은 데이터를 볼 수 있습니다
자주 묻는 질문 (FAQ)
font-display: swap과 optional 중 어떤 걸 써야 하나요?
CLS를 최우선으로 고려한다면 optional이 확실히 유리합니다. 첫 방문 시 커스텀 폰트가 안 보일 수 있다는 단점이 있지만, 레이아웃 시프트가 거의 없습니다. 브랜드 아이덴티티 때문에 커스텀 폰트가 반드시 보여야 한다면 swap을 쓰되, size-adjust로 폴백 폰트 메트릭을 맞춰서 CLS를 최소화하세요. 저는 하이브리드 전략(본문 optional, 제목 swap)을 가장 자주 씁니다.
한국어 웹 폰트를 셀프 호스팅하는 게 나은가요, Google Fonts를 쓰는 게 나은가요?
한국어 폰트의 경우, Google Fonts CSS API v2의 머신러닝 기반 동적 서브셋이 워낙 잘 되어 있어서 대부분의 프로젝트에서는 Google Fonts가 더 편리하고 효율적입니다. 단, GDPR 같은 프라이버시 규정 준수, 오프라인 지원, 또는 극한의 성능 튜닝이 필요하다면 셀프 호스팅을 선택하고 korsubset이나 font-range 같은 도구로 서브셋을 직접 만드는 게 맞습니다.
가변 폰트를 사용하면 일반 폰트보다 항상 빠른가요?
아뇨, 항상 그런 건 아닙니다. 한 가지 굵기만 쓴다면 일반 폰트가 오히려 더 작습니다. 가변 폰트가 유리해지는 건 3개 이상의 굵기를 사용할 때부터입니다. 그리고 한국어 가변 폰트는 아직 영문 가변 폰트보다 파일이 꽤 크므로, 반드시 서브셋과 함께 쓰세요.
preload를 사용하면 폰트 렌더링이 얼마나 빨라지나요?
일반적으로 200~500ms의 LCP 개선 효과가 있습니다. 폰트 파일은 보통 CSS가 파싱된 후에야 발견되는데, preload를 쓰면 HTML 파싱 단계에서 바로 다운로드를 시작하니까 이 지연이 통째로 사라집니다. 다만 프리로드할 폰트는 1~2개로 제한하세요. 과도한 프리로드는 네트워크 대역폭 경쟁을 유발해서 역효과가 납니다.
size-adjust로 CLS를 완전히 제거할 수 있나요?
size-adjust, ascent-override, descent-override를 사용하면 수직 방향의 레이아웃 시프트는 거의 제거할 수 있습니다. 하지만 문자 간격(letter-spacing)과 단어 간격(word-spacing)은 이 디스크립터로 조절할 수 없기 때문에, 긴 텍스트 블록에서 줄바꿈 위치가 달라져 미세한 높이 변화가 남을 수 있습니다. 완벽한 CLS 제로가 목표라면, font-display: optional이 사실상 유일한 답입니다.