프로필 사진
Sojin Park
Frontend Dev

한글 웹 폰트의 최적화
11,172개가 넘는 글자가 포함되어 있는 한글 웹 폰트를 어떻게 빠르게 로드할 수 있을까
2019. 6. 2.

웹 서비스가 iOS, Android, Windows 등 페이지가 열리는 환경에 관계 없이 일관적인 모습으로 보이도록 하는 방법 중 하나로 웹 폰트Web fonts를 사용할 수 있다. 웹 페이지를 만들 때 페이지가 iOS에서 열리느냐, Android에서 열리느냐에 따라 다른 모습으로 보이는 것을 간혹 경험했을 것이다. 이것은 각 환경에 따라 시스템 기본 글꼴이 다르기 때문이다.

웹 폰트를 이용하면 글꼴 파일을 네트워크로 전송함으로써 글자의 모양을 어떤 환경에서도 일관적으로 설정할 수 있다. 대표적으로 이 블로그도 2019년 5월 현재 Noto Serif KR 서체를 웹 폰트로 불러와 사용하고 있다.

최근에는 웹 폰트를 쉽게 사용할 수 있도록 하는 호스팅 서비스도 늘었기 때문에 웹 폰트 사용에 있어서 진입 장벽이 크게 낮아졌다. 웹 폰트 호스팅 서비스의 대표적인 예로 Google Fonts를 들 수 있다. Google Fonts를 사용하면 단순히 코드를 몇 줄 추가함으로써 원하는 웹 폰트를 바로 웹 서비스에 적용할 수 있다.

사용할 수 있다면 호스팅 서비스를 통해 웹 폰트를 사용하는 것이 제일 좋겠지만, 그렇지 못한 경우가 왕왕 있다. 사용하고자 하는 서체가 호스팅 서비스에 등록되어 있지 않거나, 회사에서 비즈니스 또는 보안 상의 이슈로 외부 서비스에 의존해서는 안 될 때의 상황 등에 그렇다. 이때를 위해 한글 웹 폰트를 최적화하는 방법을 소개한다.

웹 폰트 파일의 용량을 줄이자

웹에서 사용되는 모든 애셋의 가장 먼저 최적화해야 할 점은 용량이다. 특히, 한글은 라틴 문자 계열에 비해 사용되는 글자Glyph가 많기 때문에, 잘 관리하지 않는다면 크기가 무지막지하게 커진다. 알파벳은 각종 특수 기호들을 더해도 몇백 자 내외의 글자만이 사용되는 반면, 한글에서는 자주 사용되는 글자를 합치면 2,350자, 자주 사용되지 않는 글자까지 합한다면 11,172자가 사용된다. 단순 계산을 해 봐도 라틴 글자에 비해 글자 수가 몇십 배에서 몇백 배가 차이나기에 폰트 파일의 용량도 그만큼 차이가 발생한다.

네트워크를 통해서 송수신된다는 웹 폰트의 특징을 고려했을 때, 서체 파일의 큰 용량은 사용자 경험에 악영향을 미친다. 의미 있는 내용이 그려지는 초기 로딩 시간First Contentful Paint이 길어질 뿐더러, 서체가 로드될 때까지 글자가 표시되지 않기도 한다.

때문에 웹 폰트의 용량을 줄이는 여러 가지 방법을 적용하는 것이 필요하다.

WOFF 규격 사용하기

웹 폰트 파일의 크기를 줄이기 위한 첫 번째 방법으로 일반적인 컴퓨터 폰트 규격인 TTF나 OTF 대신 압축성이 좋은 웹 폰트 전용 규격을 사용해볼 수 있다. 대표적인 예로 모질라 재단에서 제작한 웹 폰트 전용 규격인 WOFFWeb Open Font Format 포맷을 사용해볼 수 있다. 이것을 이용하면 TTF나 OTF에 비해 큰 크기의 용량 절감을 이뤄낼 수 있으며, 사용할 수 있는 브라우저의 범위도 IE9까지 지원될 정도로 넓다.

Chrome 36, Safari 10, Firefox 35 이후의 최신 브라우저를 지원 범위로 하는 서비스의 경우에는 더욱 압축성이 좋은 WOFF 2.0 포맷을 사용하면 더욱 용량을 줄일 수 있다.

