웹 성능 개선을 위한 웹 폰트 최적화

Web

2025년 2월 20일

·

27 min read

최근 진행한 프로젝트에서 텍스트의 제목과 본문에 폰트를 적용하는 기능을 개발하면서 다양한 폰트 관련 문제에 직면했습니다.

이 과정에서 React에서 폰트를 적용하는 방법을 익히고, 발생한 문제들을 해결한 경험을 정리해보았습니다.

또한 폰트 문제 해결의 과정 속에서 자연스럽게 폰트 성능 최적화에 대해서도 고민하게 되었습니다.

이를 바탕으로 React에서 폰트를 적용하는 방법과 폰트 성능 최적화 관련 내용을 정리 해보는 시간을 가졌습니다.

웹 폰트 왜 나왔을까?

웹 폰트가 나온 배경을 이해하기 위해서는 웹 세이프 폰트를 먼저 살펴봐야합니다.

웹 세이프 폰트 / 웹 폰트 이 두 폰트는 모두 디지털 폰트 파일입니다. 하지만 중요한 차이점이 존재합니다.

웹 세이프 폰트 (Web Safe Font)

웹 세이브 폰트는 일반적으로 시스템에 설치되어 있는 폰트들입니다. (예: Arial, Helvetica 등)

이 폰트들은 브라우저가 웹에서 폰트 파일이나 관련 CSS를 다운로드할 필요가 없어서 성능적으로 우수합니다.

그러나 웹페이지에서 표시하는 글자의 폰트 선택이 크게 제한되기에, 이로 인해 웹 페이지는 글꼴의 다양한 디자인 선택지를 가지지 못하게 됩니다.

특히, 시스템 상에 해당 폰트가 다운로드 되어 있지 않다면, 다른 사용자들이 일관된 폰트를 볼 수 없는 문제도 존재했습니다.

또한 한국어는 특히나 웹 세이프 폰트만으로 글꼴을 적용하는 데에 한계가 있었죠.

위와 같은 문제를 해결하기 위해 웹 폰트가 나오게 되었습니다.

웹 폰트 (Web Font)

설치되어 있지 않아서 브라우저에서 다운로드해야 하는 폰트입니다.

따라서 브라우저는 폰트 파일과 관련된 CSS 파일을 인터넷에서 다운로드해야 합니다.

이렇게 되면 사용자가 해당 폰트를 시스템에 설치되어 있지 않아도, 브라우저에서 설치를 하기 때문에 모든 사용자가 동일한 폰트로 일관된 디자인을 경험할 수 있게 됩니다.

하지만 웹 폰트 다운로드 시간만큼 렌더링이 느려지고 성능에 영향을 주기 때문에, 사용자 경험을 해칠 수도 있게 됩니다.

따라서 프론트엔드 개발자라면, 웹 폰트의 개념을 정확하게 숙지하고, 성능을 최적화하여 로딩 시간을 충분히 단축시킬 수 있어야 합니다.

웹 폰트의 동작 방식

웹 폰트를 잘 사용하고 최적화하기 위해서는, 웹 폰트가 어떻게 동작하는지 알아야합니다.

위에서 언급했듯이, 페이지를 로드할 때 외부 서버에서 폰트 파일을 다운로드하여 사용하게 됩니다.

즉, 웹 폰트를 사용하는 페이지에 접근할 때 웹 서버 이외에 또 다른 서버로 웹 폰트를 요청하는 것입니다.

브라우저 렌더링 과정을 살펴보며, 폰트가 어떻게 적용되는지 살펴봅시다.

출처: https://web.dev/articles/optimize-webfont-loading?hl=ko
출처: https://web.dev/articles/optimize-webfont-loading?hl=ko

T₂ 단계에서 응답받은 css 파일을 확인하여 렌더링에 필요한 폰트 파일을 요청합니다.

이때, 브라우저는 폰트 요청의 응답을 기다리지 않고 렌더링을 진행합니다.

