[성능 최적화] 폰트 최적화란?!

이주영·2024년 2월 27일
1

성능 최적화

목록 보기
4/4

서론

돈워리 프로젝트를 하면서 next.js에서 제공해주는 localFont를 활용하여 정적 폰트를 사용하였는데...

이 참에 선배 개발자분들의 노고와 문제를 해결한 방법을 살펴보고 최적화하는 방법을 정리해보려고 한다.

본론

브라우저에서 폰트의 동작 원리를 살펴보자

기본 동작

브라우저는 글꼴을 렌더링하기 전에 렌더 트리를 구성해야한다. 그 말인 즉슨 DOM과 CSSOM이 만들어져 합쳐져야한다는 것이다. 그로 인해 폰트는 서버로부터 응답을 받을때까지 브라우저가 텍스트를 렌더링하지 못하는 경우가 있다. 그렇게 되면 유저가 페이지에 들어왔을때 무엇인가 보이고 난 후 레이아웃이 변경되면서 폰트가 적용되는 어릴 적 흔했던 웹페이지를 볼 수 있다.

위의 작업을 하나씩 정리해보면

  1. 서버로 HTML 문서를 요청.
  2. 서버로부터 받은 HTML 응답을 파싱하고 DOM을 구성하기 시작.
  3. 브라우저가 CSS, JS 및 기타 리소스를 발견하고 요청을 전달.
  4. 브라우저는 모든 CSS 콘텐츠가 수신된 후 CSSOM을 생성하고 이를 DOM 트리와 결합하여 렌더 트리를 구성.
    • 렌더링 트리가 페이지에 지정된 텍스트를 렌더링하는 데 필요한 글꼴 버전을 표시하면 글꼴 요청이 전달.
  5. 브라우저는 레이아웃을 실행하고 콘텐츠를 화면에 그림.
    • 글꼴을 아직 사용할 수 없는 경우 브라우저가 텍스트 픽셀을 렌더링할 수 없고 글꼴을 사용할 수 있게 되면 브라우저가 텍스트 픽셀을 그림.

과거에는 이런 문제가?

브라우저에 내장되어있는 폰트만을 사용해야했고 특정 폰트를 사용하려면 해당 폰트를 서버에 호스팅하고 가지고 오는 방식으로 과거에는 구현했다고 한다. 혹은 이미지를 사용하여 텍스트를 렌더링하고 이미지에 원하는 폰트 스타일을 적용하는 방법도 사용됐다. 이런 문제를 해결하기 위해 ... 등장한 것이!

1998년에 재정된 @font-face로 해결

@font-face라는 기본 css를 활용하여 웹페이지의 텍스트에 온라인폰트를 적용할 수 있게 됐다. @font-face를 사용하여 개발자가 원하는 폰트를 사용할 수 있게 됐다. (그러면 이전에는 안됐다는건가?! ) 로컬 폰트만 사용해야했던 제약
@font-face statement는 font-family, src, font-weight, font-style 등의 속성을 사용하여 폰트를 세밀하게 제어할 수 있습니다.

두번째 문제가 있었다.

렌더 트리 이후 화면에 그려지는 단계에서 병목이 일어나 글꼴이 늦게 보여지는 문제를 어떻게 해결할 수 있을까였다.

가장 먼저 요청을 보내고 @font-face의 속성인 font-display로 해결하다

첫번째, WebFonts를 미리 로드한다.

//html 파일의 header 내부에 
 <link rel="preload"></link>

를 추가하여 CSSOM이 생성되기 전 렌더링 경로 초기에 WebFont 요청을 트리거한다.

font-display를 알기전 사전 지식이 필요한데 그건 FOUT, FOIT

FOUT, FOIT는 무엇인가

