[최적화] 성능 개선 - CLS

이지·2024년 1월 15일
1

웹 최적화

목록 보기
1/5
post-thumbnail

Lighthouse에 대한 간단한 설명과 CLS 항목을 개선한 글입니다.

성능 측정 방법

개발자 도구에서 Lighthouse를 통해 원하는 웹페이지의 성능을 측정할 수 있습니다.

성능은 위의 사진처럼 FCP, LCP, TBT, CLS 로 나뉘어 측정되게 됩니다.

  • FCP : First Contentful Paint
    • 웹 페이지가 처음 콘텐츠를 렌더링하기 시작한 시점을 측정하는 지표
    • 사용자가 페이지 내용을 볼 수 있는 시간
  • LCP : Largest Contentful Paint
    • 웹 페이지에서 가장 큰 콘텐츠 요소가 화면에 표시되는 시점을 측정하는 지표
  • TBT : Total Blocking Time
    • 웹 페이지가 사용자의 입력에 응답하지 않고 차단된 시간의 총합을 측정하는 지표
    • 페이지가 JS 작업으로 인해 응답이 지연되어 사용자 상호작용에 문제가 발생할 수 있는 시간
  • CLS : Cumulative Layout Shift
    • 웹 페이지의 레이아웃 안정성을 측정하는 지표
    • 사용자가 웹 페이지를 이용하는 동안 발생하는 레이아웃의 예기치 않은 이동을 측정
    • 웹 페이지가 로드되는 동안 요소들의 크기, 위치, 스타일 등이 변경되어 레이아웃이 이동되고 이로 인해 사용자 경험을 저하시킬 수 있습니다.
    • CLS는 0~1의 값으로 계산을 하게 되는데 0에 가까울수록 사용자가 웹 페이지를 이용하는 동안 레이아웃 이동이 거의 없음을 의미합니다.

📱 CLS 개선하기

원인

CLS는 예상치 않은 레이아웃의 이동에 대한 점수로 일반적으로 아래와 같은 이유로 발생합니다.

  • width, height가 없는 이미지, 비디오가 있는 경우
  • 동적으로 추가된 콘텐츠
  • FOIT/FOUT을 유발하는 웹 글꼴

🔺 대규모 레이아웃 변경 피하기


현재 제 프로젝트의 경우 CLS에 기여한 대부분이 footer입니다.
footer의 경우 반응형을 위해 높이가 지정되지 않은 상태였습니다.
min-height을 적용해봤지만 이 문제는 해결이 되지 않았습니다.

제 생각에는 상품리스트가 렌더링되기 전 footer가 나타나고 상품 리스트가 렌더링되면서 최하단으로 내려가게되고 대규모 레이아웃 변경이 발생했다고 판단하는 것 같습니다. 이 부분을 해결해주기 위해서 상품리스트가 렌더링되기 전 스켈레톤 UI를 추가하기로 결정했습니다.

Skeleton UI

SkeletonProduct.tsx

import { keyframes, styled } from "styled-components";
import { media } from "../style/media";
interface Props {
  count: number;
}
export default function SkeletonProduct({ count }: Props) {
  const array = Array.from({ length: count });
  return (
    <>
      {array.map((_, index) => (
        <SkeletonWrap key={index}>
          <div></div>
          <div></div>
          <div></div>
          <div></div>
        </SkeletonWrap>
      ))}
    </>
  );
}
const loading = keyframes`
  0% {
      background-color: rgba(165, 165, 165, 0.1);
    }
    50% {
      background-color: rgba(165, 165, 165, 0.3);
    }
    100% {
      background-color: rgba(165, 165, 165, 0.1);
    }
`;

const SkeletonWrap = styled.li`
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 6px;
  div {
    background-color: #f2f2f2;
    position: relative;
    overflow: hidden;
  }
  div::before {
    content: "";
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    animation: ${loading} 1.5s infinite ease-in-out;
  }
  div:first-child {
    border-radius: 10px;
    height: 350px;
    ${media.Medium`
    height: 250px;
  `}
    ${media.Small`
    height: 90px;
  `}
  }
  div:not(:first-child) {
    height: 22px;
  }
  div:nth-child(2) {
    width: 50%;
  }
  div:last-child {
    width: 30%;
  }
`;
  • 스켈레톤을 원하는 개수만큼 props로 받아 나타날 수 있도록 했습니다.

ProductList.tsx
스켈레톤 UI 적용한 컴포넌트