따라서 렌더링 시점에 폰트를 아직 사용할 수 없는 경우 글자 픽셀은 렌더링되지 않을 수도 있습니다.

이때 만약 대체 폰트가 설정되어 있다면 대체 폰트로 렌더링하고, 기존 폰트가 준비되었을 땐 폰트를 변경하여 렌더링합니다.

위 렌더링 과정을 이해하고 있어야, 웹 폰트에서 발생하는 현상인 FOIT와 FOUT 현상을 이해하고 폰트 로딩을 최적화할 수 있습니다.

FOIT / FOUT

폰트 로딩과 관련한 최적화 작업을 처리하지 않았을 때, 사용자 경험에 불편을 초래할 수 있는 두 가지 현상인 FOIT와 FOUT에 대해서 살펴봅시다.

FOIT (Flash of Invisible Text)

FOIT는 웹 폰트가 완전히 다운로드되기 전까지 텍스트가 화면에 표시되지 않는 현상입니다. 사용자는 페이지 로딩 시 텍스트가 아예 보이지 않게 되며, 웹 폰트가 다운로드되고 렌더링이 완료되면 그때서야 텍스트가 나타납니다.

이 문제는 주로 웹 폰트가 로드될 때까지 다른 대체 폰트 없이 텍스트를 숨겨놓기 때문에 발생합니다. 결과적으로, 사용자는 일시적으로 콘텐츠를 전혀 볼 수 없게 됩니다. FOIT는 페이지의 로딩 시간과 사용자 경험에 큰 영향을 미칠 수 있기 때문에, 이를 피하기 위한 최적화가 필요합니다.

FOUT (Flash of Unstyled Text)

FOUT는 웹 폰트가 다운로드되기 전까지 기본 시스템 폰트로 텍스트가 표시되다가, 폰트 다운로드가 완료되면 지정된 웹 폰트로 갑자기 전환되는 현상입니다. 이 경우, 페이지가 로드되면 처음에는 기본 시스템 폰트로 텍스트가 보이다가, 웹 폰트가 완전히 로드된 후 폰트가 변경되는 모습을 볼 수 있습니다.

FOUT는 FOIT보다는 덜 불편할 수 있지만, 여전히 텍스트가 갑자기 바뀌는 것에 대한 시각적 충격을 줄 수 있습니다. 이 문제도 최적화 작업을 통해 해결할 수 있습니다.


제가 위에서 설명했던 렌더링 과정을 이해하셨다면, 왜 이런 문제가 발생하는지 짐작이 가실 겁니다.

FOIT와 FOUT는 모두 브라우저의 렌더링 과정과 관련이 있습니다.

브라우저는 HTML 문서를 요청하고 DOM구성을 시작한 뒤, CSS, JavaScript 등의 리소스를 요청합니다. 이 과정에서 CSS 파일을 모두 받은 후 CSSOM을 구성하고, DOM과 CSSOM을 결합하여 렌더링 트리를 구성합니다.

하지만, 폰트 리소스는 이 시점에서야 요청되기 시작하며, 폰트 다운로드가 완료되기 전까지 텍스트를 화면에 렌더링할 수 없습니다. 이로 인해, 폰트가 준비되기 전까지 텍스트가 보이지 않거나 기본 폰트로 표시되는 문제가 발생합니다.

즉, 폰트 리소스는 다른 리소스들보다 상대적으로 늦게 요청되기 때문에, 이로 인한 지연이 발생하는 것입니다.

폰트 최적화 방법

이제 어떻게 FOIT와 FOUT 문제를 해결함과 더불어 웹 폰트를 최적화하는 방법에 대해 살펴봅시다.

당연히 FOIT, FOUT 둘 다 최소화하는 것이 좋습니다.

그래도 비교하자면 FOIT와 같이 콘텐츠가 아예 블락되어 버리는 것은 너무 치명적입니다. (일단은 텍스트가 보이게...)

