Back

[TIL] 웹 폰트의 구조와 next/font 최적화

2026. 03. 08.Yeji Kim
Frontend

폰트 최적화와 관련된 작업이 있어 기록한다.

웹 폰트는 왜 느릴까

웹 폰트를 쓰려면 보통 Google Fonts에서 <link> 태그를 복붙한다.

html
<link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet">

간단해 보이지만 이 한 줄이 실제로 만드는 네트워크 요청 체인은 생각보다 길다.

  1. 브라우저가 fonts.googleapis.com에 CSS를 요청한다
  2. CSS 안에 @font-face와 폰트 파일 URL이 들어있다
  3. 그 URL은 fonts.gstatic.com이라는 또 다른 호스트를 가리킨다
  4. 그 호스트에 대해 DNS 조회 + TCP + TLS를 다시 한다
  5. 드디어 .woff2 파일을 다운로드한다

2개의 외부 호스트직렬로 요청이 나간다. 폰트 파일은 CSS가 다 내려와야 다운로드를 시작할 수 있어서, 아무리 빨라도 수백ms는 걸린다.

외부 요청 2+개 · ~400-800ms
0ms200ms400ms600ms
HTML
my-site.com
CSS (font face)
fonts.googleapis.com
DNS + TLS
fonts.gstatic.com
.woff2
fonts.gstatic.com
폰트 적용
~500ms+
HTML
CSS
DNS/TLS
Font file
Inline

next/font는 이 체인을 아예 없앤다. 빌드 시점에 Google Fonts API에서 폰트 파일을 미리 다운로드해서 /_next/static/media/에 넣어두고, @font-face CSS를 HTML에 인라인한다. 사용자가 사이트에 접속하면 외부 요청이 0개다. 폰트 파일도 같은 도메인에서 preload로 바로 내려온다.


font-display와 세 가지 문제

폰트가 아직 안 내려왔을 때 브라우저가 어떻게 행동할지를 정하는 게 font-display 속성이다.

css
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: swap;    /* 이 값에 따라 동작이 바뀐다 */
}
동작문제
block폰트 로드까지 텍스트를 숨긴다FOIT - 느린 네트워크에서 빈 화면
swapfallback 폰트로 먼저 보여주고 로드 후 교체FOUT - 교체 시 레이아웃이 밀림 (CLS, Cumulative Layout Shift)
optional캐시에 없으면 fallback을 유지첫 방문에서는 웹 폰트가 안 보일 수 있음

아래에서 각 값이 실제로 어떻게 동작하는지 시뮬레이션할 수 있다.

fallback 폰트로 먼저 보여주고 나중에 교체 (FOUT)

Typography is the art and technique of arranging type to make written language legible, readable and appealing when displayed.

대기 중

next/font는 기본으로 swap을 쓴다. 하지만 그냥 swap만 쓰면 fallback에서 웹 폰트로 교체될 때 글자 폭 차이로 레이아웃이 밀리는 문제(CLS)가 생긴다. 이걸 어떻게 해결하는지가 next/font의 핵심이다.


잠깐, 폰트 용어 정리

size-adjust를 이해하려면 폰트가 어떻게 생겼는지부터 알아야 한다.

글리프(Glyph)란

글리프는 하나의 문자가 화면에 그려지는 모양이다. 'A'라는 문자의 글리프는 폰트마다 다르게 생겼다. 같은 'A'라도 Inter의 글리프와 Arial의 글리프는 폭과 높이가 다르다. 이 차이가 결국 폰트 교체 시 레이아웃이 밀리는 원인이 된다.

폰트의 격자 구조

폰트는 보이지 않는 가이드라인 위에 글리프를 배치한다. 아래 다이어그램에서 각 라인을 클릭해보자.

측정 중...

이 가이드라인들이 바로 폰트 메트릭이다. 폰트 파일(.woff2) 안에는 이 수치들이 숫자로 기록되어 있다.

  • ascent - baseline에서 ascender까지의 거리. 글자 위쪽 공간
  • descent - baseline에서 descender까지의 거리. 글자 아래쪽 공간
  • line-gap - 줄과 줄 사이에 추가되는 여백
  • UPM (Units Per Em) - 이 모든 수치의 기준이 되는 단위. 보통 1000이나 2048

