좋은 사용자 경험을 위해 CLS(Cumulative Layout Shift) 해결하기

허재원·2023년 1월 9일
1

문제 원인 파악하기

아직도 끝나기 않은 룩북 리스트 페이지 만들기... 이제 하나 빼고 파란불이 들어온다.. 근데 하나가 노란불도 아닌 빨간불... Cumulative Layout Shift 뭔데!

원인 찾기

Footer 컴포넌트가 lookbooks의 초기 상태인 빈 배열에서 20개의 룩북 리스트를 받아오면서 아래로 밀려나는 것을 확인할 수 있다. 이는 DOM 요소가 기존 콘텐츠 위의 페이지에 동적으로 추가되기 때문에 발생한다. 누적 레이아웃 이동(CLS)는 실제 사용자에게 이러한 일이 발생하는 빈도를 측정하여 이 문제를 해결하는데 도움을 준다.

CLS(Cumulative Layout Shift)

CLS 란?

방문자에게 콘텐츠가 얼마나 불안정한 지 측정하는 사용자 경험 측정 항목이다.
위에 보고 있는 페이지처럼 갑자기 발생하는 레이아웃의 변경은 시각적으로 거슬리며 사용자의 주의를 산만하게 할 수 있다. 그리고 어떠한 경우에는 잘못된 클릭을 유도하여 실제 피해를 일으킬 수 도 있고 아주 실망스러운 사용자 경험으로 이어질 수 있다.

CLS 점수

일정기간동안 레이아웃 이동이 없는 상태에서 발생하는 예상하지 않은 레이아웃 이동에 대한 누적된 점수이다. 뷰포트에서 이동한 콘텐츠의 양과 영향을 받은 요소가 이동한 거리를 확인한다.

좋은 사용자 환경을 제공하려면 사이트에서 CLS 점수가 0.1미만이어야 한다. 근데 0.3이면..ㅎ

레이아웃 이동이 발생하는 원인

  • 치수가 없는 이미지
  • 크기가 없는 광고, 삽입 및 iframe
  • 동적으로 삽입된 콘텐츠
  • FOIT / FOUT를 유발하는 웹 글꼴
  • DOM을 업데이트하기 전에 네트워크 응답을 기다리는 작업

나의 경우에는 룩북 리스트 데이터를 받아오고 렌더링하므로 동적으로 삽입된 콘텐츠에 해당하는 것 같다.

Large Layout Shift 해결하기

기존 코드

 <S.LookbooksList>
  {lookbooks.map(({ id, banners, name }) => (
    <li key={id}>
      <Link to={`/lookbooks/${id}`}>
        <ImgBox src={publicURL(banners[0].img)} alt='' ratio='150%' />

        <S.LookbookName>{name}</S.LookbookName>
      </Link>
    </li> 
  ))}
</S.LookbooksList>

위 코드를 보면 lookbooks.map(() => ...)로 되어 있는데 lookbooks가 처음에 빈 배열([])이기 때문에 다음과 같은 현상이 발생하는 것이다. 그래서 나는 처음 빈 배열인 경우에 이미지가 들어갈 공간을 확보하면 이를 해결할 수 있지 않을까 하고 생각하여 다음과 같이 코드를 작성하였다.

리팩토링

 <S.LookbooksList>
  {lookbooks.length ? lookbooks.map(({ id, banners, name }) => (
    <li key={id}>
      <Link to={`/lookbooks/${id}`}>
        <ImgBox src={publicURL(banners[0].img)} alt='' ratio='150%' />

        <S.LookbookName>{name}</S.LookbookName>
      </Link>
    </li> 
  )) : (
    Array.from({ length: 20 }).map((_, i) => (
      <li key={i}>
        <div style={{ paddingTop: '150%', backgroundColor: 'lightgray'  }} />             
      </li>
    ))
  )}
</S.LookbooksList>

아래 Array.from({ length: 20 }).map()을 사용하여 div 박스를 위처럼 주게 되면 다음처럼 렌더링된다.(styled-component로 바꿀 예정)

솔직히 더 나은 코드가 있을게 분명하고 아니면 Next는 SSR과 Next/Image 컴포넌트에 Cumulative Layout Shift를 방지하기 위해 placeholder를 제공하여 최적화되니 Next를 사용하는 것이 답일 수 있지만 지금은 React를 사용하고 이미지가 들어갈 공간을 확보하여 Footer 컴포넌트가 아래로 확 밀려나는 것을 방지할 수 있었다. 더 나은 방법 아시는 분 꼭 알려주셨으면 감사합니다..ㅠㅜ

