지연 시간 없이 웹폰트 서빙하기 - Feat. Safari & Edge functions

지연 시간 없이 웹폰트 서빙하기 - Feat. Safari & Edge functions

안녕하세요 뱅크샐러드 Web Front-End Engineer 민찬기입니다. 여러분들은 운영하시는 서비스의 폰트를 바꾸신 적이 있으신가요? 바꾸시는 과정에서 어떤 어려움을 겪으셨나요?

눈썰미가 남다르신 분들은 눈치채셨겠지만, 뱅크샐러드는 9월부터 여기 블로그를 포함한 모든 서비스에 Pretendard를 공통 폰트로 사용하고 있습니다. 이번 지면을 빌어 뱅크샐러드 Web/Webview page에서 커스텀 폰트를 적용하며 얻은 경험을 공유하고자 합니다.

커스텀 폰트 표시하기 😎

@font-face 구문을 전역 CSS 파일에 추가하여 커스텀 폰트를 사용할 수 있습니다.

@font-face {
  font-family: "Pretendard";
  font-weight: 800;
  font-display: swap;
  src: local("Pretendard ExtraBold"),
    url("./pretendard/Pretendard-ExtraBold.woff2") format("woff2"),
    url("./pretendard/Pretendard-ExtraBold.woff") format("woff");
}
...

일반적인 지원 범위 (<2%)에서는 woff2를 우선하여 로드하고 woff를 fallback font로 지정하시면 충분합니다. (woff2는 woff대비 50% 가량 적은 용량을 차지합니다.)

그러면 적용도 마쳤으니 결과를 확인해 볼까요?

폰트 로드가 지연되는 증상
폰트 로드가 지연되는 증상

어떠신가요? 분명 폰트는 잘 표시되지만, 순간적으로 폰트가 출렁이는 게 느껴지셨나요? 많은 Glyph를 가진 폰트 파일을 다운로드하는 데에는 시간이 필요합니다. 브라우저는 폰트를 다운로드받는 동안 대체 폰트를 표시하기 때문에 깜빡이는 증상이 발생하여 사용자 경험을 해칠 수 있습니다.

font-display: block를 사용하면 브라우저는 폰트가 다운로드 되기 전까지 투명한 폰트를 사용하여 페이지를 렌더링합니다. 하지만 이 경우에도 사용자는 좋지 못한 경험을 느낄 수 있습니다.

따라서 좋은 사용자 경험을 위해 우리는 브라우저가 최대한 빨리 폰트를 가져올 수 있도록 도와줘야 합니다.

꼭 필요한 Glyph만 포함하는 폰트 파일 만들기 📝

가장 먼저, 웹 페이지에서 노출될 일이 없는 Glyph를 삭제할 수 있습니다.

Glyph: 자체(字體), 자형(字形)은 글자의 모양을 가리킨다. 자체는 하나 이상의 자소로 이루어진다. 글리프(glyph)라는 개념은 자체의 문자 코드에서 뜻과 소리를 지니지 않은 도형 기호(구두점, 괄호, 공백 등)의 추상화를 포함한다. (Wikipedia)

영문 폰트는 대소문자를 포함해 72개의 Glyph만 있으면 됩니다. 하지만 한글은 자모음의 조합으로 Glyph가 구성되기 때문에 가능한 모든 조합을 계산하면 11,172개의 Glyph가 필요합니다. 따라서 한글이 포함된 폰트는 용량이 커지게 됩니다.

하지만 우리가 웹 페이지를 선보이기 위해 11,172개의 글자가 필요할까요? Pretendard font를 한번 확인해 보겠습니다.

불필요한 자체들의 모임
불필요한 Glyph들

이런 Glyph를 브라우저에 보낼 필요가 있을까요? 미사용 Glyph를 삭제하면 폰트 용량을 많이 줄일 수 있습니다. 한글 부호의 Subset은 정보 교환용 부호계를 참고할 수 있으며, 변환은 fonttools 등의 라이브러리를 사용하실 수 있습니다.