CSS에서 line-height: normal일 때 한 줄의 높이는 이 세 값으로 결정된다. (ascent + descent + line-gap) / UPM × font-size. 예를 들어 UPM이 2048이고 ascent 1854, descent 434, line-gap 0인 폰트에 font-size 16px을 주면, 한 줄 높이는 (1854 + 434 + 0) / 2048 × 16 ≈ 17.9px이 된다.

왜 이게 중요할까

Arial의 ascent가 1854이고 Inter의 ascent가 2048이면(UPM 2048 기준), 같은 font-size라도 Inter가 더 높은 공간을 차지한다. 폰트가 교체되면 모든 줄의 높이가 바뀌고, 그 아래에 있는 버튼, 이미지, 다른 텍스트가 전부 밀린다. 이게 CLS다.


size-adjust로 fallback 폰트 맞추기

CLS의 원인은 위에서 본 것처럼, 두 폰트의 글리프 크기가 다르기 때문이다. 같은 문장이라도 Arial로 렌더링하면 폭이 좁고, Inter로 렌더링하면 넓다. 교체 시 텍스트가 차지하는 공간이 바뀌면서 아래 콘텐츠가 밀린다.

next/font는 빌드 시점에 폰트 파일의 메트릭(ascent, descent, lineGap, 평균 글자 폭)을 읽어서, fallback 폰트의 크기를 웹 폰트에 맞추는 합성 @font-face를 생성한다.

css
/* next/font가 빌드 시 자동 생성하는 코드 */
 
/* 실제 웹 폰트 */
@font-face {
  font-family: '__Inter_aabbcc';
  src: url('/_next/static/media/inter-latin.woff2') format('woff2');
  font-display: swap;
}
 
/* 보정된 fallback 폰트 */
@font-face {
  font-family: '__Inter_Fallback_aabbcc';
  src: local('Arial');
  ascent-override: 96.88%;
  descent-override: 24.15%;
  line-gap-override: 0%;
  size-adjust: 107.64%;
}

size-adjust: 107.64%는 Arial의 모든 글리프를 7.64% 키우겠다는 뜻이다. 위 다이어그램에서 봤던 ascent와 descent를 ascent-override, descent-override로 맞추고, 글리프 폭을 size-adjust로 맞추는 거다. 이 네 가지 값을 조합하면 Arial이 Inter와 거의 같은 공간을 차지하게 된다.

결과적으로 swap이 발생해도 텍스트가 차지하는 영역이 바뀌지 않아서 CLS가 0에 가까워진다.

아래에서 size-adjust를 켜고 끈 상태에서 두 폰트의 글자 폭 차이를 비교해볼 수 있다. 슬라이더로 size-adjust 값을 조정하면서 어느 정도에서 폭이 맞아떨어지는지 확인해보자.

size-adjust로 fallback 폰트 교정
Web Font (Inter)

The quick brown fox jumps over the lazy dog. 0123456789

Fallback (Arial)

The quick brown fox jumps over the lazy dog. 0123456789

Arial과 Inter의 글자 폭이 달라서 교체 시 레이아웃이 밀린다
이 보정 값은 어떻게 계산할까

next/font는 빌드 시점에 폰트 바이너리에서 UPM, ascent, descent, lineGap을 읽어서 fallback 폰트와의 비율을 계산한다. 직접 계산할 일은 없고 adjustFontFallback 옵션(기본 true)만 켜두면 자동이다.


실제로 폰트를 로드해보자

이론은 됐고, 직접 느껴보자. 아래 버튼을 누르면 Google Fonts에서 Playfair Display를 실제로 다운로드해서 텍스트에 적용한다. Arial에서 Playfair Display로 교체될 때 아래 요소가 얼마나 밀리는지 px 단위로 측정해준다.

실제 폰트 로딩 CLS 체험

