본문 바로가기
웹 개발

Next.js 폰트 최적화

by xosoy 2025. 9. 12.

폰트가 어떻게 성능에 영향을 끼치는가?

웹페이지의 성능을 측정하는 지표 중 폰트가 영향을 줄 수 있는 대표적인 지표는 다음과 같다.

 

1) Largest Contentful Paint (LCP)

  • 페이지에서 가장 큰 요소가 텍스트인 경우, 폰트가 늦게 로드되면 텍스트 요소의 렌더링이 지연된다.

2) Cumulative Layout Shift (CLS)

  • 폰트 로딩이 완료되기 전까지 시스템 폰트로 먼저 보여주고 로딩 완료 후 웹 폰트로 교체되는 경우, 텍스트의 폭이나 줄바꿈이 바뀌면서 화면이 밀리는 현상(layout shift)이 발생할 수 있다.

 

Next.js의 Font Optimization 사용법

폰트 최적화하는 방법은 매우 단순하다! 

Next.js는 폰트 최적화를 위한 함수를 제공한다.

 

사용 방법은 Google Font에서 제공하는 폰트와 그 외의 폰트 파일, 두 가지로 나뉜다.

 

Google Font

원하는 구글 폰트를 이름으로 하는 함수를 next/font/google에서 import한다.

함수에는 실제 사용할 weight, variable, subsets 등을 명시해주면 된다. 구체적으로 적을수록 파일 용량과 속도를 최소화할 수 있다.

import { Bebas_Neue } from "next/font/google";

export const bebasNeue = Bebas_Neue({
  weight: "400",
  variable: "--font-babas-neue",
  subsets: ["latin"],
});

 

Local Font

Google Font에 없는 폰트는, 직접 다운로드 받은 후 프로젝트 내 public 폴더 또는 src 폴더 내에 저장하여 불러올 수 있다.

variable는 어떤 이름으로 해당 폰트를 사용할 것인지를 적어주면 된다. (ex: font-familyvar(--font-freesentation))

import localFont from "next/font/local";

export const freesentation = localFont({
  src: "./fonts/Freesentation-5Medium.ttf",
  variable: "--font-freesentation",
});

 

Next.js는 어떻게 최적화를 하는가?

Next.js는 폰트를 어떻게 최적화하고, 그리고 성능에 어떤 영향을 주는지 알아보자.

 

1. 셀프 호스팅

공식 문서를 보면, 구글 폰트들에 대해 배포물과 같은 도메인에 정적 에셋으로서 저장하여(셀프 호스팅) 구글에 요청을 하지 않게 된다고 한다.

 

실제로 개발 환경에서 확인해보면, 폰트 파일에 대한 요청 경로가 빌드 산출물 next/static/media 폴더 아래에 있는 폰트 파일인 것을 확인할 수 있었다.


구글 폰트:

Bebas_Neue 폰트 (구글 폰트)

로컬 폰트:

로컬 파일로 저장된 폰트

 

구글 폰트와 로컬 폰트 모두 프로젝트 /.next/static/media 하위에 파일이 생성되어 있었다. 구글 폰트는 실제 이름과 다르게 만들어져 있었다.

 

Next.js는 빌드 시점에 폰트 파일을 가져와서 프로젝트 내부(next/static/media)에 포함시킨다. 따라서 런타임에 외부 서버에 폰트를 요청하지 않게 되어, 네트워크 지연 문제를 개선할 수 있다.

 

2. 필요한 subset만 가져오기

Next.js는 사용자가 지정한 문자셋(ex: latin, hangul 등)과 font weight 등 필요한 폰트만 다운받아, 저장 용량와 로드 시간을 최소화한다.

const roboto = localFont({
  src: [
    {
      path: './Roboto-Regular.woff2',
      weight: '400',
      style: 'normal',
    },
    {
      path: './Roboto-Italic.woff2',
      weight: '400',
      style: 'italic',
    },
    {
      path: './Roboto-Bold.woff2',
      weight: '700',
      style: 'normal',
    },
    {
      path: './Roboto-BoldItalic.woff2',
      weight: '700',
      style: 'italic',
    },
  ],
})

 

3. preload

Next.js는 폰트 파일을 <link rel="preload" as="font">로 등록하여 미리 로드한다. 따라서 초기 로드 지연을 최소화할 수 있다.

 

4. CSS-in-JS 삽입

폰트에 대한 @font-face 규칙을 생성하여, css 코드 작성 없이 className으로 바로 컴포넌트에 폰트를 적용할 수 있게 한다.