...
export default function ProductList() {
  const [productListData, setProductListData] = useState<Product[]>([]);
  const [page, setPage] = useState(0);
  const [isLoading, setIsLoading] = useState(true);
  const [pageEnd, setPageEnd] = useState(false);

 const getProductList = async (page: number) => {
    setIsLoading(true);
    try {
      const res = await loadAllProduct(page);
      setProductListData((prev) => [...prev, ...res.data.results]);
      setIsLoading(false);
      if (res.data.next === null) {
        setPageEnd(true);
      }
    } catch (err) {
      if (axios.isAxiosError(err)) {
        if (err.response?.data?.detail === "페이지가 유효하지 않습니다.") {
          console.error(err);
        }
      }
    }
  };
  const targetRef = useIntersectionObserver({
    onIntersect: () => {
      setPage((prev) => prev + 1);
    },
    options: { threshold: 1 },
    pageEnd,
  });

  useEffect(() => {
    getProductList(page);
  }, [page]);

  return (
    <>
      <S.ProductUl>
        {isLoading && <SkeletonProduct count={3} />}
        {productListData.map((product) => (
          <li key={product.product_id}>
            <S.ProductLink to={`/detail/${product.product_id}`}>
              <S.ProductImg src={product.image} alt="상품이미지" />
              {product.stock === 0 && <S.SoldOut />}
              <S.ProductCorporation>{product.store_name}</S.ProductCorporation>
              <S.ProductName className="ellipsis">
                {product.product_name}
              </S.ProductName>
              <S.ProductPrice>
                {product.price.toLocaleString("ko-KR")}
                <S.ProductWon></S.ProductWon>
              </S.ProductPrice>
            </S.ProductLink>
          </li>
        ))}
        {isLoading && !pageEnd && <SkeletonProduct count={3} />}
      </S.ProductUl>
      <div ref={targetRef} />
    </>
  );
}
  • isLoading === true일 때 스켈레톤이 나타나도록 했습니다.
  • 스켈레톤을 위 아래에서 불러오는 이유는 상품이 이미 보이고 있는 상태 이후에도 스켈레톤을 적용해 주기 위함입니다.
  • 두번째 스켈레톤에서 !pageEnd를 설정한 이유는 무한스크롤을 사용해서 맨 마지막 상품까지 나타났을 경우에는 스켈레톤이 더이상 나타나지 않아도 되기 때문입니다.

🟧 이미지 요소에 width 및 height가 명시되어 있지 않습니다.

[해결방법]

  • aspect-ratio 적용하기 (근데 이 방법은 적용해도 경고 메시지가 사라지지 않음.. ㅠ)
  • 이미지 태그에 width, height 적용하여 이미지 비율 설정하기

이미지가 만약 width, height가 고정되어 있다면 실제 단위를 포함해서 width, height를 지정해주거나 아래처럼 단위 없이 비율을 적용해주면 해결됩니다.

// before
<S.LogoImg src={LogoIcon} alt="호두 로고" />
// after
<S.LogoImg src={LogoIcon} alt="호두 로고" width={124} height={38} />
// 이미지가 로드되기 전에 width, height 속성을 기반으로 가로세로 비율을 계산합니다.

🎇 폰트 깜빡임 문제

웹 페이지를 새로고침하거나 다른 페이지로 이동하게 되면 폰트가 깜빡이는 문제가 있었습니다.(FOUT 현상)

styled-components를 사용하면서 createGlobalStyle로 폰트를 설정했는데 이때, style 태그가 재생성될 때 폰트를 다시 불러오게 되면서 깜빡이는 현상이 발생하게 됩니다.


기존에 GlobalStyle.tsx의 font-face를 font.css 파일을 생성하여 옮겨주었습니다. 그러고나서 App에 import 해주었습니다.

// font.css
@font-face {
  font-family: "Spoqa Han Sans Neo";
  src: url(./fonts/SpoqaHanSansNeo-Regular.ttf);
  font-weight: 400;
}

@font-face {
  font-family: "Spoqa Han Sans Neo";
  src: url(./fonts/SpoqaHanSansNeo-Medium.ttf);
  font-weight: 500;
}

@font-face {
  font-family: "Spoqa Han Sans Neo";
  src: url(./fonts/SpoqaHanSansNeo-Bold.ttf);
  font-weight: 700;
}

사실 이 문제는 개발환경에서만 나타났고, 실제 배포된 웹에서는 발생하지 않는 문제입니다. 개발환경에서 폰트 깜빡임이 거슬리는 경우 위와 같이 수정해주면 됩니다!

결과

CLS가 거의 0.3에 가깝게 나왔었는데 수정하고 나니 0으로 측정되었습니다!


[참고사이트]
https://web.dev/articles/optimize-cls?hl=ko
https://velog.io/@tech-hoon/skeleton-ui
https://tesseractjh.tistory.com/182

0개의 댓글

관련 채용 정보