뱅크샐러드에서는 정보 교환용 부호계에 더하여 사용자가 입력 과정에서 발생시킬 수 있는 Glyph에 대한 Subset를 추가해서 Pretendard font에 대한 Subset font를 제작했습니다. Subset으로 인해 원본 대비 70% 이상 용량을 절감할 수 있었습니다.

원본 대비 70% 적은 용량을 차지하는 Subset Font
원본 대비 70% 적은 용량을 차지하는 Subset Font


Critical Request Depth 줄이기

Subset Font를 통해 폰트의 크기를 줄일 수 있었습니다. 하지만 최대한 빨리 에 관점에서 몇 가지 더 개선할 점이 있습니다. 개발자 도구를 통해 Network waterfall을 한번 살펴볼까요?

CSS 로드 후 폰트를 가져오기까지 5초 이상 지연됨
CSS 로드 후 폰트를 가져오기까지 5초 이상 지연됨

보시는 바와 같이 initial load와 font request 사이에 지연이 발생함을 알 수 있습니다. 커스텀 폰트 표시하기 😎 절에서 우리는 font.css 파일을 사용했기 때문인데요, 브라우저는 font.css 파일을 다운로드받기 전에는 어떤 font request를 날려야 하는지 알 수 없고, initial request가 완료되기 전까지 어떤 CSS 파일이 필요한지 알 수 없습니다. 한번 정리해보면..

  1. 브라우저는 URL에 GET을 요청합니다.
  2. 브라우저가 font.css가 필요함을 확인하고 GET을 요청합니다.
  3. 브라우저가 font.css을 읽고 font request를 발생합니다.
  4. 브라우저가 font를 획득합니다.

보시는 바와 같이 font를 가져오기 위해 동기적으로 여러 가지 요청을 발생했습니다. 이러한 문제를 Critical Request Depth라고 부릅니다. 더욱 빠른 페이지를 위해 우리는 CRD를 줄여야 합니다. 어떻게 줄일 수 있을까요?

우리는 정확히 어느 URL로 font request를 전송해야 할 지 알고 있습니다. font.css를 작성하는 시점에 알고 있으니까요. 이렇게 최초 로드 후 최대한 빨리 가져와야 하는 리소스를 대상으로 우리는 preload link를 사용할 수 있습니다.

// _document.tsx
export const BanksaladDocument = () => {
  return (
    <Html lang="ko">
      <Head>
        {/* Preload woff2 fonts */}
        <link
          rel="preload"
          as="font"
          type="font/woff2"
          crossOrigin=""
          href={`${CDN_BASE_URL}/font/pretendard/Pretendard-Regular.subset.woff2`}
        />
        {/* Request font.css */}
        <link
          rel="stylesheet"
          type="text/css"
          href={`${CDN_BASE_URL}/font.css`}
        />
      </Head>
      ...
    </Html>
  );
};
CSS와 font를 병렬로 다운로드하여 CRD 해결
CSS와 font를 병렬로 다운로드하여 CRD 해결

이제 Network waterfall은 위와 같이 개선되었고, 사용자는 큰 불편함 없이 페이지를 사용할 수 있게 되었습니다. 👏👏👏



Safari는 왜 Preload가 동작하지 않을까?

뱅크샐러드는 위와 같은 과정을 통해 Pretendard 폰트를 여러분께 제공하고 있습니다. 적용하는 과정에서 Safari에서만 preload link가 동작하지 않는 이슈가 있었습니다. Safari에서 Network waterfall을 확인해보니 preload 한 요청을 재사용하지 않고, 다시 요청함을 확인할 수 있었습니다.

Safari > 동일한 font가 2번 다운로드됨
Safari > 동일한 font가 2번 다운로드됨

왜 이런 일이 발생했을까요?

HTTP Cache

브라우저와 CDN은 각각 Cache를 가지고 있습니다. 가장 기본적인 Cache Table은 URL을 Cache Key로 사용합니다.