서브셋 글꼴 사용하기

현대 한글로 표현할 수 있는 11,172자 중에서 실제로 자주 사용되는 글자의 경우 그다지 많지 않다. 쁇, 힣, 쨠과 같이 일반적으로 나타나지 않는 글자는 웹 폰트로 제공하는 것이 낭비일 수 있다. 때문에 사용하고자 하는 글자들만 추려서 새 폰트 파일을 만든다면 용량을 절감할 수 있을 것이다. 일반적인 경우 KS X 1001에 정의된 한글 완성형 2,350자와 ㄱ, ㄴ, ㄷ을 포함하는 한글 자음과 모음, 알파벳, 문장 부호 정도만 제공해도 충분하다.

FontTools

TTF나 OTF 등의 폰트 파일을 WOFF 규격으로 만들고, 사용할 글자를 골라내기 위해서는 일반적으로 FontTools와 같은 도구를 사용한다. 사용법에 대해서는 잘 정리한 한국어 블로그 글이 있으므로 참고하면 좋다.

대체 폰트를 표시하는 방법을 조정하자

위와 같이 한글 웹 폰트 파일 크기의 최적화를 이루어 내고 나면 그 크기가 수백 킬로바이트 단위까지 떨어졌을 것이다. 그러나 수백 킬로바이트라고 하는 크기도 속도가 느린 무선인터넷 환경에서는 충분히 작지 않다. 웹 폰트 크기가 수십 KB밖에 되지 않는 라틴 문자 문화권에서도 웹 폰트의 로딩이 느려져 아래와 같은 일이 일어난다.

위 그림의 영어 문장을 보면 "미트 롬니는 대통령에 출마할 것임을 공식화했다"라는 기사의 제목으로 보인다. 그러나

웹 폰트 로딩이 끝난 후 기사의 제목은 "미트 롬니는 대통령에 출마하지 않을 것임을 공식화했다"으로 바뀐다. Not 부분이 별도의 기울임꼴 웹 폰트로 구성되면서 폰트 파일의 로드 시점에 차이가 생겨 벌어진 일이다. 이처럼 웹 폰트가 로딩되지 않았을 때 글자가 아예 표시되지 않는 것을 FOITFlash of Invisible Text라고 하는데, 경우에 따라서는 사용자 경험을 저하시킨다.

한글의 경우 폰트 파일의 용량이 크기에, 웹 폰트 로드 전 글자가 표시되지 않는 상황이 이것보다 오래 지속될 가능성이 크다. 따라서 그런 상황에서 글자를 어떻게 표시할지 효과적으로 설정할 필요가 있다.

CSS의 font-display 속성 사용하기

웹 폰트 로드가 늦어질 때 취할 수 있는 방법은 두 가지가 있다.

  1. 폰트 파일이 로드될 때까지 빈 글자를 보여준다. (Block)
    사용자가 웹 폰트 로드 전에는 글을 읽을 수 없고, 위와 같이 글의 내용이 왜곡될 수도 있다. 때문에 빈 글자가 보여지는 상황이 오래 지속되는 것은 바람직하지 않다.

  2. 폰트 파일이 로드될 때까지 대체 폰트로 글자를 보여준다. (Swap)
    해결책이 될 수 있으나 웹 폰트 로드가 완료된 이후 불러온 웹 폰트 파일로 글자가 다시 그려지면서 레이아웃이 요동치고 사용자 경험을 저하시킬 수 있다.

1번과 2번을 조합해서 생각해 본다면, 적당한 시기(로드 시작 이후 1초 이내)까지는 글자를 그리지 않고 기다리다가, 로드가 예상보다 오래 걸리는 경우 대체 폰트를 그려서 보여준다는 것이 대체로 맞는 방향임을 생각할 수 있다.

이것을 조절하는 것이 @font-face 규칙 안의 font-display 속성이다.

