티스토리 뷰
1. 폰트 최적화가 왜 필요할까?
Nextjs는 왜 폰트 최적화를 만들었을까요?
폰트를 그냥 <link> 태그로 폰트 다운로드해서 css에 적용하면 되는 것으로 알고 있었지만
폰트를 최적화한다는 것은 ux를 개선하고 seo점수를 개선하는 효과가 있습니다.
폰트와 ux, seo가 어떤 관련이 있는 것일까요?
혹시 이런경험 없으셨나요?
커스텀 폰트가 적용되면서 Text가 차지하는 영역이 바뀌면서 다른 엘리먼트의 위치까지 변경되는 경험이요.
혹은 데이터 패치가 완료되고 dom에 다른 요소가 추가되면서 다른 엘리먼트의 위치가 변경되는 경우도 있습니다.
이렇게 다른 요소의 변경에 의한 의도치 않은 dom의 위치가 변경되는 것을 Cumulative Layout Shift(이하 cls)라고 합니다.
(https://web.dev/articles/cls?hl=ko)
이런 현상은 보기에도 불편하지만 사용자에게 직접적인 불편함을 주는 경우도 있습니다.
(위 영상의 경우 저는 항상 다른 아이디를 클릭하려다가 갑자기 cls가 발생하면서 계정 더 보기 숨기기가 클릭돼서 불편했습니다.)
위의 경우는 UX에 직접적인 악영향을 주는 경우입니다.
또한 cls는 구글이 퍼포먼스를 측정하는 항목이기 때문에 seo에 안 좋은 영향이 있습니다.
정리하자면 커스텀 폰트를 적용하면 cls가 발생하고 이것은 ux, seo에 악영향을 줍니다.
2. Cumulative Layout Shift란?
next/font를 사용하는 방법은 공식문서도 있고 블로그도 많으니 cls에 대해 좀 더 알아보겠습니다.
cls 점수가 0.1 이하로 유지되면 좋은 평가를 받을 수 있습니다.
cls 점수는 어떻게 결정될까요?
어떤 엘리먼트의 프레임이 변경되면 해당 엘리먼트의 시작위치(상단 또는 왼쪽)가 변경될 때마다 해당 요소가 불안정한 요소로 간주되어 cls점수가 측정됩니다. 단, 새 요소가 dom에 추가되거나 기존 엘리먼트의 크기가 변하는 것은 해당 요소의 레이아웃이 변경됐다고 간주되지는 않는다고 합니다. 엘리먼트의 시작위치(상단이나 왼쪽)가 변경된 것은 아니니까요.
새 요소가 dom에 추가되어 오른편에 있던 button이 오른쪽으로 밀리는 상황을 예로 들면 새로 추가된 요소는 불안정한 요소는 아니지만 밀려버린 button은 불안정한 요소로 간주되어 cls가 측정됩니다.
cls점수는 영향비율 * 거리비율로 결정됩니다.
layout shift score = impact fraction * distance fraction
영향비율은 현재 프레임 대비 불안정한 요소가 움직인 모든 영역 비율입니다.
붉은 영역이 불안정한 요소가 움직인 모든 영역이고 이 영역은 전체 프레임의 75%를 차지하므로 영향비율은 0.75입니다.
거리비율은 전체 프레임 대비 불안정한 요소가 이동한 최대거리 비율입니다.
파란색이 불안정한 요소가 움직인 거리입니다. 이 거리는 전체프레임의 25%를 차지하므로 거리비율은 0.25입니다.
따라서 위 예제의 cls점수는 0.75*0.25 = 0.1875로 썩 좋지 않습니다.
위 예에서 click me 버튼이 뒤늦게 dom에 생기는 경우를 보겠습니다.
회색 text영역의 시작위치(상단)는 변하지 않았으므로 불안정한 요소로 고려되지 않습니다.
주황색 click me 버튼은 이전에 없던 요소이므로 마찬가지로 불안정한 요소로 고려되지 않습니다.
하지만 주황색 버튼이 생기면서 초록색 text영역의 시작위치(상단)가 변경되었으므로 불안정한 요소가 되어 cls점수가 측정됩니다.
위 경우 영향비율 0.5 거리비율 0.14로 cls는 0.07입니다. 나쁘지 않습니다.
중요한 것은 cls점수를 계산하는 방법이 아니라 이렇게 예상치 못한 변경으로 인해 불안정한 요소가 될 수 있다는 것을 알면 됩니다.
그렇다면 클릭이벤트에 의한 의도적인 레이아웃 변경, 예를 들면 뭐.. 아코디언 같은 게 있을 수 있겠네요.
그런 건 전부 나쁜 걸까요? 아닙니다. 말 그대로 의도적인 사용자와의 상호작용(이벤트)에 의한 변경은 문제가 되지 않습니다.
그럼 이런 건 어떨까요?
제가 개발 중인 컴포넌트의 일부입니다.
안 좋은 네트워크 환경의 유저를 생각해서 네트워크속도를 느린 3g로 설정했습니다.
분명히 유저의 의도적인 클릭이벤트에 의한 레이아웃 변경이므로 cls 대상이 아닐 것으로 예상했지만
유저 이벤트 발생으로부터 500ms 이후 발생하는 레이아웃변경은 불안정한 요소로 판단됩니다.
즉, 유저 이벤트로부터 500ms이내 발생하는 레이아웃 변경은 특정 플래그가 설정되어 cls대상이 아니지만
그 이후라면 cls대상이 되어 안 좋은 cls점수를 받을 수 있습니다.
cls점수를 떠나서 클릭 이후 500ms가 지나도록 아무 반응이 없다가 갑자기 element가 튀어나오면 유저가 불편하게 느낄 수 있겠네요.
ux에 좋지 않습니다.
이런 경우에는 즉시 fetch 된 데이터가 들어갈 box를 만들어 레이아웃을 확보해 놓고 pending기간 동안 로딩표시를 해주고
데이터가 들어왔을 때 공간을 채워주는 방식이 좋다고 하네요. 저도 수정해야겠습니다.
3. next/font의 역할은?
그렇다면 next/font의 역할은 무엇일까요?
바로 문제가 되는 Cumulative Layout Shift를 제거하는 것입니다. 어떻게 cls를 제거할까요
next/font를 사용하게 되면 빌드타임에 미리 폰트를 다운로드합니다. 미리 개발자가 사용한 폰트를 다운로드하여서 다운로드한 폰트파일을 static폴더에 넣어서 셀프 호스팅을 할 수 있습니다. next 웹서버에서 정적파일을 바로 로드할 수 있기 때문에 다른 도메인에 네트워크 요청을 하는 과정이 줄어들게 됩니다.
static 한 파일을 가지고 있다면 해당 폰트를 css 변수와 함께 사용할 수 있게 됩니다.
Dashboard라고 쓰여있는 h1 태그를 보면 클래스명에 처음 보는 것이 들어가 있습니다.
바로 __className_712214입니다. 이것의 스타일을 찾아보면 next/font로 설정했던 Lusitana폰트가 css font family로 들어가 있습니다. __className_712214는 알겠는데, __className_Fallback_712214 는 뭘까요?
* Fallback font
font파일을 self-hosting 한다고 해도 next 웹서버에서 font파일을 클라이언트 브라우저로 내려줘야 하는 것은 여전합니다.
대신 next/font를 사용하면 layout shift를 방지할 수 있도록 최적화된 fallback font를 생성합니다.
쉽게 말하자면 '나중에 가져올 폰트와 동일한 사이즈를 가진' fallback font가 준비되어 있기 때문에 앱이 다시 로드되어도(fallback font가 적용되어도) 레이아웃에는 변경이 없다. 고 이해했습니다.
그렇다면 나중에 가져올 폰트와 동일한 사이즈(레이아웃)를 가진 fallback font는 어떻게 만들까요?
next/font의 소스코드를 보면
fallbackFontFace.nodes = [
new _postcss.default.Declaration({
prop: "font-family",
value: adjustFontFallbackFamily
}),
new _postcss.default.Declaration({
prop: "src",
value: `local("${fallbackFont}")`
}),
...ascentOverride ? [
new _postcss.default.Declaration({
prop: "ascent-override",
value: ascentOverride
})
] : [],
...descentOverride ? [
new _postcss.default.Declaration({
prop: "descent-override",
value: descentOverride
})
] : [],
...lineGapOverride ? [
new _postcss.default.Declaration({
prop: "line-gap-override",
value: lineGapOverride
})
] : [],
...sizeAdjust ? [
new _postcss.default.Declaration({
prop: "size-adjust",
value: sizeAdjust
})
] : []
];
이런 부분이 있습니다.
postcssNextFontPlugin이라는 함수가 인자로 받는 adjustFontFallback이라는 값이 ascentOverride, descentOverride, lineGapOverride 와 같은 값들을 가지고 있는데 이것들을 css 속성인 "ascent-override", "descent-override", "line-gap-override", "size-adjust"의 값으로 넣어줍니다. 이 속성들이 재배치된 fallback font가 레이아웃 시프트를 방지하게 하는 거군요.
그렇다면 postcssNextFontPlugin은 어디에서 호출되고 adjustFontFallback은 무엇일까요?
이번에는 build/webpack/loaders/next-font-loader 폴더의 index.js를 보겠습니다.
emitFontFile이라는 함수가 있는데 여기서 adjustFontFallback을 만들어서 postcssNextFontPlugin를 호출할 때 인자로 넘겨줍니다.
adjustFontFallback을 어떻게 만드는지 찾아보겠습니다.
// Import the font loader function from either next/font/local or next/font/google
// The font loader function emits font files and returns @font-faces and fallback font metrics
const fontLoader = require(fontLoaderPath).default;
let { css, fallbackFonts, adjustFontFallback, weight, style, variable } = await nextFontLoaderSpan.traceChild("font-loader").traceAsyncFn(()=>fontLoader({
functionName,
variableName,
data,
emitFontFile,
resolve: (src)=>(0, _util.promisify)(this.resolve)(_path.default.dirname(_path.default.join(this.rootContext, relativeFilePathFromRoot)), src.startsWith(".") ? src : `./${src}`),
isDev,
isServer,
loaderContext: this
}));
adjustFontFallback을 어떤 비동기 함수의 결과물로 받아오는데요.... 주석을 참고해 보면
next/font/google파일에서 폰트로더 함수를 가져오고
이 폰트로더 함수는 폰트파일을 내보내고(비동기적으로 다운로드하는 함수겠네요) fallback metrics를 반환한다고 합니다.
폰트로더 함수를 찾아보겠습니다.
nextFontGoogleFontLoader라는 함수가 있네요.
https://github.com/vercel/next.js/blob/canary/packages/font/src/google/loader.ts
이 함수 안에서 getFallbackFontOverrideMetrics이라는 메서드를 호출합니다.. 따라가 볼게요
export function getFallbackFontOverrideMetrics(fontFamily: string) {
try {
const { ascent, descent, lineGap, fallbackFont, sizeAdjust } =
calculateSizeAdjustValues(fontFamily)
return {
fallbackFont,
ascentOverride: `${ascent}%`,
descentOverride: `${descent}%`,
lineGapOverride: `${lineGap}%`,
sizeAdjust: `${sizeAdjust}%`,
}
} catch {
Log.error(`Failed to find font override values for font \`${fontFamily}\``)
}
}
인자로 받은 fontFamily는 구글에서 다운로드한 fontFamily처럼 보입니다. 그리고 calculateSizeAdjustValues 메서드에서 반환한 fallback font용 ascent, descent, lineGap, fallbackFont, sizeAdjust를 리턴해줍니다.
calculateSizeAdjustValues까지 뭔지 따라가 보겠습니다.
export function calculateSizeAdjustValues(fontName: string) {
const fontKey = formatName(fontName)
const fontMetrics = capsizeFontsMetrics[fontKey]
let { category, ascent, descent, lineGap, unitsPerEm, xWidthAvg } =
fontMetrics
const mainFontAvgWidth = xWidthAvg / unitsPerEm
const fallbackFont =
category === 'serif' ? DEFAULT_SERIF_FONT : DEFAULT_SANS_SERIF_FONT
const fallbackFontName = formatName(fallbackFont.name)
const fallbackFontMetrics = capsizeFontsMetrics[fallbackFontName]
const fallbackFontAvgWidth =
fallbackFontMetrics.xWidthAvg / fallbackFontMetrics.unitsPerEm
let sizeAdjust = xWidthAvg ? mainFontAvgWidth / fallbackFontAvgWidth : 1
ascent = formatOverrideValue(ascent / (unitsPerEm * sizeAdjust))
descent = formatOverrideValue(descent / (unitsPerEm * sizeAdjust))
lineGap = formatOverrideValue(lineGap / (unitsPerEm * sizeAdjust))
return {
ascent,
descent,
lineGap,
fallbackFont: fallbackFont.name,
sizeAdjust: formatOverrideValue(sizeAdjust),
}
}
fallback font의 설정으로 넘겨줄 변수들의 실제 데이터를 결국 capsizeFontsMetrics에서 가져오는군요!
거기까지만 가볼게요
const capsizeFontsMetrics = require('next/dist/server/capsize-font-metrics.json')
거의 다 온 것 같습니다. json파일에 재미난 게 있을 것 같네요.
찾았습니다. 16400줄짜리 json파일을 찾았고, 여기에 각 font family별로 fallback font를 만들기 위한 metric들이 원시값으로 모여있습니다. 고마운 마음도 들고 저도 이런 일에 기여해보고 싶다는 마음도 드네요.
모든 소스코드를 찾아본 건 아니라서 정확히 파악한 것은 아니겠지만..
정리해 보자면 빌드시점에 실제로 구글에서 폰트를 다운로드하고, 개발자가 사용하고자 하는 폰트의 fallback font를 만들기 위해 json에 저장해 놓은 원시값들을 찾아서 __className_Fallback_712214 과 같은 클래스명에 해당 css 속성을 주입해서 내려준다.
덕분에 우리는 레이아웃 변경을 예방할 수 있는 fallback font를 사용할 수 있고
웹 개발자가 쉽게 완성도 있는 레이아웃을 그릴 수 있게 도와준다.
감사합니다.
'NextJS' 카테고리의 다른 글
React 18.2 useOptimistic(Experimental) 낙관적 업데이트(정말 react-query, SWR은 필요없어지나!?) (0) | 2024.02.06 |
---|---|
Server Action으로 form 이미지 미리보기 & Mutate 구현 (0) | 2024.01.25 |
Next14 서버컴포넌트의 이미지최적화 (2) | 2023.12.23 |
[NextJS]Minified React error #425,418,423 해결 (0) | 2023.02.28 |
[Next]이미지 최적화.. 'sharp' package is strongly recommended (0) | 2023.01.27 |