하지만 폰트가 뒤늦게 적용되는 FOUT도 사용자 경험에는 치명적이긴 하지만, 아예 블락되는 것보다는 괜찮다고 볼 수는 있죠.

따라서 FOIT는 방지하고 FOUT로 처리되도록 하되, 최소화하는 방법으로 적용해야합니다.

폰트 파일 포맷 최적화

웹 폰트를 사용한다는 것은 결국 추가적인 파일을 다운로드하는 것입니다.
따라서 이 파일들의 크기를 크기를 줄여야 다운로드 시간이 단축되고, 로딩 속도 향상에 기여할 수 있게 되겠죠.

웹 폰트는 TTF/OTF, WOFF, WOFF2, SVG, EOT 형식이 있습니다.

출처: https://www.w3schools.com/Css/css3_fonts.asp
출처: https://www.w3schools.com/Css/css3_fonts.asp

현재는 WOFF2(Web Open Font Format, 버전 2)가 가장 성능이 우수한 폰트 압축 형식입니다.

WOFF보다 평균 압축률이 무려 26.61% 가장 높으며, 덜 효율적인 GZIP 대신 Brotli 압축을 사용하기 때문입니다.

하지만 구글 폰트나 눈누와 같은 사이트에서 폰트를 다운로드 해보면, 여전히 TTF, OTF 또는 WOFF와 같은 덜 효율적인 형식으로만 제공되는 경우가 많습니다.

폰트를 .woff2 형식으로 변환하려면 CloudConvert의 WOFF2 변환기와 같은 변환 도구를 사용할 수 있습니다.

WOFF2 형식이 가장 효율적이지만, 모든 브라우저에서 지원하지 않는 경우가 있기 때문에 WOFF나 TTF/OTF 형식도 함께 제공할 수 있습니다.

@font-face {
  font-family: "Nanum Gothic";
  font-style: normal;
  font-weight: 400;
  src: url(/fonts/NanumGothic-Regular.woff2) format("woff2"), url(/fonts/NanumGothic-Regular.woff)
      format("woff"), url(/fonts/NanumGothic-Regular.ttf) format("truetype");
}
 
/*@font-face는 아래에서 자세하게 다룰 예정*/

@font-face에서 여러 파일 형식을 나열하는 방식은 폰트 파일의 우선순위를 설정하는 방법입니다.

브라우저는 앞에서부터 지원되는 폰트를 찾아 로드하므로, 먼저 WOFF2를 제공하고, 지원하지 않으면 WOFF와 TTF/OTF를 차례로 시도합니다.

폰트 서브셋 활용

서브셋(Subset)이란 웹 폰트 파일에서 필요한 문자 집합만을 선택하여 추출한 폰트 파일입니다.

이를 통해 폰트 파일의 크기를 줄여 다운로드 속도를 개선할 수 있습니다.

영어는 26개의 알파벳으로 이루어져 있으며, 대소문자를 포함하여 총 72자 정도의 글자만 필요합니다.

하지만 한글은 자음과 모음을 조합할 때 가능한 글자 수가 11,172자나 됩니다. 이 중에는 실제 서비스에서 사용되지 않는 드문 조합들, 예를 들어 겕, 뿕, 뽥과 같은 글자들이 포함되어 있습니다.

따라서 한글 폰트는 영문 폰트보다 훨씬 큰 용량을 가질 수밖에 없습니다.

이러한 불필요한 글자를 폰트에서 제거하여 폰트의 용량을 줄이고 폰트를 최적화활 수 있습니다.

Google Fonts에서는 서브셋 기능을 제공하여, 한글, 라틴 문자, 또는 특정 언어의 문자만 포함된 폰트를 다운로드할 수 있습니다.

또한 transfonter.orgopentype.jp와 같은 곳에서 서브셋 폰트를 생성할 수도 있습니다.