import localFont from "next/font/local";

export const freesentation = localFont({
  src: "./fonts/Freesentation-5Medium.ttf",
  variable: "--font-freesentation",
});

export default function Layout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
      <body className={freesentation.className}>
        {children}
      </body>
  );
}

 

5. FOIT 방지

next/font는 기본적으로 font-display: swap을 적용한다. 폰트가 로드되기 전에는 시스템 폰트로 먼저 표시하고, 준비되면 교체한다. 따라서 텍스트가 보이지 않는 현상 FOIT(Flash of Invisible Text)를 방지한다.

 

6. layout shift 최소화

폰트 로드 완료 전까지 보여줄 fallback 폰트의 형태가 실제 폰트와 차이가 크다면, 위와 같이 layout shift가 발생할 수 있다. 

 

next/font는 빌드 시점에 빌드 시점에 폰트의 메트릭 정보(ascent, descent, lineGap, unitsPerEm 등)를 읽어온다. 그리고 그 정보를 가지고, fallback 폰트의 폭/줄바꿈 등을 최대한 원래 폰트와 비슷하게 맞추기 위해 적용해야할 스타일 값들을 구한다.
결과적으로 원래 폰트로 교체될 때 줄바꿈이 바뀌는 등의 layout shift 현상을 최소화할 수 있다.

 

코드 살펴보기

실제로 어떤 흐름으로 처리하는지 궁금해져 코드를 살펴보았다.

Google Font

🔗 https://github.com/vercel/next.js/blob/canary/packages/font/src/google/loader.ts

 

1) 구글 폰트 요청 url 생성

const url = getGoogleFontsUrl(fontFamily, fontAxes, display)

 

2) fallback 설정

const adjustFontFallbackMetrics: AdjustFontFallback | undefined =
      adjustFontFallback ? getFallbackFontOverrideMetrics(fontFamily) : undefined

 

2-1) layout shift 최소화를 위한 메트릭 계산

// getFallbackFontOverrideMetrics 함수 코드
export function getFallbackFontOverrideMetrics(fontFamily: string) {
  try {
    const { ascent, descent, lineGap, fallbackFont, sizeAdjust } =
      calculateSizeAdjustValues(fontFamily)
    return {
      fallbackFont,
      ascentOverride: `${ascent}%`,
      descentOverride: `${descent}%`,
      lineGapOverride: `${lineGap}%`,
      sizeAdjust: `${sizeAdjust}%`,
    }
  } catch {
    Log.error(`Failed to find font override values for font \`${fontFamily}\``)
  }
}

 

3) 구글 폰트 CSS 가져오기

const hasCachedCSS = cssCache.has(url)
let fontFaceDeclarations = hasCachedCSS
  ? cssCache.get(url)
  : await fetchCSSFromGoogleFonts(url, fontFamily, isDev).catch(() => null)

if (!hasCachedCSS) {
  cssCache.set(url, fontFaceDeclarations ?? null)
} else {
  cssCache.delete(url)
}
  • 캐싱 적용

 

4) CSS에서 폰트 파일의 url 추출

const fontFiles = findFontFilesInCss(
  fontFaceDeclarations,
  preload ? subsets : undefined
)

 

5) 각 폰트 파일 다운로드 후 셀프 호스팅 처리

const downloadedFiles = await Promise.all(fontFiles.map(async ({ googleFontFileUrl, preloadFontFile }) => {
  const hasCachedFont = fontCache.has(googleFontFileUrl)
  const fontFileBuffer = hasCachedFont
    ? fontCache.get(googleFontFileUrl)
    : await fetchFontFile(googleFontFileUrl, isDev).catch(() => null)

  if (!hasCachedFont) fontCache.set(googleFontFileUrl, fontFileBuffer ?? null)
  else fontCache.delete(googleFontFileUrl)

  if (fontFileBuffer == null) nextFontError(...)

  const ext = /\.(woff|woff2|eot|ttf|otf)$/.exec(googleFontFileUrl)![1]

  // 폰트 파일 내보내기
  const selfHostedFileUrl = emitFontFile(
    fontFileBuffer,
    ext,
    preloadFontFile,
    !!adjustFontFallbackMetrics
  )

  return { googleFontFileUrl, selfHostedFileUrl }
}))
  • emitFontFile: next/static/media/로 다운로드한 파일을 내보냄

 

6) CSS 치환

