폰트 최적화
next/font로 Google·로컬 폰트를 자체 호스팅하고 서브셋·가변 폰트·fallback 설정으로 표시 지연을 줄입니다.
웹 폰트는 쓰는 글꼴과 문자 범위, 굵기에 따라 전송량이 커질 수 있는 자원이므로, 폰트를 불러오는 방식은 페이지 성능에 직접 영향을 줍니다. 폰트 파일이 크거나 로딩이 비효율적이면 텍스트 표시가 늦어지거나, FOIT(Flash Of Invisible Text)나 FOUT(Flash Of Unstyled Text) 같은 문제가 생겨 사용자 경험을 해칠 수 있습니다. Next.js는 이런 문제를 줄이는 폰트 최적화 기능을 내장하고 있습니다. 이 글에서는 폰트 최적화가 왜 중요한지, Next.js의 next/font 모듈을 어떻게 쓰는지, 그리고 폰트 로딩 성능을 개선하는 여러 전략을 알아봅니다.
폰트 최적화의 중요성
웹 폰트 파일은 용량이 커서 내려받는 시간이 길어지면 페이지 렌더링이 늦어질 수 있으며, 이는 모바일이나 느린 네트워크 환경에서 더 두드러집니다. 또한 폰트 파일 로딩이 끝나기 전에 대체 폰트(fallback font)로 텍스트를 그렸다가 폰트가 도착한 뒤 레이아웃이 바뀌면 CLS(Cumulative Layout Shift)가 생깁니다. 이는 사용자의 시각적 안정성을 해치고 Core Web Vitals 점수에도 나쁜 영향을 줍니다.
FOIT와 FOUT라는 현상도 있습니다. FOIT는 폰트가 로딩될 때까지 텍스트가 전혀 보이지 않는 현상으로, 사용자에게 빈 화면을 보여 주어 답답함을 줍니다. FOUT는 폰트가 로딩될 때까지 시스템 기본 폰트나 대체 폰트로 텍스트를 보여 주다가, 로딩이 끝나면 웹 폰트로 바뀌면서 글꼴이 갑자기 달라지는 현상입니다. 폰트 최적화는 이런 문제를 해결해 웹 페이지의 시각적 안정성과 로딩 성능을 높입니다.
next/font 모듈 사용하기
next/font는 Google Fonts와 로컬 폰트를 빌드 과정에서 최적화하는 기능으로, 쓸 때는 폰트 소스, 서브셋, display, 적용 위치를 함께 정해야 합니다. 이 모듈은 폰트 파일을 내려받는 방식, font-display, 폰트 메트릭(metrics) 조정으로 FOIT와 FOUT, CLS 문제를 줄입니다.
Google 폰트 파일은 빌드 과정에서 준비해 앱과 함께 자체 호스팅합니다. 방문자의 브라우저가 Google Fonts 서버에 직접 요청하는 방식은 아니지만, 빌드 환경은 그 파일을 가져올 수 있어야 합니다. 실제 로딩 속도는 파일 크기, 캐시, 네트워크에 따라 달라집니다.
Google Fonts 최적화
Google Fonts는 웹에서 가장 널리 쓰이는 폰트 서비스 가운데 하나이며, next/font/google을 쓰면 Google Fonts를 빠르고 효율적으로 불러올 수 있습니다. 먼저 루트 레이아웃인 src/app/layout.tsx나 개별 페이지, 컴포넌트 파일에서 next/font/google을 import하고 쓸 폰트를 설정합니다.
import './globals.css';
import { Inter, Noto_Sans_KR } from 'next/font/google'; // 사용할 폰트 임포트
// 1. Inter 폰트 설정 (영문 기본 폰트)
const inter = Inter({
subsets: ['latin'], // 미리 로드할 서브셋 지정
display: 'swap', // 대체 폰트를 먼저 표시한 뒤 웹 폰트로 교체
variable: '--font-inter', // CSS 변수로 사용하기 위한 이름 지정
});
// 2. Noto Sans KR 폰트 설정 (한글 폰트)
// weight: 사용할 폰트 두께 지정 (용량 최적화)
// display: 'swap' (FOUT 방식)
// variable: CSS 변수 이름 지정
const notoSansKr = Noto_Sans_KR({
subsets: ['latin'], // latin 서브셋을 preload하며 한글 글리프 제거 설정이 아님
weight: ['100', '400', '700', '900'], // 사용할 두께만 지정하여 용량 최적화
display: 'swap',
variable: '--font-noto-sans-kr',
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
// 3. html 태그에 CSS 변수 클래스 적용
// classNames 라이브러리 등을 사용하여 조건부 클래스 적용 가능
<html lang="ko" className={`${inter.variable} ${notoSansKr.variable}`}>
<body>{children}</body>
</html>
);
}레이아웃에서 <html>에 붙인 inter.variable과 notoSansKr.variable은 각각 --font-inter, --font-noto-sans-kr 변수를 정의하는 클래스 이름입니다. 변수가 <html>에 정의되므로 그 아래의 모든 요소가 var()로 이 값을 읽을 수 있습니다. 다음으로 src/app/globals.css에서 이 CSS 변수로 폰트를 적용합니다. font-family는 적은 순서대로 글꼴을 시도하며, 마지막의 sans-serif는 앞의 웹 폰트를 쓸 수 없을 때의 대체 글꼴입니다.
html, body {
padding: 0;
margin: 0;
font-family: var(--font-noto-sans-kr), var(--font-inter), sans-serif;
/* 한글 폰트를 먼저 지정하여 우선 순위를 줍니다. */
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-noto-sans-kr), var(--font-inter), sans-serif;
}
/* 특정 요소에 Inter 폰트 적용 예시 */
.english-only {
font-family: var(--font-inter), sans-serif;
}
/* 폰트 두께 사용 예시 */
.font-thin {
font-weight: 100;
}
.font-bold {
font-weight: 700;
}next/font/google의 주요 옵션은 다음과 같습니다. subsets는 미리 불러올(preload) 서브셋을 지정합니다. 지원하는 이름은 폰트마다 다르므로, 이 예제의 Noto Sans KR에 임의로 korean 같은 값을 넣지 않으며, ['latin']이 한글 글리프를 지운다는 뜻도 아닙니다. weight는 ['400', '700']처럼 쓸 폰트 두께를 지정해, 모든 두께를 불러오지 않고 필요한 두께만 불러와 용량을 줄입니다.
display는 폰트가 늦게 준비될 때 텍스트를 어떻게 보여 주고 바꿀지 정하며, 기본값은 swap입니다. variable은 폰트의 CSS 변수 이름을 지정해 CSS 파일에서 var(--font-변수명) 형태로 폰트를 쉽게 적용하게 합니다. preload는 폰트를 미리 불러올지 정하며 기본값은 true이고, 루트 레이아웃에서 쓰면 모든 경로에, 특정 레이아웃이나 페이지에서 쓰면 그 범위에 적용됩니다.
NEXT · 12.2
폰트가 늦으면 표시 정책에 따라 교체 시점이 달라집니다
본문의 next/font 예제는 display: swap을 사용합니다. 아래는 웹 폰트가 아직 준비되지 않았을 때 대체 글자를 표시하고 교체하는 정책의 비교입니다.
| 정책 | 텍스트를 먼저 표시하는 방식 | 늦게 도착한 폰트 |
|---|---|---|
| block | 짧은 block 기간에는 글자를 숨기고, 이후 대체 폰트를 표시합니다. | swap 기간에는 웹 폰트로 교체합니다. |
| swap | 매우 짧은 block 기간 뒤 대체 폰트를 표시합니다. | 나중에 도착해도 교체할 수 있어 FOUT가 남을 수 있습니다. |
| fallback | 매우 짧은 block 기간 뒤 대체 폰트를 표시합니다. | 짧은 swap 기간 안에 도착한 경우에만 교체합니다. |
| optional | 첫 표시까지 준비되지 않으면 대체 폰트를 사용합니다. | 대체 폰트로 표시한 뒤에는 해당 페이지에서 늦게 교체하지 않습니다. |
- block
- 텍스트를 먼저 표시하는 방식: 짧은 block 기간에는 글자를 숨기고, 이후 대체 폰트를 표시합니다.늦게 도착한 폰트: swap 기간에는 웹 폰트로 교체합니다.
- swap
- 텍스트를 먼저 표시하는 방식: 매우 짧은 block 기간 뒤 대체 폰트를 표시합니다.늦게 도착한 폰트: 나중에 도착해도 교체할 수 있어 FOUT가 남을 수 있습니다.
- fallback
- 텍스트를 먼저 표시하는 방식: 매우 짧은 block 기간 뒤 대체 폰트를 표시합니다.늦게 도착한 폰트: 짧은 swap 기간 안에 도착한 경우에만 교체합니다.
- optional
- 텍스트를 먼저 표시하는 방식: 첫 표시까지 준비되지 않으면 대체 폰트를 사용합니다.늦게 도착한 폰트: 대체 폰트로 표시한 뒤에는 해당 페이지에서 늦게 교체하지 않습니다.
block과 swap은 표시 기간의 이름입니다. 구체적인 기간은 브라우저 정책에 따릅니다. swap도 매우 짧은 block 기간이 있고, 폰트 교체에 따른 레이아웃 이동을 항상 없애지는 않습니다.
위 표는 웹 폰트가 아직 준비되지 않았을 때 대체 글자를 보여 주고 바꾸는 display 정책을 비교하며, 본문의 next/font 예제는 swap을 씁니다. block은 짧은 block 기간에 글자를 숨긴 뒤 대체 폰트를 보여 주고, swap 기간 안에 도착한 웹 폰트로 바꿉니다. swap은 매우 짧은 block 기간 뒤에 대체 폰트를 보여 주며, 폰트가 나중에 도착해도 바꿀 수 있어 FOUT가 남을 수 있습니다. fallback은 짧은 swap 기간 안에 도착한 경우에만 바꾸고, optional은 첫 표시까지 준비되지 않으면 대체 폰트를 쓰며 그 페이지에서는 늦게 바꾸지 않습니다.
표의 아래 설명처럼 block과 swap은 표시 기간의 이름이며, 구체적인 기간은 브라우저 정책을 따릅니다. swap에도 매우 짧은 block 기간이 있고, 폰트를 바꿀 때 생기는 레이아웃 이동을 언제나 없애지는 않습니다.
CSS 변수 클래스는 이름을 등록할 뿐이고, 실제 글꼴 적용은 font-family가 맡습니다. 위 CSS는 Noto Sans KR을 먼저 시도하므로, Inter가 모든 영문에 저절로 적용되지는 않습니다. 영문 전용 요소에는 .english-only를 적용합니다.
로컬 폰트 최적화
직접 호스팅하는 폰트 파일도 next/font/local로 최적화할 수 있습니다. 먼저 프로젝트 안에 폰트 파일을 저장할 src/fonts 같은 디렉터리를 만들고, 다음 코드에 맞춰 my-custom-font-thin.woff2, my-custom-font-regular.woff2, my-custom-font-bold.woff2를 준비합니다. path는 폰트를 선언한 파일을 기준으로 해석됩니다. WOFF2는 압축 효율이 좋은 최신 형식이라 권장됩니다. 그다음 next/font/local로 폰트 파일을 불러옵니다.
import './globals.css';
import localFont from 'next/font/local'; // 로컬 폰트 임포트
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' });
// 1. 로컬 폰트 설정
const myCustomFont = localFont({
src: [
// 여러 폰트 두께 및 스타일을 지정할 수 있습니다.
{
path: '../fonts/my-custom-font-thin.woff2',
weight: '100',
style: 'normal',
},
{
path: '../fonts/my-custom-font-regular.woff2',
weight: '400',
style: 'normal',
},
{
path: '../fonts/my-custom-font-bold.woff2',
weight: '700',
style: 'normal',
},
],
display: 'swap',
variable: '--font-my-custom',
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
// 2. html 태그에 CSS 변수 클래스 적용
<html lang="ko" className={`${inter.variable} ${myCustomFont.variable}`}>
<body>{children}</body>
</html>
);
}localFont()가 돌려준 객체의 .variable을 <html>에 붙이는 방식은 Google Fonts와 같습니다. src 배열의 세 파일은 weight만 다른 하나의 글꼴 계열로 묶이므로, CSS에서는 --font-my-custom 하나만 지정하고 두께는 font-weight로 고릅니다. 예를 들어 font-weight: 700인 요소에는 bold 파일이 쓰입니다. 마지막으로 CSS에서 폰트를 씁니다.
html, body {
padding: 0;
margin: 0;
font-family: var(--font-my-custom), var(--font-inter), sans-serif;
}next/font/local의 src는 폰트 파일 경로의 배열이며, 각 객체에 path, weight, style을 넣을 수 있습니다. display, preload, variable 등은 Google Fonts와 똑같이 동작합니다.
폰트 예제를 기존 앱에 합칠 때는 layout.tsx 전체를 새 예제로 덮어쓰기보다, 폰트 선언과 className만 옮깁니다. 10장의 SessionProvider 같은 기존 래퍼도 그대로 유지하며, 폰트 설정이 인증 상태를 제공하지는 않습니다.
로컬 파일 경로는 이 선언이 들어 있는 파일을 기준으로 읽습니다. 위 src/app/layout.tsx의 ../fonts/my-custom-font-regular.woff2는 src/fonts/my-custom-font-regular.woff2를 가리키며, public/fonts에 넣은 파일과는 다른 경로이므로 먼저 세 파일의 위치와 이름을 맞추세요. .variable 클래스는 CSS 변수를 내보낼 뿐이고, 실제 글꼴 선택에는 위의 font-family 선언이 필요합니다.
폰트 최적화를 위한 추가 전략
next/font 외에도 폰트 로딩 성능을 더 개선하는 몇 가지 전략이 있습니다. 먼저 필요한 폰트만 불러옵니다. thin, regular, bold, black 같은 모든 두께와 normal, italic 같은 모든 스타일을 한꺼번에 불러오면 폰트 파일 용량이 크게 늘어나므로, 실제로 쓰는 두께와 스타일만 고릅니다. 이때 weight, style과 미리 불러올 subsets는 구분해서 고릅니다.
폰트 형식도 고릅니다. WOFF2는 지금 가장 효율적인 웹 폰트 형식으로 최신 브라우저에서 널리 지원되며, 압축률은 글꼴과 원본에 따라 달라집니다. WOFF는 WOFF2를 지원하지 않는 오래된 브라우저를 위한 대체 형식으로 쓸 수 있고, TrueType(TTF)과 OpenType(OTF)은 원본이나 용도에 따라 쓸 수 있지만 웹 배포에는 WOFF2의 전송량과 비교해 고릅니다.
앞의 정책표를 기준으로 display를 정하고, 대체 폰트와의 크기 차이도 확인합니다. 메트릭 조정은 레이아웃 이동을 줄이지만 모든 이동을 없앤다고 보장하지는 않습니다. 더 세밀한 제어가 필요하다면 JavaScript의 Font Loading API로 폰트 로딩 상태를 직접 다루고, 폰트가 도착한 뒤 특정 동작을 실행할 수 있습니다.
서브셋팅(subsetting)은 쓰지 않는 언어의 글리프나 문자를 지워 폰트 파일을 줄이는 기술입니다. 한글 폰트는 모든 문자를 넣으면 용량이 매우 커지므로, 웹사이트 제목처럼 특정 목적에만 쓰는 폰트라면 폰트 제공업체나 도구로 서브셋팅을 고려할 수 있습니다. 폰트 파일을 CDN에 두면 사용자에게 지리적으로 가까운 서버에서 폰트를 제공해 로딩 속도를 높일 수 있습니다.
폰트 최적화는 웹 성능 최적화의 중요한 부분이며, next/font 같은 Next.js의 내장 도구를 쓰면 복잡한 설정을 직접 하지 않고도 빠르고 안정적인 폰트 로딩을 구현할 수 있습니다.
더 깊이: 폰트 함수를 부른 위치가 정하는 preload 범위
next/font의 폰트는 정의했다고 해서 모든 라우트에서 미리 불러오지(preload) 않습니다. Next.js 문서의 Font에 따르면 preload의 범위는 폰트 함수를 부른 파일의 종류로 정해집니다. 하나의 페이지에서 불렀다면 그 페이지의 라우트에서만, 레이아웃에서 불렀다면 그 레이아웃이 감싸는 모든 라우트에서, 루트 레이아웃에서 불렀다면 모든 라우트에서 미리 불러옵니다. 그래서 특정 페이지의 제목에만 쓰는 장식용 폰트를 루트 레이아웃에서 부르면, 그 폰트를 쓰지 않는 페이지에서도 파일을 먼저 내려받게 됩니다.
같은 폰트를 여러 곳에서 쓸 때는 부르는 횟수에도 주의합니다. localFont나 Google 폰트 함수를 부를 때마다 그 폰트가 애플리케이션 안에서 하나의 인스턴스로 따로 호스팅되므로, 공식 문서는 styles/fonts.ts 같은 폰트 정의 파일 한곳에서 부르고 필요한 곳에서 그 객체를 import하라고 권합니다. 대체 폰트의 크기를 맞춰 레이아웃 이동을 줄이는 adjustFontFallback은 Google 폰트에서 기본값 true, 로컬 폰트에서 기본값 'Arial'입니다.
정리
- 웹 폰트는 늦게 도착하면 FOIT, FOUT, CLS를 일으킬 수 있으며,
next/font는 자체 호스팅,font-display, 메트릭 조정으로 이를 줄입니다. next/font/google은 빌드 과정에서 폰트 파일을 준비하므로 방문자의 브라우저가 Google Fonts 서버에 직접 요청하지 않습니다.subsets는 미리 불러올 서브셋,weight는 불러올 두께,display는 늦게 도착한 폰트의 표시 정책,variable은 CSS 변수 이름을 정합니다..variable클래스는 CSS 변수를 등록할 뿐이며, 실제 글꼴은font-family에서 그 변수를 써야 적용됩니다.next/font/local의path는 선언한 파일을 기준으로 해석되므로, 파일 위치와 경로를 함께 맞춥니다.
연습 문제
1. 경로와 적용 단계 확인하기
위 예제에서 .variable 클래스는 남기고 font-family만 지웠다고 가정합니다. 로컬 폰트가 저절로 본문 글꼴이 될까요? 또 글꼴 파일만 src/fonts에서 public/fonts로 옮겼다면, 지금의 src 선언으로 찾을 수 있을까요?
해설 보기
변수를 등록한 것만으로는 본문 글꼴이 선택되지 않습니다. font-family에서 그 변수를 쓰거나, 다른 방법으로 폰트의 .className을 적용해야 합니다. 파일만 옮기고 상대 경로를 그대로 두면 선언은 계속 src/fonts를 가리키므로 찾지 못합니다. 빌드가 읽는 파일 위치와 브라우저가 쓸 CSS 속성을 차례로 확인합니다.
2. 늦게 도착한 폰트의 교체
display: 'optional'로 설정했는데, 첫 표시 시점까지 웹 폰트가 준비되지 않았고 2초 뒤에 도착했습니다. 이 페이지에서 글꼴이 웹 폰트로 바뀔까요? display: 'swap'이었다면 어떻게 달라질까요?
해설 보기
optional은 첫 표시까지 준비되지 않으면 대체 폰트를 쓰고, 그 페이지에서는 늦게 도착한 폰트로 바꾸지 않습니다. 그래서 글꼴이 갑자기 바뀌는 FOUT나 그에 따른 레이아웃 이동은 없지만, 이번 방문에서는 웹 폰트를 보지 못할 수 있습니다.
swap이었다면 대체 폰트를 먼저 보여 주다가, 2초 뒤에 도착한 웹 폰트로 바꿉니다. 웹 폰트는 보여 줄 수 있지만 교체 순간의 FOUT와 레이아웃 이동이 생길 수 있습니다.
3. 쓰지 않는 두께 줄이기
Noto Sans KR의 weight를 ['100', '400', '700', '900']으로 설정했는데, 실제 CSS에서는 400과 700만 씁니다. 어떻게 고치는 편이 좋을까요?
해설 보기
weight를 ['400', '700']으로 줄입니다. 쓰지 않는 100과 900을 함께 지정하면 그만큼 준비하고 내려받을 폰트 데이터가 늘어날 수 있습니다. CSS의 font-weight에서 쓰는 두께와 next/font에 지정한 두께를 맞추고, 나중에 새 두께가 필요해지면 그때 추가합니다.
next/font의 옵션과 동작은 Next.js 공식 문서의 Font 레퍼런스에서 확인할 수 있습니다.
다음 글 코드 분할 및 지연 로딩에서는 필요한 코드만 나누어 불러와 첫 로딩의 JavaScript를 줄이는 방법을 알아봅니다.