추가로, unicode-range를 사용하여 직접 폰트에 서브셋을 적용할 수도 있습니다. 이 속성은 특정 문자 범위만 선택하여 폰트를 적용하는 데 유용하며, 필요한 문자 집합만 로드함으로써 웹 페이지의 성능을 더욱 개선할 수 있습니다.

해당 내용은 뒤에 @font-face에서 다시 한 번 설명하도록 하겠습니다.

미리 폰트 다운로드 받기 - preload

FOUT 현상이 발생하는 이유는 무엇이었나요?

브라우저가 HTML 파일을 먼저 요청하고, 그 후에 CSS 파일을 요청하며, CSS 내에서 정의된 font-face로 지정된 폰트 파일을 마지막에 요청하기 때문입니다.

이 과정에서 폰트 파일이 아직 다운로드되지 않아, 대체 폰트로 텍스트가 먼저 렌더링되고, 이후 폰트 파일이 로드되면 다시 렌더링되어 FOUT 현상이 발생했죠.

그렇다면 폰트 리소스 요청 시점을 당길 수 있는 방법이 있을까요?

가능합니다. 바로 preload를 사용하는 것입니다.

preload를 사용하면, 즉시 필요한 웹 폰트를 미리 로딩하도록 설정할 수 있습니다.

이는 HTML <head> 태그 내에 rel="preload" 속성을 넣어 폰트 리소스를 미리 요청하고, 리소스 대기열에서 우선순위를 높여 폰트가 빠르게 로드될 수 있도록 하는 방법입니다.

<head>
  <link
    rel="preload"
    href="path/to/font.woff2"
    as="font"
    type="font/woff2"
    crossorigin="anonymous"
  />
</head>

이렇게 설정하면 폰트 파일은 CSS 파일이 로딩될 때까지 기다리지 않고 CSS 요청과 동시에 병렬로 요청되어 가져올 수 있습니다. 따라서 폰트 파일이 빠르게 로드되어 FOUT 현상을 최대한 방지할 수 있습니다.

하지만 폰트 파일 크기가 크거나 너무 많은 폰트를 미리 로드하려 하면, 초기 로딩 성능에 영향을 끼칠 수 있습니다.

따라서 명확하게 필요한 최소한의 폰트 파일만 preload로 설정하도록 주의해야 합니다.

@font-face 선언 최적화

@font-face는 웹 폰트를 정의하는 데 사용되는 CSS 규칙으로, 아래는 @font-face의 주요 속성입니다.

속성웹 성능 이점향상되는 성능 지표
unicode-range지정된 문자가 페이지에 사용될 때만 폰트 파일 다운로드FCP, LCP
local()사용자가 폰트를 로컬에 설치했는지 확인 후 설치된 폰트를 사용TTFB, FCP, LCP
font-display브라우저가 사용할 대체(fallback) 전략 정의CLS
size-adjust주요 폰트와 대체 폰트의 크기 조정CLS

unicode-range

unicode-range는 폰트 파일에 포함될 문자 범위를 지정할 수 있는 속성입니다. 이를 통해 웹 페이지에서 실제로 사용되는 문자만 포함된 폰트를 로드하게 되어 불필요한 문자가 포함된 폰트 파일을 줄일 수 있습니다.

@font-face {
  font-family: "CustomFont";
  src: url("customfont.woff2") format("woff2");
  unicode-range: U+0025-00FF; /* 필요한 문자 범위만 지정 */
}

local()

local() 함수는 사용자의 시스템에 해당 폰트가 이미 설치되어 있는지 확인한 후, 로컬 폰트를 사용할 수 있도록 합니다. 이를 통해 폰트 파일을 다시 다운로드할 필요 없이 로컬 폰트를 사용할 수 있습니다.

@font-face {
  font-family: "CustomFont";
  src: local("CustomFont"), url("customfont.woff2") format("woff2");
}

font-display

font-display 속성은 폰트 로딩 상태에 따라 브라우저가 사용할 대체(fallback) 폰트를 설정하는 속성입니다.