@font-face {
  font-family: ExampleFont;
  src: url('/path/to/fonts/examplefont.woff') format('woff');
  font-weight: 400;
  font-style: normal;
  /* font-display를 설정함으로써 웹폰트 로딩 완료 전 글자 표시 방법을 조절할 수 있다. */
  font-display: fallback;
}

  1. block: 웹폰트가 로드 완료될 때까지 글자를 표시하지 않는다. 로드에 아주 오랜 시간(3초 이상) 걸려야 대체 폰트를 표시한다.
  2. swap: 웹폰트 로드를 기다리지 않고 바로 대체 폰트를 표시한다.
  3. fallback: 웹폰트 로드를 아주 짧은 시간(보통 0.1초) 기다려 보고, 로드되지 않으면 대체 폰트를 표시한다.
  4. optional: 사용자 네트워크 상황에 따라 브라우저가 웹폰트를 사용할지 결정한다.
  5. auto: 사용자 브라우저에 처리를 위임한다. (기본값으로, 일반적으로 block 이다.)

이중 앞에서 말한 이유로 일반적으로 fallback 옵션이 추천된다. 처음부터 대체 폰트를 그리면 결국 글자를 다시 그려야 해서 사용자 입장에서 이질적으로 느껴지는 경우가 많기 때문이다. 0.1초 정도의 짧은 시간동안 웹 폰트 로드를 기다려 보는 것이 보통 가장 적합하다.

이 글(영문)에서 더욱 자세한 정보 및 예시를 담고 있다.

폰트 파일을 잘게 쪼개기

글자가 표시될 때까지의 시간을 더욱 아끼고 싶다면 폰트 파일을 잘게 쪼개는 방법을 사용할 수 있다. 이는 Google Fonts의 한글 웹 폰트인 Noto Sans KR에 적용되어 있는 방법이다. Chrome에서 해당 웹 폰트의 CSS 파일에 접속해보면, 폰트 파일이 90여 개로 쪼개져 있음을 확인할 수 있다.

이것은 사용자가 현대 한글 11,172자에 해당하는 자형 모두를 한꺼번에 내려받는 것이 아닌, 실제로 페이지에서 사용되는 글자들만 필요에 따라 다운로드받을 수 있게 하기 위함이다. 예를 들어, 하나의 페이지에서 전체 글자 중 500자만 사용된다면 사용자는 해당 500자에 해당하는 글자 모양만 내려받는 것이 가장 이상적일 것이다.

Google은 웹 폰트에 포함된 모든 글자를 자주 사용되는 글자들을 중심으로 100여 개의 묶음으로 나누었다. 그리고 사용자는 그 중 페이지에 포함되는 글자가 있는 묶음만 내려받도록 설정했다. 이렇게 하면 페이지에서 쓰이지 않은 글자를 대부분 걸러내고, 꼭 필요한 글자만을 중심으로 웹 폰트를 내려받을 수 있게 된다.

이러한 아이디어를 실현하기 위해서 CSS의 unicode-range 프로퍼티를 이용할 수 있다.

CSS의 unicode-range 속성

@font-face 규칙 안에서 쓸 수 있는 unicode-range 속성은 웹 폰트가 대상으로 하는 유니코드 글자의 범위를 지정하는 규칙이다. 예를 들어, 1, 2, 3과 같은 숫자의 경우에는 A라는 웹 폰트 파일을 사용하도록 하고, , , 와 같은 한글의 경우에는 B라는 웹 폰트 파일을 사용하도록 지정하는 식이다.

이렇게 웹 폰트를 정의하는 경우, 웹 브라우저는 unicode-range로 지정된 글자가 페이지에 존재하는 경우에만 해당 웹 폰트를 내려받게 된다. 예를 들어, A라는 폰트 파일로 그려지도록 지정된 1, 2, 3이 페이지에 존재해야 A 웹 폰트 파일을 내려받는다. 반대로, 웹 페이지에 , , 글자가 포함되지 않아서 웹 폰트 B를 불러올 필요가 없는 경우, 해당 폰트를 내려받지 않는다.

구체적인 예시를 코드로 확인하자.

@font-face {
  font-family: ExampleFont;
  font-weight: 400;
  font-style: normal;
  /* Unicode Range 속성으로 U+c790(한글 자)부터 U+c800(저) 까지의 유니코드 글자만을 examplefont_1.woff 로 처리하도록 한다.*/
  src: url('/path/to/fonts/examplefont_1.woff') format('woff');
  unicode-range: U+c790-c800;
}