적용 결과

그래도 90점에서 99점으로 9점이나 올라간 것을 확인할 수 있다!!!!! 정답은 아니라도 CLS에 대해 배울 수 있는 좋은 기회였다!
그리고 덤으로 웹 접근성 헤딩 요소를 h3 -> h2로 수정한 것만으로 100점으로 올라갔네ㅎㅎㅎ
물론 chrome의 private 모드에서 로컬의 빌드 환경으로 돌려서 결과를 얻은 것이고, 계속 개발하거나 배포하면 낮아질 수 있지만 암튼 기분 좋다!!

다시 리팩토링

이전 코드

// src/components/common/Layout
import React from 'react';
import { Outlet } from 'react-router-dom';
import { Header, Footer } from 'components';

export const Layout = () => {
  return (
    <>
      <Header />
      <Outlet />
      <Footer />
    </>
  );
};

어떻게 보면 레이아웃을 너무 한 꺼번에 잡으려고 했고, Footer 컴포넌트는 레이아웃으로 먼저 렌더링되었지만 lookbooks가 빈 배열에서 데이터를 받아오면서 밀린 것이기 때문에 Footer 부분을 레이아웃에서 빼면 Large Layout Shift 문제를 해결할 수 있지 않을까 다시 생각했다.

다시 리팩토링

// src/components/common/Layout
import { Outlet } from 'react-router-dom';
import { Header } from 'components';

export const Layout = () => {
  return (
    <>
      <Header />
      <Outlet />
    </>
  );
};
// src/pages/Lookbooks/index.tsx
import { Footer, ImgBox } from 'components';
import { useAppSelector } from 'hooks';
import { Link } from 'react-router-dom';
import { selectAllLookbooks } from 'store/slices/lookbookSlice';
import publicURL from 'utils/publicURL';
import * as S from './style';

export const Lookbooks = () => {
  const lookbooks = useAppSelector(selectAllLookbooks);

  if (!lookbooks.length) return <p>Loading...</p>;

  return (
    <>
      <main>
        <S.LookbookSection>
          <h2 className='ir'>HOW ABOUT OOTD 룩북 리스트</h2>
          <S.LookbooksList>
            {lookbooks.map(({ id, banners, name }) => (
              <li key={id}>
                <Link to={`/lookbooks/${id}`}>
                  <ImgBox src={publicURL(banners[0].img)} alt='' ratio='150%' />
                  <S.LookbookName>{name}</S.LookbookName>
                </Link>
              </li>
            ))}
          </S.LookbooksList>
        </S.LookbookSection>
      </main>
      <Footer />
    </>
  );
};

아래에 Footer 컴포넌트를 추가하고 if(!lookbooks.length) { ... }을 통해서 Spinner 컴포넌트(아직 만들지 않았음)을 보여주는 방식으로 다시 작성했다.

다시 적용한 결과

똑같은 결과를 얻을 수 있었고, 너무 효율(?)을 생각하다보니 다음과 같은 문제가 발생한다는 것을 알 수 있는 좋은 기회였다!!!!

2개의 댓글

comment-user-thumbnail
2023년 8월 30일

안녕하세요. CLS-Cumulative Layout Shift 방지를 검색하다가 이곳에 도착하였습니다.
만약 사용자 경험을 아주 조금이라도 더 증대시킨다면 스켈레톤UI를 쓰는 것이 도움이 될 수도 있다고 생각합니다. 스켈레톤UI를 사용한다면 회색바탕으로 화면을 채울 수 있을뿐만 아니라 사용자에게 현재 이미지 혹은 텍스트가 로딩되고 있다는 정보또한 간접적으로 전달할 수 있을 것입니다.

마지막으로 좋은 글 정말 잘 읽었습니다. 코드 수정의 Before, After가 있었던 덕분에 더 잘 이해할 수 있는 글이였습니다. 감사합니다.

+++
혹시 최상단 이미지에 나온 웹사이트의 점수를 판별하는 것은 어떤것인지 알려주실 수 있나요?
+++

답글 달기
comment-user-thumbnail
2023년 12월 18일

스켈레톤을 구현하기 위해, 현재는 if (!lookbooks.length) return

Loading...

이렇게 작성을 하셨는데
React에서 제공하는 Suspense를 사용해서 조금 더 선언적으로 로딩처리를 해줄 수 있습니다!

답글 달기