속성 목록은 아래와 같습니다.

  • auto - 브라우저의 기본동작에 맡기는 방식
  • block - FOIT 즉, 타임아웃까지 텍스트를 보여주지 않음
  • swap - 응답이 올 때까지 무한정 기다리고 그 전까진 바로 기본폰트를 보여줌. 꼭 적용해야만 하는 중요폰트일 경우에 쓸 수 있음.
  • fallback - 100ms 내외의 시간 동안만 block을 하고 기본폰트를 보여줌. 응답이 오면 해당 폰트로 swap 하지만 짧은 시간(3s)만 기다림.
  • optional - 100ms 내외의 시간 동안만 block을 하고 기본폰트를 보여준다. 그 후에는 대체하지 않는다. => 폰트가 상관이 없을 때

size-adjust

size-adjust주요 폰트와 대체 폰트의 크기를 동일하게 맞추는 데 사용됩니다. 대체 폰트와 주 폰트의 크기 차이가 크면 레이아웃이 변경되어 **CLS(Cumulative Layout Shift)**가 발생할 수 있기 때문에 이를 조정하여 레이아웃 안정성을 높입니다.

폰트 캐싱 적용

폰트를 캐싱하면 웹 페이지 로딩 속도를 개선할 수 있습니다.

폰트 파일을 캐시할 수 있도록 HTTP 헤더를 설정하면 사용자가 페이지를 재방문할 때 더 빠르게 로드됩니다.

적절한 Cache-Control 헤더를 사용하여 폰트 파일에 대해 적절한 캐시 기간을 설정하여 사용자가 페이지를 다시 방문할 때 폰트가 재다운로드되지 않도록 할 수 있습니다.

개발하면서 마주했던 문제들 & 고민점

에디터에서 폰트 변경 기능을 구현하면서 다양한 문제들과 마주했습니다. 이론적인 최적화 방법들을 실제 프로젝트에 적용하면서 겪은 구체적인 문제들과 해결 과정을 정리해보았습니다.

1. styled-components에서의 폰트 설정 이슈

문제 상황

여러 커스텀 폰트들을 프로젝트 내부에서 관리하면서 @font-face로 다양한 폰트 포맷과 weight를 설정해야 했습니다. 처음에는 styled-components의 createGlobalStyle 내에서 @font-face를 정의했었는데, 예상치 못한 문제가 발생했습니다.

const GlobalStyle = createGlobalStyle`
  @font-face {
    font-family: "NanumGothic";
    src: url("/assets/fonts/NanumGothic.woff2") format("woff2");
    font-weight: 400;
  }
`;

원인 분석

styled-components 공식 문서에서 확인된 내용에 따르면, createGlobalStyle에서 @font-face
사용할 때 동적 스타일 변경 시 폰트가 불필요하게 재요청되는 문제가 실제로 발생합니다.

styled-components v4에서 특히 이런 문제가 보고되었으며, 컴포넌트가 리렌더링되거나 라우터 변경 시마다 폰트 파일이 다시 다운로드되는 현상이 확인되었습니다.

중요한 점은 @font-face 선언 자체가 서버에 다운로드 요청을 보내지는 않지만, 웹 폰트는 실제로 페이지에서 사용될 때만 다운로드됩니다. 하지만 styled-components의 동적 스타일 시스템과 상호작용하면서 불필요한 재요청이 발생할 수 있습니다.

해결 방법

따라서 폰트 정의를 styled-components에서 완전히 분리하여 별도의 CSS 파일로 관리하도록
변경했습니다.

/* fonts.css - 별도 파일로 분리 */
@font-face {
  font-family: "NanumGothic";
  src: url("/assets/fonts/NanumGothic.woff2") format("woff2");
  font-weight: 400;
  font-display: swap;
}

2. 폰트 preload 범위 결정의 딜레마

