현재 사내 프로젝트에서는 material-icon
을 사용하고 있다. SVG 방식으로 사용할 수도 있지만, CDN방식(웹 폰트 기반)으로 다음과 같이 icon을 활용하고 있다.
<template>
<div>
<i class="material-icons">home</i>
<i class="material-icons">favorite</i>
</div>
</template>
웹 폰트 기반으로 사용하게 되면 별도의 import문을 활용할 필요가 없고, 간편하게 작성하기에는 좋다는 장점이 있다. 하지만 다음과 같은 단점들도 존재한다.
순간적으로 폰트가 보였다가, 폰트가 로드되자마자 icon으로 바뀌면서 레이아웃이 깨진다.
다음과 같이 초기 화면을 로드시에 웹 폰트 기반 icon을 사용한 곳에서 text가 보였다가 icon이 되는 것을 확인 할 수 있었다. 폰트를 다운로드 하기 전에 이미 렌더링 작업을 하고 있기 때문에 이런 문제가 발생하고 있다.
그래서 네트워크 환경이 더 안좋은 환경에서는 text노출이 더 심해진다. (기본적인 사내 네트환경에서는 문제 없었지만, 간혹 문제가 있었고 네트워크를 빠른 4G로 설정했을 때 확실히 문제가 있었다.)
실제로 성능 탭에서 측정한 결과를 살펴보면 1200ms시점에 화면이 렌더링되고 있지만 font파일의 요청과 다운로드는 그 이후인 1600ms
시점인 것을 확인할 수 있었다.
Web.dev
에는 font파일과 관련해 이렇게 설명하고 있었다.
즉, 폰트 파일이 즉시 적용할 수 없는 상황에는 기본 시스템 폰트가 보이다 폰트 파일을 사용할 수 있는 시점에 브라우저가 텍스트를 다시 페인트 되는 원리인 것이다.
font-face
에서는 이를 해결할 수 있는 방안이 있는데, 바로 font-display
를 활용하는 것이다. 이는 폰트가 로드되는 동안 font를 보여주는 방식을 제공하고 있다. font-display속성은 또 다음과 같은 옵션을 제공하고 있다.
옵션 | 동작 방식 요약 | 장점 | 단점 |
---|---|---|---|
auto | 브라우저 기본 동작 (브라우저마다 다름) | 브라우저가 최적 판단 | 예측 불가능 |
block | 최대 3초까지 숨김(FOIT) → 이후 fallback 폰트 → 폰트 로드되면 교체(FOUT) | 폰트가 바뀌는 느낌 적음 | 텍스트가 늦게 보일 수 있음 (FOIT) |
swap | 즉시 fallback 폰트로 표시(FOUT) → 로드되면 웹폰트로 교체 | 텍스트 즉시 표시, UX 좋음 | 폰트가 갑자기 바뀌는 느낌 (FOUT) |
fallback | 짧은 시간(약 100ms)만 숨김 → 그 후 fallback → 웹폰트 로드되면 교체 또는 무시 | 대부분 UX 문제 최소화 | 사용 타이밍에 따라 폰트 적용 안될 수 있음 |
optional | fallback 보다 더 엄격 — 사용자가 느린 연결이면 웹폰트 아예 안 쓸 수도 있음 | UX 최적화, 성능 우선 | 의도한 폰트가 안 보일 가능성 있음 |
FOUT/FOIT
FOUT (Flash of Unstyled Text)
는 폰트가 로드 되기전에 기본 폰트 스타일이였다가, 이후 웹 폰트로 스타일 되는 것을 의미한다. FOIT (Flash of Invisible Text)
는 폰트가 로드 되기전에 폰트가 아예 보이지 않다가 이후 웹 폰트 스타일이 되는 것을 의미한다.
처음 문제를 접했을 때 단순히, font-face를 이용해 Preload해주면 되지 않을까?
라는 생각을 했었다. 그런데 이미 root파일 진입점에 font-face
속성이 있었다.
@font-face {
font-family: "Material Symbols Outlined";
font-style: normal;
font-weight: 100 700;
font-display: block;
src: url("./material-symbols-outlined.woff2") format("woff2");
}
그럼에도 불구하고 렌더되는 시점에 파일이 로드 요청이 가서 CLS문제가 해결이 되지 않았는데, 그 이유를 Web.dev 공식 문서에서 발견할 수 있었다.
그리고 위의 내용과 함께 올려준 예제가 있었는데,
@font-face {
font-family: "Open Sans";
src: url("/fonts/OpenSans-Regular-webfont.woff2") format("woff2");
}
h1 {
font-family: "Open Sans"
}
예제에서는 실제 사용되는 시점인 html에 h1
가 포함된 경우인 것이다. 즉, 글꼴이 사용되는 시점에 font-face파일이 로드 되는 것이다. 그렇기 때문에 지금 상황에 활용할 여지는 없었다.
또한 font-display는 여기서 큰 도움을 주지는 못한다. 우리가 주목하고 있는 것은 결국 텍스트를 렌더링하는 게 아니라 icon이기 때문에 그 어느 옵션도 cls를 방지하지는 못했다. 억지로 font가 로드 될때까지 스켈레톤 UI와 같은 fallback UI를 제공하는 방안도 있지만 좀 까다로운 작업이였다.
그래서 html문서에 link태그를 추가 리소스에 preload 힌트를 추가하는 방법을 선택했다. 그래서 생각해 볼 수 있는 두 가지 preload방식이 있었다.
<head>
<style>
@font-face {
font-family: "Open Sans";
src: url("/fonts/OpenSans-Regular-webfont.woff2") format("woff2");
}
body {
font-family: "Open Sans";
}
...etc.
</style>
</head>
이렇게 하면 브라우저가 외부 스타일시트가 다운로드될 때까지 기다릴 필요가 없으므로 브라우저에서 글꼴 선언을 더 빨리 찾을 수 있다고 한다. 그렇다고 해도 이 방식이 완벽하게 빠르게 글꼴을 로드하지는 못했다. 왜냐하면 css파일을 파싱하는 시점에 로드되기 때문이다. 실제로 로드 속도는 개선이 되었지만 아직 CLS는 발생하고 있었다. 또한 다른 리소스 로드를 차단할 수도 있기 때문에 단점이 존재한다.
<link rel='preload'>
방식으로 preload를 할 수 있다. 이 방식을 이용하면 우선순위로 리소스를 로드할수 있을 뿐만아니라, html파싱과 함께 병렬적으로 동작을 하게 된다.(다만 브라우저에서 병렬적으로 로드할 수 있는 파일이 제한적이기 때문에 남용은 자제 해야한다.)
<link
rel="preload"
href="/fonts/testfont.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
>
속성명 | 설명 |
---|---|
rel="preload" | 이 리소스를 브라우저가 중요하게 여기고 미리 불러오게 함. 렌더링에 필요한 리소스를 사전에 가져올 수 있어 성능 향상. |
href="/fonts/testfont.woff2" | 미리 불러올 리소스의 경로. 여기선 웹 폰트 파일 경로. |
as="font" | 리소스의 유형(type) 지정. 브라우저가 이 리소스를 어떤 용도로 쓸지 명확히 알 수 있게 해줌. 캐시 및 우선순위에 영향. |
type="font/woff2" | 파일의 MIME 타입. 여기선 WOFF2 포맷의 폰트임을 명시. 일부 브라우저 최적화에 도움. |
crossorigin="anonymous" | 크로스 오리진 요청임을 명시. 이 폰트를 CORS 정책에 따라 불러오며, 자격 증명 없이(쿠키 없이) 가져옴. 💡웹 폰트를 제대로 캐시하려면 crossorigin 을 지정해야 함 (CORS 헤더도 필요). |
결론적으로 font파일을 html문서 수준에서 link태그를 추가하였다.
그런데 이 로드한 폰트 파일이 번들링되면서 문제가 발생하였다...
link태그에 해당 파일을 로드하였지만, 로드된 파일이 번들링 되면서 해쉬가 붙어서 파일 인식을 제대로 못해주는 문제가 생겼다. 그래서 처음에는 브라우저 탭에서 해쉬된 키값까지 경로로 link태그에 넘겨주었다.
사진처럼 웹 페이지 요청에 따른 접근이 되자마자 우선적으로 font파일이 로드되는 것을 확인 할 수 있었다. 여기까지 preload동작이 잘되어 CLS
는 어느정도 해결은 되었다.
하지만 정적으로 해쉬된 파일을 경로로 넣어주는 것은 문제가 된다. CDN에서 받아온 이 파일이 변경이 되면 번들러는 이를 다른 파일로 인식하여 해쉬값이 변경된다. 그래서 link파일도 올바른 해쉬를 참조하도록 동적으로 조정해주어야 한다.
동적으로 넣어주기 위해서 해당 파일이 번들링될 때 인식하여, 이를 link태그로 preload하도록 주입을 시도하였다. vue-cli
공식 문서에는 preload를 위해 @vue/preload-webpack-plugin
이라는 플러그인을 권장하고 있었다.
config.plugins.push(
new PreloadWebpackPlugin({
rel: "preload",
include: "allAssets",
as(entry) {
if (/\.woff2$/.test(entry)) return "font";
},
fileWhitelist: [/material-symbols-outlined\.[a-z0-9]+\.woff2$/],
})
);
다음과 같이 webpack설정을 추가했다. 결과적으로 html head태그에 해당 link태그가 추가된 것을 확인 할 수 있었다.
사실 모든 요청 리소스는 변경 사항이 없다면 브라우저에서 자체적으로 캐싱되어, 새로고침을 한다해도 CLS가 발생할 가능성이 적다. 게다가 네트워크가 느린환경만 아니라면 누군가에게는 큰 이슈로 다가오지 않을 수도 있다.
기본적으로 모든 http요청에 대해서 캐싱할 수 있다. 여기서는 E-Tag를 통해 이 값의 변경여부에 따라 알맞은 응답을 내려준다. 대략적인 흐름은 다음과 같다.
If-None-Match
를 헤더에 포함시키고 서버로 받은 E-Tag를 value로 한다.If-None-Match
의 값을 비교하고 값이 다르다면 새 E-Tag를 보내고 200상태코드를 보낸다. 만약 값이 동일하다면 304 Not modified
를 전송한다.E-Tag방식으로 캐싱해도 네트워크 요청은 발생하는거 아닌가??
맞다. 하지만 트래픽은 절약된다. E-Tag로 검증하는 과정을 위해 네트워크 요청은 발생하지만, 리소스나 응답 본문을 내려주지는 않기 때문에 훨씬 가볍다.
하지만 모든 사용자에게 항상 일관성있는 사용자 경험을 주기 위해 이슈를 발견해서 해결했다. 사이트 초기 진입시 눈에 띄는 layout shifting이 있다면 사용자가 인식하는 첫 사이트 경험을 안좋게 심어 줄 수 있다고 생각했다.
그리고 중요한 리소스를 미리 로드하는 파일이 많지가 않았고 preload를 사용하는 건 큰 trade-off가 없었다고 생각했다.
다만, 추후에 현재의 웹폰트 기반 icon보다는 SVG기반 아이콘으로 마이그레이션하여 초기 웹 폰트 파일 리소스 자체를 로드하지 않고 정말 필요한 icon만 사용하여 트리 셰이킹 하며, 보다 직관적인 코드 스타일을 지향할 필요성이 있다. (아직은 너무 바쁘다..)
Reference
링크드인으로 이야기를 주고 받고 싶으시다면 언제든지 편하게 연락주세요. 🙇♂️