let updatedCssResponse = fontFaceDeclarations
for (const { googleFontFileUrl, selfHostedFileUrl } of downloadedFiles) {
  updatedCssResponse = updatedCssResponse.replace(
    new RegExp(escapeStringRegexp(googleFontFileUrl), 'g'),
    selfHostedFileUrl
  )
}
  • 실제 구글 폰트의 url을 셀프 호스팅 url로 치환

 

Local Font

🔗https://github.com/vercel/next.js/blob/canary/packages/font/src/local/loader.ts

 

1) 로컬 폰트 파일 읽고, 파일로 내보내기

const resolved = await resolve(path)
const fileBuffer = await promisify(loaderContext.fs.readFile)(resolved)
const fontUrl = emitFontFile(
  fileBuffer,
  ext,
  preload,
  typeof adjustFontFallback === 'undefined' || !!adjustFontFallback
)
  • 파일 읽기 (fileBuffer)
  • emitFontFile: 읽어온 버퍼를 next/static/media/ 경로로 파일 내보내기

 

2) 폰트 메타데이터 구하기

let fontMetadata: any
try {
  fontMetadata = fontFromBuffer?.(fileBuffer)
} catch (e) {
  console.error(`Failed to load font file: ${resolved}\n${e}`)
}

 

3) @font-face 만들기

const hasCustomFontFamily = declarations?.some(({ prop }) => prop === 'font-family')

const fontFaceProperties = [
  ...(declarations ? declarations.map(({ prop, value }) => [prop, value]) : []),
  ...(hasCustomFontFamily ? [] : [['font-family', variableName]]),
  ['src', `url(${fontUrl}) format('${format}')`],
  ['font-display', display],
  ...((weight ?? defaultWeight) ? [['font-weight', weight ?? defaultWeight]] : []),
  ...((style ?? defaultStyle) ? [['font-style', style ?? defaultStyle]] : []),
]

const css = `@font-face {\n${fontFaceProperties
  .map(([property, value]) => `${property}: ${value};`)
  .join('\n')}\n}\n`

 

4) 메타데이터를 가지고 fallback 보정 값 계산

let adjustFontFallbackMetrics: AdjustFontFallback | undefined
if (adjustFontFallback !== false) {
  const fallbackFontFile = pickFontFileForFallbackGeneration(fontFiles)
  if (fallbackFontFile.fontMetadata) {
    adjustFontFallbackMetrics = getFallbackMetricsFromFontFile(
      fallbackFontFile.fontMetadata,
      adjustFontFallback === 'Times New Roman' ? 'serif' : 'sans-serif'
    )
  }
}

 

 4-1) layout shift 최소화를 위한 메트릭 계산

// getFallbackMetricsFromFontFile 함수
export function getFallbackMetricsFromFontFile(
  font: Font,
  category = 'serif'
): AdjustFontFallback {
  const fallbackFont =
    category === 'serif' ? DEFAULT_SERIF_FONT : DEFAULT_SANS_SERIF_FONT

  const azAvgWidth = calcAverageWidth(font)
  const { ascent, descent, lineGap, unitsPerEm } = font

  const fallbackFontAvgWidth = fallbackFont.azAvgWidth / fallbackFont.unitsPerEm
  let sizeAdjust = azAvgWidth
    ? azAvgWidth / unitsPerEm / fallbackFontAvgWidth
    : 1

  return {
    ascentOverride: formatOverrideValue(ascent / (unitsPerEm * sizeAdjust)),
    descentOverride: formatOverrideValue(descent / (unitsPerEm * sizeAdjust)),
    lineGapOverride: formatOverrideValue(lineGap / (unitsPerEm * sizeAdjust)),
    fallbackFont: fallbackFont.name,
    sizeAdjust: formatOverrideValue(sizeAdjust),
  }
}

 

요약

  • 웹폰트는 LCP 지연과 CLS 증가를 유발할 수 있지만, Next.js의 next/font는 이를 효과적으로 최적화한다.
  • 빌드 시 셀프 호스팅되어 외부 네트워크 요청을 줄이고, 필요한 subset과 weight만 가져와 용량을 최소화한다.
  • 또한 preload와 font-display: swap을 적용해 초기 표시 지연(FOIT)을 막고, fallback 폰트 메트릭 조정을 통해 layout shift를 줄인다.
  • 결과적으로 개발자는 간단한 설정만으로 빠르고 안정적인 폰트 렌더링을 구현할 수 있다.