폰트의 변화로 발생하는 이 현상을 FOUT (Flash of Unstyled Text) 혹은 FOIT라고 한다.

  1. FOUT (Flash Of Unstyled Text)
    FOUT는 웹 폰트가 아직 로드되지 않아 기본 시스템 폰트로 텍스트가 표시된 후, 웹 폰트가 로드되면 해당 폰트로 텍스트가 교체되는 현상을 말한다.
  2. FOIT (Flash Of Invisible Text
    FOIT는 웹 폰트가 로드될 때까지 텍스트가 화면에 표시되지 않는 현상을 말한다.

예를 들어 사용하고 있는 브라우저의 기본 설정을 알아보자

  • Chrome 및 Edge : FOIT 3초
  • Firefox : FOIT 3초
  • Safari : FOUT

TIP. 그런데 왜? 지난 프로젝트(알리고 올리고)에선 크롬인데도 불구하고 FOUT 현상이 일어났지?
3초만 기다리는 FOIT이기 때문이다. 폰트의 크기가 커 3초이상의 로드 시간이 걸려 약 1초에서 2초 사이에 텍스트가 보이고 그 위에 폰트가 덮어씌게되는것이다.

FOIT,FOUT를 알았으니 @font-face의 속성인 font-display를 알아보자

font-display는 글꼴 다운로드 기간마다 글자를 어떻게 보여줄지 결정하는 CSS 속성인데 3가지 기간으로 구분된다.

속성에는 5가지가 있는데 간단히 살펴보면

  • auto : 브라우저의 기본 동작을 따른다. 사파리를 제외하고 나머지는 FOUT
  • block : FOIT라고 생각하면 될 것 같다.
  • swap : 글꼴이 다운로드되기 전까지, 시스템 글꼴을 사용하다가 다운로드가 완료되면 웹 글꼴을 적용한다.
  • fallback : 100ms 동안 글자를 숨기고, 3초 이내에 글꼴이 로드되면 웹 글꼴을 적용한다. 만약 3초(swap 기간) 동안 글꼴이 로드되지 않으면 대체 글꼴을 사용한다.
  • optional: fallback과 비슷한데 핵심은 네트워크의 상황에 따라 브라우저에게 글콜 다운로드 여부를 위임한다는 것이다.

더 좋은 옵션이 있다?!

미리 로드하는 방식과 font-display를 함께 사용해도 좋지만 추가 맞춤 설정이 필요하니 더 좋은 옵션을 소개해준다. Font Loading API에 대해 이번 블로그가 아닌 다른 블로그에서 알아보려고 한다.

본론 폰트 최적화 방법

1. 폰트 파일 크기 줄이기

압축률 좋은 포맷 사용하기
1. WOFF : 기존의 TTF와 OTF와 동일하게 동작하지만, 압축을 통해 더 작은 파일 크기를 가진다.
2. WOFF2 : WOFF보다 30~50% 정도 더 작은 파일 크기를 가지지만 호환하지 않을 확률이 높아 WOFF를 Fallback 폰트로 같이 사용한다.

2. preload 옵션으로 먼저 로딩하기

<link rel=””> 속성으로 preload 옵션을 사용하면 해당 리소스를 다른 리소스보다 빨리 로딩한다. 주로 폰트 파일, 이미지 파일, 스크립트 파일, 비디오 파일 등 페이지에서 중요도가 높은 자원을 의도적으로 먼저 로딩할때 사용한다.

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

3. 서브셋 폰트 사용하기

서브셋 폰트(Subset font)란 폰트 파일에서 불필요한 글자들을 제거하고 사용할 글자만 남겨둔 폰트이다. 이러한 불필요한 글자를 폰트에서 제거하고 사용할 글자만 남겨둔 폰트가 서브셋 폰트입니다. 보통 서브셋 폰트를 사용하면 용량이 pretendard 기준, 814KB → 273KB까지 감소한다고 한다. (출처 : https://www.datoybi.com/Web-font-optimization/)

현재 돈워리에서 폰트 최적화 코드 이유 살펴보기

현재 진행중인 축의금 선정을 도와주는 서비스인 돈워리에서는 next.js를 사용하고 있다. 로컬 폰트를 활용하여 서버로 폰트 파일을 받지 않도록 구현하였고 next/font/local 모듈을 활용하고 있다. localFont의 타입을 살펴보면서 어떻게 구현되어있는지 살펴보려고 한다.


// fonts.ts
import localFont from 'next/font/local';

export const pretendard = localFont({
  src: [
    {
      path: '../assets/fonts/Pretendard/Pretendard-Thin.woff2',
      weight: '100',
    },
    {
      path: '../assets/fonts/Pretendard/Pretendard-ExtraLight.woff2',
      weight: '200',
    },
    {
      path: '../assets/fonts/Pretendard/Pretendard-Light.woff2',
      weight: '300',
    },
    {
      path: '../assets/fonts/Pretendard/Pretendard-Regular.woff2',
      weight: '400',
    },
    {
      path: '../assets/fonts/Pretendard/Pretendard-Medium.woff2',
      weight: '500',
    },
    {
      path: '../assets/fonts/Pretendard/Pretendard-SemiBold.woff2',
      weight: '600',
    },
    {
      path: '../assets/fonts/Pretendard/Pretendard-Bold.woff2',
      weight: '700',
    },
    {
      path: '../assets/fonts/Pretendard/Pretendard-ExtraBold.woff2',
      weight: '800',
    },
    {
      path: '../assets/fonts/Pretendard/Pretendard-Black.woff2',
      weight: '900',
    },
  ],
  variable: '--font-pretendard',
});


//layout.ts
import type { Metadata } from 'next';

import { ToastContainer } from '@/components/common/toast';
import { META } from '@/constants/metadata';
import Providers from '@/contexts/Providers';

import { pretendard } from './fonts';

import './globals.css';

// TODO: 메타 데이터 수정
export const metadata: Metadata = META;

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ko">
      <body className={`${pretendard.variable}`} suppressHydrationWarning={true}>
        <Providers>
          <ToastContainer />
          <div className="layout font-pretendard ">{children}</div>
        </Providers>
      </body>
    </html>
  );
}