Typography is the art and technique of arranging type to make written language legible, readable and appealing when displayed. The arrangement of type involves selecting typefaces, point sizes, line lengths, line-spacing, and letter-spacing.

현재 폰트: Arial (fallback)

이게 font-display: swap을 그냥 쓸 때 일어나는 일이다. next/font는 위에서 본 size-adjust 보정으로 이 시프트를 거의 없앤다.


next/font 빌드 파이프라인

next/font는 런타임 자바스크립트가 0이다. 전부 빌드 타임에 처리된다.

SWC 단계

Next.js의 SWC 컴파일러가 import { Inter } from 'next/font/google'를 감지하면, Inter({ subsets: ['latin'] }) 호출을 CSS import로 변환한다. 이게 SWC가 아닌 Babel로는 안 되는 이유다.

tsx
// 개발자가 쓰는 코드
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
 
// SWC가 변환한 결과 (개념적으로)
// → 인코딩된 쿼리 파라미터가 달린 CSS import
제약 사항

폰트 선언은 반드시 모듈 최상위 스코프의 const여야 한다. 값도 리터럴만 허용된다. SWC가 컴파일 타임에 정적 분석하기 때문에, 변수나 동적 표현식은 쓸 수 없다.

Webpack 단계

next-font-loader라는 웹팩 로더가 변환된 CSS import를 처리한다.

  • Google Fonts API에서 CSS를 가져오고 .woff2 파일을 다운로드
  • .next/static/media/에 저장
  • @font-face CSS를 생성하고, fallback 보정 @font-face도 함께 생성
  • 고유한 해시 클래스명을 만든다

최종 출력

HTML에 인라인되는 것들은 이렇다.

html
<!-- 폰트 파일 preload -->
<link rel="preload" as="font" type="font/woff2"
      href="/_next/static/media/a34f9d1faa5f3315-s.p.woff2" crossorigin>
 
<!-- @font-face CSS 인라인 -->
<style>
  @font-face {
    font-family: '__Inter_aabbcc';
    src: url('/_next/static/media/a34f9d1faa5f3315-s.p.woff2') format('woff2');
    font-display: swap;
  }
  @font-face {
    font-family: '__Inter_Fallback_aabbcc';
    src: local('Arial');
    size-adjust: 107.64%;
    ascent-override: 96.88%;
    descent-override: 24.15%;
    line-gap-override: 0%;
  }
</style>

외부 CSS 요청이 없다. 폰트 파일은 HTML 파싱과 동시에 preload로 내려온다. 이게 link 태그 방식과의 근본적인 차이다.


next/font/local

Google Fonts가 아닌 커스텀 폰트 파일도 같은 최적화를 받을 수 있다.

tsx
import localFont from 'next/font/local'
 
const pretendard = localFont({
  src: [
    { path: './Pretendard-Regular.woff2', weight: '400' },
    { path: './Pretendard-Bold.woff2', weight: '700' },
  ],
  display: 'swap',
  adjustFontFallback: 'Arial',  // Arial 기준으로 fallback 보정
})

파일을 직접 제공하는 것 외에는 파이프라인이 동일하다. 빌드 시 파일을 .next/static/media/로 복사하고, 해시된 파일명을 붙이고, preload 태그와 보정된 fallback @font-face를 생성한다.


정리

link 태그next/font
외부 요청2+개 (CSS + 폰트 파일, 서로 다른 호스트)0개
요청 체인HTML → CSS → 폰트 파일 (직렬)HTML → 폰트 파일 (preload, 병렬)
CLS0.05~0.2+0에 가까움 (size-adjust 보정)
런타임 JS없음없음
프라이버시Google에 요청 발생외부 요청 없음

결국 next/font가 하는 일은 하나다. 폰트 로딩이라는 런타임 문제를 빌드 타임으로 옮기는 것. 빌드할 때 폰트를 다 내려받고, CSS를 인라인하고, fallback 메트릭을 맞춰놓으면 사용자 브라우저에서 할 일이 없어진다.


레퍼런스