CLS는 페이지 수명 동안 발생하는 모든 예상치 못한 레이아웃 변경에 관한 레이아웃 변경 점수를 측정한 것이다.
예상치 못한 레이아웃 변경이 사용자 경험에 좋지 않은 이유는? 아래 이미지를 보자.
Order confirmation 위에 있는 박스가 뒤늦게 나타나서 No를 클릭하려던 사용자가 Yes를 클릭하게 된다면...?
이처럼 극단적인 경우가 아니더라도 사용자 상호작용에 의해 응당 발생할 것이라고 기대되는 레이아웃 변경 이외에 예상치 않은 레이아웃 변경은 혼란을 초래할 수 있다.
🔎 GitHub PR에서 CLS 최적화 코드와 최적화 전후 비교 영상을 확인할 수 있습니다.
기존 랜딩 페이지를 3G 네트워크 환경에서 실행한 모습이다. 화면 높이가 쭉 늘어나고 이미지는 따로따로 보이고 폰트는 늦게 적용되고... 총체적 난국이다.
랜딩 페이지에서 발생하는 CLS의 원인은 크게 뷰포트 높이, 이미지, 폰트 3가지였다. 첫 렌더링 이후 이들이 적용되는 데 시간이 걸리면서 적용되는 과정이 불필요하게 사용자에게 노출되었다.
각각 개선해보자!
dvh
적용사파리 등 브라우저에서 주소창 때문에 100vh
가 뷰포트 실제 높이와 다르게 적용되는 현상이 발생했다. 화면 높이를 100vh
로 적용하면 스크롤이 없어야 하는데 주소창 높이가 계산이 안 되어 스크롤이 생겨버리는 것...!
import { useEffect } from 'react';
function setViewportHeight() {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
}
const useViewportHeight = () => {
useEffect(() => {
setViewportHeight();
}, []);
};
export default useViewportHeight;
:root {
--vh: 100%;
}
div#__next > div > div {
min-height: calc(var(--vh, 1vh) * 100);
}
dvh
속성으로 javascript 계산 없이 css 내에서 자체적으로 변화하는 뷰포트에 대응할 수 있게 되었다!min-height: 100dvh;
dvh란?
기존
vh
의 한계를 보완하기 위해 css에서는svh
,lvh
,dvh
라는 새로운 단위를 추가했다.
svh
Small Viewport : 주소바 UI가 축소되지 않은 상태의 뷰포트 높이lvh
Large Viewport : 주소바 UI가 축소된 상태의 뷰포트 높이dvh
Dynamic Viewport :svh
/lvh
사이에서 동적으로 변화
priority
속성을 적용하라는 경고가 콘솔에 떴다.priority
속성을 적용해서 preload되도록 했으나... 컬러휠과 이모지가 같이 나와야 하는데 컬러휠만 먼저 보이고 이모지는 뒤늦게 보였다.<ThinkImg
src={curiousEmoji.src}
alt="curious emoji"
width={96}
height={99}
priority
/>
<Image>
태그의 priority
속성을 이모지 이미지에도 적용해서 함께 preload되도록 했다.<Image>
태그에 width
, height
값을 지정했다. next.js에서는 width
, height
속성이 렌더링될 이미지 크기를 결정하기 때문에 <Image>
태그에 필수로 지정해 주어야 한다.width
, height
명시되어 있는지를 확인한다!Noto Sans KR
는 구글 웹 폰트로 제공되고 yg-jalnan
은 외부 폰트이다.기존 폰트 로드 방식
Noto Sans KR
: 구글 웹 폰트로 <head>
태그 내에서 cdn으로 불러와서 사용
// _document.page.tsx
<Head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin=""
/>
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;900&display=swap"
rel="stylesheet"
/>
</Head>
yg-jalnan
: css에서 @font-face
로 불러와서 사용
@font-face {
font-family: 'yg-jalnan';
src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_four@1.2/JalnanOTF00.woff') format('woff');
font-weight: normal;
font-style: normal;
}
페이지 첫 로드 시, 페이지 이동 시마다 폰트가 느리게 로드되어 글자 로드 후 나중에 폰트가 적용되어 글자가 바뀌어 보이는 현상이 발생했다. (특히 jalnan
폰트 로드가 오래 걸렸다.)
Noto Sans KR
: next/font/google
에서 import하여 fontFamily
속성 적용yg-jalnan
: 폰트 파일을 로컬의 public/fonts
폴더에 저장하고 next.js의 localFont
기능으로 불러와 css 변수로 적용import localFont from 'next/font/local';
import { Noto_Sans_KR } from 'next/font/google';
import { createGlobalStyle } from 'styled-components';
import theme from './theme';
const notoSansKr = Noto_Sans_KR({
subsets: ['latin'],
weight: ['400', '500', '700', '900'],
display: 'swap',
});
const jalnan = localFont({
src: [
{
path: '../../public/fonts/JalnanOTF00.woff',
weight: 'normal',
},
],
});
const GlobalStyle = createGlobalStyle`
:root {
--font-jalnan: ${jalnan.style.fontFamily}
}
html {
font-family: ${notoSansKr.style.fontFamily};
}
h1, h2, h3, h4, h5, h6, button {
font-family: var(--font-jalnan);
}
`;
export default GlobalStyle;
외부 폰트를 로드하는 데 시간이 오래 걸렸기 때문에 next.js에서 제공하는 폰트를 가져오거나, 제공하지 않는 폰트는 로컬에 파일로 갖고 있도록 해서 폰트 로딩 시간을 절약했다.
Lighthouse 측정에서 CLS 점수가 0이 되었다! 🥳
첫 렌더링 이후 발생하던 layout shift가 눈에 띄게 개선되었다. 측정항목 아래 시간별 스크린샷을 살펴보면 이미지, 폰트가 제각각 로드되고 적용되는 모습이 보이던 이전과 달리 한꺼번에 깔끔하게 렌더링되는 모습을 확인할 수 있다.
FCP(First Contentful Paint) 시간도 1.9초에서 0.7초로 무려 1.2초나 개선되었다. (우수한 사용자 환경을 제공하려면 사이트의 첫 번째 콘텐츠 렌더링 시간(FCP)이 1.8초 이하여야 한다고 한다.)
하지만 다른 항목들이 점수가 낮아졌다...🥺 아무래도 이미지를 lazy loading으로 바꿔야 할까? 다른 항목도 만족시킬 수 있는 방법을 더 고민해 봐야겠다.
CLS 최적화 후 실제 렌더링 모습은 오빠톤많아에서 확인할 수 있습니다.
이번에 CLS 최적화를 한 페이지는 API 통신도 없고 내용이 많지 않은 간단한 페이지였지만, 서비스의 첫인상을 결정하는 랜딩 페이지였기 때문에 최적화 대상 페이지로 선정했다. 만약 다른 페이지에서 CLS 최적화를 한다면 방법이 또 달라질 것이다.
참고
Cumulative Layout Shift (CLS)
Optimize Cumulative Layout Shift | Articles