URL Response
cdn.banksalad.com/font/pretendard <binary file>
banksalad.com <!doctype html>…

하지만 같은 URL에 요청해도 동일한 응답이 반환되지 않을 수 있습니다. 예를 들어 Accept-Encoding Header를 Cache된 요청과 다르게 보낼 경우 Server는 다른 응답을 반환할 수 있습니다. 이 문제를 해결하기 위해 Server는 특정 Header에 의해 응답값이 변경될 수 있다면 Vary Header에 문제가 될 수 있는 Header를 필드로 삽입합니다. Cache는 Vary Header의 각 값마다 Table에 새로운 열을 생성하고, 새로 발생한 요청의 Header를 대조해서 Cache Hit 여부를 결정합니다.

CORS에 관련된 Origin 값에 따라 브라우저가 응답을 거부할 가능성이 있으므로 Static File Server는 응답의 Vary Header에 Origin을 삽입합니다. 이에 따라 CORS가 활성화된 요청에 대한 브라우저의 Private Cache는 아래와 같이 구성됩니다.

URL Origin Response
cdn.banksalad.com/font/pretendard banksalad.com <binary file>

따라서 위와 같이 CORS로 요청한 응답이 캐시된 상황에서 Origin이 없는 요청 (non-CORS) 요청을 할 경우 브리우저 Cache Table의 Vary Header가 일치하지 않았으므로 Cache Hit이 발생하지 않습니다.

Font request는 CORS 요청입니다.

MDN의 CORS 관련 문서에 적혀진 바와 같이, W3C Recommendation에 의거하여 Font에 대한 cross-domain 요청은 CORS 요청이어야 합니다.

MDN > 어떤 요청이 CORS를 사용하나요?
MDN > 어떤 요청이 CORS를 사용하나요?


즉, font.css에서 url() 표기법으로 작성한 요청은 CORS 요청으로 발생합니다. 하지만 preload link는 기본적으로 non-CORS 요청이므로 우리는 CORS 요청으로 맞춰주기 위해 crossorigin attribute를 사용했습니다.

<link
  rel="preload"
  as="font"
  type="font/woff2"
  crossOrigin="" // 이 요청은 CORS 요청임을 알려줍니다.
  href={`${CDN_BASE_URL}/font/pretendard/Pretendard-Regular.subset.woff2`}
/>

따라서 가이드라인대로 우리는 url()preload link의 CORS credentials flag를 동일하게 맞췄으므로 preload 과정에서 생성된 Cache를 재활용해야 했습니다. 왜 Safari에서만 문제가 발생했을까요?

Safari는 Font preload request에 CORS 요청을 하지 않습니다.

안타깝게도 Safari는 @font-face에서 교차 도메인 리소스를 요청하는 겅우 CORS 요청을 하지 않습니다. 이는 Safari의 브라우저 엔진인 Webkit의 개발자들이 의도적으로 구현하지 않았기 때문입니다.

W3C Recommendation에서 Font에 대한 cross-domain 요청은 CORS 요청으로 제한한 것은 폰트의 소유권을 보장하기 위한 측면이 있었습니다. 폰트를 소유한 사람이 허용한 도메인에서만 폰트를 다운로드하도록 제한하는 거죠.

간단히 예를 들어, 제가 폰트를 구매해서 CDN에 올렸을때 다른 사이트의 개발자가 제 CDN에 있는 폰트 파일을 사용하는 것을 방지하기 위함입니다.

The implications of this for authors are that fonts will typically not be loaded cross-origin unless authors specifically takes steps to permit cross-origin loads. (W3C-CSS Fonts Module Level 3)

Webkit의 이슈 트레커에 따르면 이러한 조치를 Webkit은 동의하지 않았고, 구현하지 않은 것으로 보입니다. 이슈 트레커에서 관련 논의를 확인할 수 있습니다.

