CLS 최적화하기: Next.js 이미지 및 폰트 최적화, dvh 적용

설탕·2024년 2월 9일
1

CLS(Cumulative Layout Shift)란?

CLS는 페이지 수명 동안 발생하는 모든 예상치 못한 레이아웃 변경에 관한 레이아웃 변경 점수를 측정한 것이다.

예상치 못한 레이아웃 변경이 사용자 경험에 좋지 않은 이유는? 아래 이미지를 보자.

cls1 cls2

Order confirmation 위에 있는 박스가 뒤늦게 나타나서 No를 클릭하려던 사용자가 Yes를 클릭하게 된다면...?
이처럼 극단적인 경우가 아니더라도 사용자 상호작용에 의해 응당 발생할 것이라고 기대되는 레이아웃 변경 이외에 예상치 않은 레이아웃 변경은 혼란을 초래할 수 있다.

오빠톤많아 프로젝트의 CLS를 개선해 보자!

🔎 GitHub PR에서 CLS 최적화 코드와 최적화 전후 비교 영상을 확인할 수 있습니다.

문제 상황 및 원인 파악

기존 랜딩 페이지를 3G 네트워크 환경에서 실행한 모습이다. 화면 높이가 쭉 늘어나고 이미지는 따로따로 보이고 폰트는 늦게 적용되고... 총체적 난국이다.

before

랜딩 페이지에서 발생하는 CLS의 원인은 크게 뷰포트 높이, 이미지, 폰트 3가지였다. 첫 렌더링 이후 이들이 적용되는 데 시간이 걸리면서 적용되는 과정이 불필요하게 사용자에게 노출되었다.

각각 개선해보자!

뷰포트 높이: dvh 적용

어떤 이슈가 있었나?

사파리 등 브라우저에서 주소창 때문에 100vh가 뷰포트 실제 높이와 다르게 적용되는 현상이 발생했다. 화면 높이를 100vh로 적용하면 스크롤이 없어야 하는데 주소창 높이가 계산이 안 되어 스크롤이 생겨버리는 것...!

이전 해결법과 문제점

  • 실제 뷰포트 높이를 javascript로 계산하여 css 변수에 전달하는 커스텀 훅을 구현하여 해결하였으나...
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);
}
  • javascript로 높이 계산하는 데 걸리는 시간 때문에 페이지 렌더링 시 layout shift가 발생했다. 처음에는 높이가 계산 안 된 채로 요소들이 보이다가 중간에 높이가 다시 계산되면서 요소들이 다시 재배치되는 모습이 보였다.

새로운 해결법: dvh 적용

  • 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 사이에서 동적으로 변화
  • dvh는 새로 추가된 단위라 사용하기 전 호환성을 확인해 보았다. caniuseMDN에서 확인해보고 사용하기에 충분하다고 판단했다.

이미지 최적화

Before

  • 컬러휠 이미지가 LCP(Largest Contentful Paint) 요소이기 때문에 로드하는 데 오래 걸리니 next image priority 속성을 적용하라는 경고가 콘솔에 떴다.
  • 따라서 컬러휠 이미지에 priority 속성을 적용해서 preload되도록 했으나... 컬러휠과 이모지가 같이 나와야 하는데 컬러휠만 먼저 보이고 이모지는 뒤늦게 보였다.

After

<ThinkImg
  src={curiousEmoji.src}
  alt="curious emoji"
  width={96}
  height={99}
  priority
/>
  • 랜딩 페이지에서 이모지와 컬러휠이 따로따로 보여지는 현상을 개선하기 위해 next <Image> 태그의 priority 속성을 이모지 이미지에도 적용해서 함께 preload되도록 했다.
  • lighthouse 측정 시 계산된 이미지 가로세로 비율에 맞춰 next <Image> 태그에 width, height 값을 지정했다. next.js에서는 width, height 속성이 렌더링될 이미지 크기를 결정하기 때문에 <Image> 태그에 필수로 지정해 주어야 한다.
    • Lighthouse에서도 CLS 측정 항목으로 이미지에 width, height 명시되어 있는지를 확인한다!
      lighthouse
  • 이미지 때문에 전체 페이지 렌더링이 늦어진다면 추후에는 lazy loading으로 바꾸는 방식도 고려해볼 수 있겠다.

폰트 최적화

  • 현재 적용 중인 폰트는 2가지로, Noto Sans KR는 구글 웹 폰트로 제공되고 yg-jalnan은 외부 폰트이다.

Before

  • 기존 폰트 로드 방식

    • 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 폰트 로드가 오래 걸렸다.)

After

  • next.js에서 제공하는 font 기능을 활용하여 최적화했다.
    • 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에서 제공하는 폰트를 가져오거나, 제공하지 않는 폰트는 로컬에 파일로 갖고 있도록 해서 폰트 로딩 시간을 절약했다.

CLS 최적화 전후 Lighthouse 측정 비교

Before

image

After

image
  • Lighthouse 측정에서 CLS 점수가 0이 되었다! 🥳

  • 첫 렌더링 이후 발생하던 layout shift가 눈에 띄게 개선되었다. 측정항목 아래 시간별 스크린샷을 살펴보면 이미지, 폰트가 제각각 로드되고 적용되는 모습이 보이던 이전과 달리 한꺼번에 깔끔하게 렌더링되는 모습을 확인할 수 있다.

  • FCP(First Contentful Paint) 시간도 1.9초에서 0.7초로 무려 1.2초나 개선되었다. (우수한 사용자 환경을 제공하려면 사이트의 첫 번째 콘텐츠 렌더링 시간(FCP)이 1.8초 이하여야 한다고 한다.)

  • 하지만 다른 항목들이 점수가 낮아졌다...🥺 아무래도 이미지를 lazy loading으로 바꿔야 할까? 다른 항목도 만족시킬 수 있는 방법을 더 고민해 봐야겠다.

CLS 최적화 후 실제 렌더링 모습은 오빠톤많아에서 확인할 수 있습니다.

다른 페이지에서 CLS 최적화를 한다면?

이번에 CLS 최적화를 한 페이지는 API 통신도 없고 내용이 많지 않은 간단한 페이지였지만, 서비스의 첫인상을 결정하는 랜딩 페이지였기 때문에 최적화 대상 페이지로 선정했다. 만약 다른 페이지에서 CLS 최적화를 한다면 방법이 또 달라질 것이다.

  • API 호출해서 데이터를 가져오는 페이지에서는? 데이터가 표시될 영역만큼 width와 height를 미리 지정해 놓으면 데이터 로드 전후 레이아웃 변경을 최소화할 수 있을 것이다. 로딩 중 스켈레톤 UI를 보여주는 것은 레이아웃 변경을 사용자가 예상 가능하도록 하는 좋은 방법 중 하나이다.
  • 요소가 많고 스크롤이 있는 페이지에서는? 스크롤하기 전 가장 먼저 보여지는 화면에 필요한 요소만 우선적으로 로드하고, 스크롤해야 볼 수 있는 요소들은 나중에 로드하는 방식을 고려해볼 수 있겠다.

참고
Cumulative Layout Shift (CLS)
Optimize Cumulative Layout Shift | Articles

profile
공부 기록

0개의 댓글