@font-face {
  font-family: ExampleFont;
  font-weight: 400;
  font-style: normal;
  /* U+d74a(한글 모)에서 U+bab0(몰) 범위의 글자는 examplefont_2.woff 로 처리하도록 한다.*/
  src: url('/path/to/fonts/examplefont_2.woff') format('woff');
  unicode-range: U+baa8-bab0;
}

이렇게 ExampleFont 웹 폰트를 정의하면 U+c790-c800 범위의 글자가 페이지에 포함되는 경우에만 examplefont_1.woff를 내려받는 식으로 내용에 맞춰 필요한 웹 폰트만 내려받을 수 있다.

Google이 제공하는 Noto Sans KR 웹 폰트 CSS 파일의 일부분을 다시 한 번 살펴보면 모든 @font-face 블록에 unicode-range 속성이 포함되어 있음을 볼 수 있다.

/* [103] */
@font-face {
  font-family: 'Noto Sans KR';
  font-style: normal;
  font-weight: 400;
  src: local('Noto Sans KR Regular'), local('NotoSansKR-Regular'),
    url(https://fonts.gstatic.com/s/notosanskr/v11/PbykFmXiEBPT4ITbgNA5Cgm20xz64px_1hVWr0wuPNGmlQNMEfD4.103.woff2)
      format('woff2');
  unicode-range: U+b4, U+20a9, U+20ac, U+2190, U+24d8 /*... 길어서 생략 .. */;
}

또한, Noto Sans KR의 명조 버전인 Noto Serif KR의 CSS 파일에서도 동일한 unicode-range 범위로 웹 폰트 파일을 나눠 놓고 있다. 해당 범위가 Google이 생각했을 때 가장 효율적인 문자 그룹 나누기 방식이기 때문일 것으로 보인다. 우리는 이 범위를 사용하여 웹 폰트를 쪼갬으로써 동일한 최적화 효과를 가져갈 것이다.

Google이 사용하는 unicode-range 가공하기

Google의 CSS 파일에 포함되어 있는 한글 unicode-range 범위를 쉽게 사용할 수 있는 형태로 가공하는 작업이 필요하다.

간단히 Google에서 CSS 파일을 가져와서 unicode-range들을 뽑아 내는 스크립트를 작성한다. 아래는 스크립트의 핵심적인 부분을 정리한 것이다.

async function fetchUnicodeRangeGroups() {
  const cssContent = await fetchCSSFile(
    'https://fonts.googleapis.com/css?family=Noto+Sans+KR'
  );
  const unicodeRanges = parseUnicodeRanges(cssContent);

  return unicodeRanges;
}

async function fetchCSSFile(url) {
  const response = await fetch(url, {
    headers: {
      'User-Agent':
        // unicode-range가 포함된 CSS는
        // 상대적으로 새 브라우저의 User-Agent가 제공되었을 경우에만
        // Google이 서빙하기 때문에,
        // 커스텀 User-Agent를 헤더에 포함해 줌
        'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36',
    },
  });

  return response.text();
}

font-ranger npm 모듈로 폰트 파일 쪼개기

NPM 패키지 font-ranger를 이용하면 가져온 unicode-range를 실제 폰트 파일에 적용하고, 여러 개의 파일로 쪼갤 수 있다.

아래처럼 비동기로 동시에 웹 폰트를 쪼개주는 스크립트를 작성한다.

const fontRanger = require('font-ranger/lib/font-ranger');
const fetchUnicodeRangeGroups = require('./download');

(async () => {
  const unicodeRangeGroups = await fetchUnicodeRangeGroups();

  const jobs = unicodeRangeGroups.map(async (ranges, index) => {
    try {
      await fontRanger({
        fontFile,
        fontFamily,
        fontWeight,
        fontDisplay,
        skipCss,
        addWoff,
        urlPrefix,
        outputFolder,
        locals,
        ranges,
        fontName: `<span class="katex"><span class="katex-mathml"><math><semantics><mrow><mrow><mi>f</mi><mi>o</mi><mi>n</mi><mi>t</mi><mi>F</mi><mi>a</mi><mi>m</mi><mi>i</mi><mi>l</mi><mi>y</mi><mi mathvariant="normal">.</mi><mi>t</mi><mi>o</mi><mi>L</mi><mi>o</mi><mi>w</mi><mi>e</mi><mi>r</mi><mi>C</mi><mi>a</mi><mi>s</mi><mi>e</mi><mo>(</mo><mo>)</mo></mrow><mi mathvariant="normal">.</mi></mrow><annotation encoding="application/x-tex">{fontFamily.toLowerCase()}.</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord"><span class="mord mathdefault" style="margin-right:0.10764em;">f</span><span class="mord mathdefault">o</span><span class="mord mathdefault">n</span><span class="mord mathdefault">t</span><span class="mord mathdefault" style="margin-right:0.13889em;">F</span><span class="mord mathdefault">a</span><span class="mord mathdefault">m</span><span class="mord mathdefault">i</span><span class="mord mathdefault" style="margin-right:0.01968em;">l</span><span class="mord mathdefault" style="margin-right:0.03588em;">y</span><span class="mord">.</span><span class="mord mathdefault">t</span><span class="mord mathdefault">o</span><span class="mord mathdefault">L</span><span class="mord mathdefault">o</span><span class="mord mathdefault" style="margin-right:0.02691em;">w</span><span class="mord mathdefault">e</span><span class="mord mathdefault" style="margin-right:0.02778em;">r</span><span class="mord mathdefault" style="margin-right:0.07153em;">C</span><span class="mord mathdefault">a</span><span class="mord mathdefault">s</span><span class="mord mathdefault">e</span><span class="mopen">(</span><span class="mclose">)</span></span><span class="mord">.</span></span></span></span>{index}`,
      });
    } catch (e) {
      console.error(e);
    }
  });
  await Promise.all(jobs);
})();

생성된 CSS 파일을 모으기

작업이 완료되었으면 쪼개진 웹 폰트마다 생성된 CSS 파일을 한 곳으로 모으는 작업이 필요하다. 마찬가지로 스크립트를 작성하자.

const fs = require('fs');
const path = require('path');
const concat = require('concat');
const rimraf = require('rimraf');

async function aggregateCSSFiles(dir, dest) {
  const filePaths = getCSSFilePaths(dir);
  const result = await concat(filePaths);

  await removeIntermediateCSSFiles(dir);
  fs.writeFileSync(dest, result);
}

function getCSSFilePaths(dir) {
  return fs
    .readdirSync(dir)
    .map(fileName => path.join(dir, fileName))
    .filter(filePath => {
      if (!fs.statSync(filePath).isFile()) {
        return false;
      }

      return /\.css$/.test(filePath);
    });
}

async function removeIntermediateCSSFiles(dir) {
  return await new Promise(resolve => {
    rimraf(path.join(dir, '**', '*.css'), resolve);
  });
}

위 3가지 스크립트를 직접 작성해서 실행하는 것은 귀찮은 일이므로, 작동하는 코드를 korean-web-font-optimization 레포지토리에 업로드해 두었다. README에 포함된 방법으로 명령어를 실행하면 한글 웹 폰트를 여러 개의 파일로 쪼개둘 수 있다.

웹 폰트의 업로드

이제 작성한 웹 폰트 파일들을 AWS S3나 GCP Filestore와 같은 서비스에 업로드하고, 웹 서비스에 포함하면 자신만의 최적화된 웹 폰트를 사용할 수 있다.

<link rel="stylesheet" href="https://cdn.example.com/css/font.css" />

<style>
  body {
    font-family: 'font';
  }
</style>

정리

  1. 웹 폰트는 글꼴 파일을 네트워크로 전송하여 글자의 모양이 어떤 환경에서도 일관적으로 보여질 수 있도록 하는 기술이다.
  2. 한글 웹 폰트는 포함된 글자가 많기 때문에 성능을 위해 용량에 신경을 써 주어야 한다.
    • 압축성이 좋은 WOFF 파일 포맷으로 웹 폰트 파일의 용량을 절감할 수 있다.
  3. 웹 폰트 파일의 로드가 완료되지 않은 경우 글자 표시 방법을 font-display로 지정할 수 있다. 일반적으로 알맞은 값은 fallback이다.
  4. 웹 폰트 파일을 여러 개로 쪼개면 필요한 글자만 내려받음으로써 성능 향상을 이끌어낼 수 있다.
    • 여기에 CSS unicode-range 속성을 이용한다.