we didn’t want to add pseudo-DRM to the Web. No word on breaking content, which didn’t even exist back then. (Webkit Bugzilla - Downloadable font loads should be subject to CORS)

Edge function으로 Safari에 Preload 적용하기

이 문제를 해결하기 위해서는 Safari가 preload한 Cache를 non-CORS font request에서 활용할 수 있게 만들어야 합니다. 저는 이 문제를 해결하기 위해 Cloudfront Function (이하 CFF)를 사용했습니다. CFF는 Viewer (클라이언트)와 CDN의 사이에서 요청/응답의 데이터를 조작할 수 있는 미들웨어를 제공합니다. 동작 흐름은 아래와 같습니다.

CloudFront function flowchart

CFF 내에서 View to CDN Request가 폰트와 관련된 파일인 경우 Origin를 무조건 삽입합니다. Safari를 제외한 브라우저는 W3C Recommendation에 의해 Origin를 이미 넣어주고 있으므로 문제가 없고, Safari에서 발생한 요청이 Origin (S3)에서 CORS 응답으로 돌아오지 않는 문제를 해결할 수 있습니다.

// cff-viewer-request.js
function handler(event) {
   var request = event.request;
   var headers = request.headers;
   var uri = request.uri
   var host = headers.host.value
   var isRequestsFont = uri.startsWith('[**scrubbed**]')
 
   // Origin header가 없다면 추가해서 CORS 요청으로 변경합니다.
   if (!headers.origin && isRequestsFont) {
     headers.origin = {value:`https://${host}`};
   }
   return request;
}

CDN to Viewer Response가 폰트 관련 파일인 경우 Vary Header 중 Origin을 삭제합니다. 이는 Safari가 preload 요청에서는 Origin Header를 포함해서 요청했기 때문에 Safari에서 Cache를 사용하지 못하는 문제를 해결합니다.

// cff-viewer-response.js
function handler(event)  {
   var response  = event.response;
   var request  = event.request;
   var headers  = response.headers;
   var uri = request.uri;
   var isRequestsFont = uri.startsWith('[**scrubbed**]')
   if(headers['vary'] && isRequestsFont) {
       headers.vary = headers.vary.filter(header => header !=='Origin')
   }
   return response;
}

마지막으로 위 두가지 CFF를 코드로 관리하기 위해 Terraform IaC 코드를 작성하여 리소스 관리가 잘 이루어지도록 했습니다.

# cdn cloundfront function
resource "aws_cloudfront_function" "cdn_viewer_request" {
  name    = "cdn-viewer-request"
  runtime = "cloudfront-js-1.0"
  comment = "[**scrubbed**] 경로에 대해 {key: Origin, value: `https: host`} 값을 request.header에 추가합니다."
  publish = true
  code    = file([**scrubbed**])
}

resource "aws_cloudfront_function" "cdn_viewer_response" {
  name    = "cdn-viewer-response"
  runtime = "cloudfront-js-1.0"
  comment = "[**scrubbed**] 요청에 대한 vary header 중 Origin을 삭제합니다."
  publish = true
  code    = file([**scrubbed**])
}

요약

  • 웹 폰트를 적용할 때 Subset Font를 사용하여 용량을 줄일 수 있습니다.
  • 폰트, 스크린 상단에 표시되는 이미지 등 최대한 빨리 가져와야 하는 리소스는 preload link를 사용할 수 있습니다.
  • 자주 사용하는 CDN 등 최초 연결 이후 10초 이내에 요청할 가능성이 높은 Domain에 대하여 preconnect link를 사용할 수 있습니다.
  • 브라우저 이슈로 인해 Cache가 동작하지 않는 경우 CloudFront Function 등 Edge function을 사용할 수 있습니다.

지금까지 Web Front-End Engineer 민찬기였습니다. 감사합니다.

보다 빠르게 뱅크샐러드에 도달하는 방법 🚀

지원하기
Share This:

Featured Posts

post preview
post preview
post preview
post preview
post preview

Related Posts