안녕하세요 뱅크샐러드 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)라는 개념은 자체의 문자 코드에서 뜻과 소리를 지니지 않은 도형 기호(구두점, 괄호, 공백 등)의 추상화를 포함한다. (Wikipedia)
영문 폰트는 대소문자를 포함해 72개의 Glyph만 있으면 됩니다. 하지만 한글은 자모음의 조합으로 Glyph가 구성되기 때문에 가능한 모든 조합을 계산하면 11,172개의 Glyph가 필요합니다. 따라서 한글이 포함된 폰트는 용량이 커지게 됩니다.
하지만 우리가 웹 페이지를 선보이기 위해 11,172개의 글자가 필요할까요? Pretendard font를 한번 확인해 보겠습니다.
이런 Glyph를 브라우저에 보낼 필요가 있을까요? 미사용 Glyph를 삭제하면 폰트 용량을 많이 줄일 수 있습니다. 한글 부호의 Subset은 정보 교환용 부호계를 참고할 수 있으며, 변환은 fonttools 등의 라이브러리를 사용하실 수 있습니다.
뱅크샐러드에서는 정보 교환용 부호계에 더하여 사용자가 입력 과정에서 발생시킬 수 있는 Glyph에 대한 Subset를 추가해서 Pretendard font에 대한 Subset font를 제작했습니다. Subset으로 인해 원본 대비 70% 이상 용량을 절감할 수 있었습니다.
Subset Font를 통해 폰트의 크기를 줄일 수 있었습니다. 하지만 최대한 빨리 에 관점에서 몇 가지 더 개선할 점이 있습니다. 개발자 도구를 통해 Network waterfall을 한번 살펴볼까요?
보시는 바와 같이 initial load와 font request 사이에 지연이 발생함을 알 수 있습니다. 커스텀 폰트 표시하기 😎 절에서 우리는 font.css 파일을 사용했기 때문인데요, 브라우저는 font.css 파일을 다운로드받기 전에는 어떤 font request를 날려야 하는지 알 수 없고, initial request가 완료되기 전까지 어떤 CSS 파일이 필요한지 알 수 없습니다. 한번 정리해보면..
보시는 바와 같이 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>
);
};
이제 Network waterfall은 위와 같이 개선되었고, 사용자는 큰 불편함 없이 페이지를 사용할 수 있게 되었습니다. 👏👏👏
뱅크샐러드는 위와 같은 과정을 통해 Pretendard 폰트를 여러분께 제공하고 있습니다. 적용하는 과정에서 Safari에서만 preload link
가 동작하지 않는 이슈가 있었습니다. Safari에서 Network waterfall을 확인해보니 preload 한 요청을 재사용하지 않고, 다시 요청함을 확인할 수 있었습니다.
왜 이런 일이 발생했을까요?
브라우저와 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이 발생하지 않습니다.
MDN의 CORS 관련 문서에 적혀진 바와 같이, W3C Recommendation에 의거하여 Font에 대한 cross-domain 요청은 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-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)
이 문제를 해결하기 위해서는 Safari가 preload한 Cache를 non-CORS font request에서 활용할 수 있게 만들어야 합니다. 저는 이 문제를 해결하기 위해 Cloudfront Function (이하 CFF)를 사용했습니다. CFF는 Viewer (클라이언트)와 CDN의 사이에서 요청/응답의 데이터를 조작할 수 있는 미들웨어를 제공합니다. 동작 흐름은 아래와 같습니다.
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**])
}
지금까지 Web Front-End Engineer 민찬기였습니다. 감사합니다.
보다 빠르게 뱅크샐러드에 도달하는 방법 🚀
지원하기