preload를 사용해서 폰트를 미리 받아올 수 있지만, 어디까지 적용해야 할지 고민이 되었습니다.

  • 모든 폰트 preload: 사용자 경험은 좋지만 초기 로딩 성능에 영향
  • 필요한 것만 preload: 초기 성능은 좋지만 폰트 변경 시 지연 발생

특히 툴팁에서 보여주는 폰트 미리보기는 대부분 Regular weight만 사용하므로, Bold weight까지 미리 받아오는 것이 과연 효율적인지 의문이었습니다.

하지만 실제 사용자 패턴을 고려했을 때

  • 에디터 서비스의 특성상 폰트 변경이 자주 발생
  • 5개 폰트 정도는 초기 로딩에 큰 부담을 주지 않음
  • 복잡한 동적 로딩 로직보다는 단순한 구조가 유지보수에 유리

위와 같이 판단하여, 결과적으로 Regular weight의 주요 폰트들을 모두 preload하는 방식을 선택했습니다.

const criticalFonts = [
  "NotoSansKR-Regular.woff2",
  "NanumMyeongjo.woff2",
  "NanumGothic.woff2",
  "NanumBarunGothic.woff2",
  "Helvetica.woff2",
];

Vite 환경에서 폰트 preload 구현

우선 선택한 폰트들을 효율적으로 preload하기 위해 별도의 설정 파일을 만들었습니다.

// fontPreload.ts
import { HtmlTagDescriptor } from "vite";
 
const criticalFonts = [
  "NotoSansKR-Regular.woff2",
  "NanumMyeongjo.woff2",
  "NanumGothic.woff2",
  "NanumBarunGothic.woff2",
  "Helvetica.woff2",
];
 
export const injectFontsToHead: HtmlTagDescriptor[] = criticalFonts.map(
  (fontFile) => ({
    injectTo: "head",
    tag: "link",
    attrs: {
      rel: "preload",
      href: `/assets/fonts/${fontFile}`,
      as: "font",
      type: "font/woff2",
      crossorigin: "anonymous",
    },
  })
);

그 다음, Vite의 HTML 플러그인을 사용하여 빌드 시점에 폰트 preload 태그들을 자동으로 HTML head에 삽입하도록 설정했습니다.

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
import { injectFontsToHead } from "./src/utils/fontPreload";
import { createHtmlPlugin } from "vite-plugin-html";
 
export default defineConfig({
  plugins: [
    react(),
    createHtmlPlugin({
      minify: true,
      inject: {
        tags: injectFontsToHead,
      },
    }),
  ],
  publicDir: "public",
  base: "/",
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
      assets: path.resolve(__dirname, "./src/assets"),
      // ... 기타 alias 설정
    },
  },
});
폰트를 동적으로 받아오면서, 폰트가 느리게 적용이 되는 모습
폰트를 동적으로 받아오면서, 폰트가 느리게 적용이 되는 모습
preload를 통해 폰트를 미리 받아와서, 끊김없이 폰트가 적용이 되는 모습
preload를 통해 폰트를 미리 받아와서, 끊김없이 폰트가 적용이 되는 모습

실제로 preload할 Font가 많아지면서, 초기 로딩 성능에 이슈가 있을까하여 lighthouse로 분석한 결과 크게 차이가 없었습니다. 그래서 preload로 적용을 했습니다.

다만 향후 더 많은 폰트를 다뤄야 하는 상황이 온다면, 다음과 같은 전략을 고려할 것 같습니다.

  • 선택적 preload: 초기에 사용되는 기본 폰트만 preload하고 나머지는 동적 로딩
  • 파일 크기 최적화: 폰트 서브셋 활용으로 불필요한 글자 제거
  • 로딩 상태 관리: 동적 폰트 로딩 시 FOUT 방지를 위한 UI/UX 처리
    • 스켈레톤 UI 표시
    • 로딩 인디케이터 제공
    • 적절한 fallback 폰트 설정
  • web font
  • react
  • FOIT/FOUT
  • preload