위의 코드와 같이 정적 폰트 파일을 가지고 있고 localFont 매소드의 인자로 객체 안에 넣어주고 있다. localFont의 타입을 살펴보면

type LocalFont<T extends CssVariable | undefined = undefined> = {
    src: string | Array<{
        path: string;
        weight?: string;
        style?: string;
    }>;
    display?: Display;
    weight?: string;
    style?: string;
    adjustFontFallback?: 'Arial' | 'Times New Roman' | false;
    fallback?: string[];
    preload?: boolean;
    variable?: T;
    declarations?: Array<{
        prop: string;
        value: string;
    }>;
};

export default function localFont<T extends CssVariable | undefined = undefined>(options: LocalFont<T>): T extends undefined ? NextFont : NextFontWithVariable;

localFont는 undefined를 기본으로 CssVariable과 undefined 타입을 상속 받은 제네릭 타입을 받고 있고 인자로는 LocalFont 타입에 T를 받고 있다.

//위에 보이는 타입들을 조금 더 깊이 살펴보자 
export type CssVariable = `--${string}`;
export type Display = 'auto' | 'block' | 'swap' | 'fallback' | 'optional'; // 이부분은 font-display의 value값과 동일하다. 구현은 font-display로 되어있는 것 같다. 
export type NextFont = {
    className: string;
    style: {
        fontFamily: string;
        fontWeight?: number;
        fontStyle?: string;
    };
};
export type NextFontWithVariable = NextFont & {
    variable: string;
};

핵심적으로 localFont도 결국 @font-face를 보다 쉽게 사용할 수 있도록 구현해놓은 것이 아닐까 생각한다.

결론

Next/font를 채택한 이유는 이러하다.

  • next/font will automatically optimize your fonts (including custom fonts) and remove external network requests for improved privacy and performance.
  • next/font includes built-in automatic self-hosting for any font file. This means you can optimally load web fonts with zero layout shift, thanks to the underlying CSS size-adjust property used.
  • This new font system also allows you to conveniently use all Google Fonts with performance and privacy in mind. CSS and font files are downloaded at build time and self-hosted with the rest of your static assets. No requests are sent to Google by the browser.
    Next.js - Font Optimization
  • next-font를 사용하면 자동으로 최적화를 해주며 레이아웃 이동 없이 폰트를 적용할 수 있다. 만약 구글 폰트를 사용하면 요청을 보내지 않고 CSS와 폰트 파일이 빌드시 같이 다운로드 돼, 깜빡거리는 현상이 사라진다.
  • 개인 정보 보호와 성능을 향상시키기 위해 외부 네트워크 요청을 제거한다.
  • CSS의 size-adjust 속성을 사용하여 자동으로 레이아웃 변동을 제거한다.

점차 기본적인 개념들을 몰라도 개발하는데는 무리가 없어지고 있다고 느낀다. 필히 기술에 대한 역사와 배경은 알고 쓰자.

출처
1. 웹 프론트엔드 성능 최적화 교제
2. web.dev : https://web.dev/articles/optimize-webfont-loading?hl=ko
3. @font-face : https://developer.mozilla.org/ko/docs/Web/CSS/@font-face
4. Naver D2 : https://d2.naver.com/helloworld/4969726
5. Next.js 공식 문서 : https://nextjs.org/docs/pages/building-your-application/optimizing/fonts

profile
https://danny-blog.vercel.app/ 문제 해결 과정을 정리하는 블로그입니다.

0개의 댓글