Lighthouse CLS, LCP 점수 올려보기

무무닷·2024년 12월 2일
0
post-thumbnail

들어가며

1년 전에 진행하고, 완성했던 프로젝트를 다시 보니 성능이 엉망이었다. 부트캠프를 수료하고 바로 만드는 프로젝트였기에 기능 구현만을 목표로 잡고 만든 프로젝트여서, 최적화를 하는 방법도 몰랐고, 성능측정을 어떻게 하는지도 몰랐다.
부족했던 나 자신을 바로 보게 되고, 어떤게 문제인지 이제는 알게 된 나를 칭찬해보는 기회였다.
이번 기회에 최적화를 달성해보자는 목적을 갖고 무엇이 문제인지 검사해보기로 했다.

프로젝트는 React + Firebase로 진행했고, 이미지 storage는 cloudinary를 사용했다.

1. 성능 최적화가 필요했던 이유


보다시피 엉망인 상황이었다. 특히 이미지가 너무 많아서 LCP 점수가 매우 높았다.

1.1 발견된 문제점

  • LCP(Largest Contentful Paint)10.4초로 매우 느림 (권장: 2.5초 이하)
  • CLS(Cumulative Layout Shift)0.11로 레이아웃 불안정 (권장: 0.1 이하)
  • 이미지 용량이 18,274KiB로 매우 큼 (모바일 환경에서 특히 문제)

1.2 사용자 경험을 해치는 느린 LCP

LCP(Largest Contentful Paint)는 페이지의 주요 콘텐츠가 로드되는 시점을 측정하는 지표입니다. 우리 서비스의 경우 LCP가 10.4초로 측정되었는데, 이는 Google이 권장하는 2.5초보다 훨씬 긴 시간이었다. 빨간색으로 표시된 저 숫자가 과거의 나를 비웃었다.

LCP가 사용자 경험에 미치는 영향

  • 첫인상의 실패: 사용자가 처음 페이지에 진입했을 때 10초 이상을 하얀 화면이나 로딩 화면을 보게 된다. 끔찍한 현실이었다.
  • 이탈률 증가: Google의 연구에 따르면 페이지 로딩이 3초를 넘어갈 때마다 이탈률이 32% 증가한다. 10초가 넘는 로딩 시간이라면, 내 웹사이트의 이용자가 없는 이유를 알겠다.
  • 검색 엔진 최적화(SEO) 불이익: Google은 Core Web Vitals를 검색 순위 결정의 중요한 요소로 사용한다. 느린 LCP는 검색 결과에서의 순위 하락으로 이어질 수 있다.

1.3 불안정한 레이아웃으로 인한 CLS 문제

CLS(Cumulative Layout Shift)는 페이지 로드 중 발생하는 예기치 않은 레이아웃 이동을 측정하는 지표라고 한다. 우리 서비스는 0.11의 CLS 점수를 기록했는데, 이는 '개선 필요' 단계에 해당했다. 주황색으로 뜨는게 오히려 다행이었다.

CLS가 사용자 경험에 미치는 영향

  • 잘못된 클릭 유도: 레이아웃이 갑자기 이동하면서 사용자가 의도하지 않은 요소를 클릭하게 되는 문제가 발생하게 된다. 주로 모바일환경에서 발생하는데, 어떤 요소를 탭했을 때 갑자기 다른 요소가 생겨 잘못 누르게 되면서 짜증이 나는 경우가 있었을 것이다.
  • 사용자 불편감: 콘텐츠를 읽거나 특정 요소를 클릭하려고 할 때 레이아웃이 움직이면, 사용자는 원하는 위치를 다시 찾아야 한다. 이는 사용자의 피로도를 높이고 불편함을 초래하게 된다.
  • 전문성 하락: 레이아웃이 불안정한 웹사이트는 사용자들에게 아마추어처럼 보이고, 웹 사이트 이탈을 증가시키는 원인이 된다.

2. LCP를 해결해보자(feat. WebP)

사용되는 이미지들의 확장명이 png가 많았고, jpg도 조금씩 보였다. Lighthouse에서 차세대이미지형식(webp)를 사용하라고 권장했기에 이미지들을 webp로 변환하는 절차를 밟기로 했다.

2.1 프로젝트 이미지 처리 흐름

이미지는 세 측면으로 나누어서 진행했다.
public 폴더에 저장되는 Static Images와, cloudinary에 저장되어있는 CDN Images로 나뉘었는데, public 폴더에 있는 이미지들은 그냥 webp로 변환해서 다시 넣어주어서 쉽게 용량을 줄일 수 있었다.

문제는 CDN Images인데, 어드민 페이지에서 제품을 추가할 때 이미지 파일을 input으로 받고, 이를 cloudinary에 업로드해서 url을 받는다. 이 url을 제품 정보와 함께 firebase DB에 저장하게 된다.

2.2 기존 이미지 webP변환

이미 서비스 중인 프로젝트였기 때문에, 이미 올라간 URL들은 어떻게 처리할지가 문제였다.
생각해낸 방법은 2가지였다.

  1. admin 계정으로 관리자 페이지에서 제품 이미지를 webp로 변환하여 일일이 다시 업로드한다.(다소 무식)
  2. 클라이언트에서 데이터를 받아올 때 이미지를 변환하여 가져온다.

1번의 방법은 확실하지만 굉장히 노가다스럽고 개발자스럽지 않은 일이었기 때문에, 최후의 보루로 남겨두었다.
2번 방법은 이미 url을 통해 이미지를 받아온 상태에서, 클라이언트 측 유틸 코드를 통해서 변환하는 방법이기 때문에 불러오는 이미지 용량에는 변화가 없다.

이러한 상황 속에서 다른 방법이 없을까 하고 찾아보던 차에, Cloudinary 쪽 공식문서에서 희망을 발견했다.

공식문서

이미지를 불러올 때 확장자만 바꿔주면, cloudinary 안에서 알아서 이미지를 변환해서 보내주는 것이다! Image transformations 기능을 제공해준다.

이 방법을 사용하면 이미지 용량이 현저히 줄고, 그로 인해 이미지를 불러오는 시간이 줄어들어 LCP가 점수가 높아질 것이다.
다음은 해결 방안이다.

// utils/imageConverter.js
export const imageExtensionToWebp = (url) => {
  const regex = /\.(jpg|jpeg|png)$/i;
  if (regex.test(url) === false) return url;
  return url.replace(regex, '.webp');
};

유틸에 이미지 컨버터 파일을 만들고, 정규식을 통해 jpg, jpeg, png가 있는 경우에 .webp로 변환하여 url을 return하게 만들었다.
이미지 url은 firebase DB에 있는 데이터를 불러오기 때문에, 추가적으로 useEffect를 사용해서 image가 클라이언트에 로드 되었을 때 변환을 하기로 했다.

//pages/ProductDetail.jsx
  const { data: product, isLoading, isError, error } = useDetailProduct(id); // product를 fetching합니다.
  const {
    image,
    title,
    description,
    price,
    vimeoId,
    category,
    section,
    detailImage,
  } = product || {}; // product에서 필요한 부분만 추출합니다.

  useEffect(() => {
    setConvertedImage(imageExtensionToWebp(image));
    setConvertedDetailImage(imageExtensionToWebp(detailImage));
  }, [image, detailImage]); // image와 detailImage 상태가 변경되면 변환합니다.
// ...생략
return (
  // ...생략
  <img
  	src={convertedImage}
  	alt={title}
  />
  <img
	className={'rounded-lg'}
	src={convertedDetailImage}
	alt={title}
  />
)

2.3 업로드 시 webP 변환

새로 올리는 이미지는 그냥 올려도 2.2처럼 불러올 때 webp로 변환하면 되지만, 그러면 cloudinary 서버에서 이미지가 많아지면 저장용량이 줄어들기 때문에 업로드 시에 webP로 변환하기로 했다.

export const convertToWebP = async (file, maxWidth = 1920) => {
  // 파일 크기 체크 (예: 10MB 초과)
  if (file.size > 10 * 1024 * 1024) {
    throw new Error('File size should be less than 10MB');
  }

  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = (event) => {
      const img = new Image();
      img.onload = () => {
        const canvas = document.createElement('canvas');

        // 이미지가 maxWidth보다 큰 경우 리사이징합니다.
        let width = img.width;
        let height = img.height;
		// maxWidth를 초과하는 경우 비율에 맞춰 높이 조정
        if (width > maxWidth) {
          height = Math.round((height * maxWidth) / width);
          width = maxWidth;
        }

        canvas.width = width;
        canvas.height = height;

        const ctx = canvas.getContext('2d');
        ctx.drawImage(img, 0, 0, width, height);  // 이미지를 캔버스에 그리기

        // 품질 설정 (큰 이미지의 경우 더 낮은 품질 적용)
        const quality = file.size > 5 * 1024 * 1024 ? 0.7 : 0.8;

        canvas.toBlob(
          (blob) => {
            if (!blob) {
              reject(new Error('Canvas to Blob conversion failed'));
              return;
            }
            const fileName = file.name.replace(/\.[^/.]+$/, '.webp'); // 원본 파일 이름에서 확장자 변경
            const webpFile = new File([blob], fileName, { type: 'image/webp' }); // Blob을 File 객체로 변환
            resolve(webpFile); // 변환된 WebP 파일 반환
          },
          'image/webp',
          quality
        );
      };
      img.onerror = reject;
      img.src = event.target.result; // FileReader로 읽은 데이터로 이미지 소스 설정
    };
    reader.onerror = reject;
    reader.readAsDataURL(file); // 파일을 Data URL로 읽기
  });
};

새로 업로드되는 이미지는 클라이언트 단에서 WebP로 변환하고, 파일 크기에 따라 품질을 자동으로 조절하도록 구현했다.

아쉽게도 최적화 전에 스크린샷을 못찍어놓고, 기록으로만 남아있다..

이미지를 webP로 바꾸니 전체 이미지 절감 가능치가 18,274KiB에서 1,300KiB16,974KiB 절감되었다.


3. CLS를 개선해보자

CLS(Cumulative Layout Shift)는 페이지 로드 중 발생하는 예기치 못한 레이아웃 변경을 측정하는 지표다. 레이아웃이 불안정하면 사용자가 클릭하려던 요소가 이동하여 불편함을 유발할 수 있다. 이 문제를 해결하기 위해 Skeleton UI로 레이아웃을 잡아두고, 로딩이 완료되면 레이아웃에 콘텐츠가 들어갈 수 있게 구성했다.

3.1 Skeleton UI 적용하기

Skeleton UI는 콘텐츠가 로드되기 전까지의 공간을 차지하는 빈 껍데기(프레임)를 보여주는 방식이다. 이를 통해 페이지 로딩 중에도 레이아웃을 안정적으로 유지할 수 있었다. Skeleton UI를 적용한 결과, 주요 콘텐츠가 늦게 로드되더라도 레이아웃이 안정적으로 유지되어 CLS 점수가 개선되었다.
카테고리 메뉴가 로드되는 동안 로딩 스피너를 표시하여 사용자에게 피드백을 제공했다.

{isOptionsLoading &&
  Array.from({ length: 6 }).map((_, index) => (
    <div key={index} className="flex h-[30px] px-[10px]">
      <span className="loading loading-spinner loading-xs"></span>
    </div>
  ))}

카테고리 메뉴가 로드되는 동안 로딩 스피너를 보여주어 사용자가 콘텐츠가 로드되고 있음을 인지할 수 있도록 했다. 이렇게 함으로써, 레이아웃이 불안정해지는 것을 방지하고 사용자 경험을 개선할 수 있었다.

Suspense와 SectionSkeleton을 사용하여 섹션들이 로드되는 동안 빈 공간을 차지하도록 했다.

<Suspense
  fallback={Array.from({ length: 6 }).map((_, index) => (
    <SectionSkeleton key={index} />
  ))}
>
  <Sections />
</Suspense>

이렇게 Skeleton UI를 적용하니, 사용자는 콘텐츠가 로드되고 있음을 인지할 수 있었고, 레이아웃의 안정성 덕분에 불편함이 줄어들었다. 결과적으로 CLS 점수가 개선되어 사용자 경험이 한층 향상되었다.

Suspense에 대해서도 이슈가 있어 자세히 탐구했었는데, 따로 포스팅으로 올리겠다.

마치며

결국 달성하게 됐다.

FCP는 좀 늘기는 했지만, LCP와 CLS는 확실하게 줄었다. 아직 부족한 부분이 많지만, 그래도 뿌듯하다.

프로젝트들은 항상 미련이 남는 것 같다. 이번에 포스팅한 프로젝트는 수정할 것들이 많고, 서비스되고 있기는 하지만, 개발용 배포를 구축해놨던 프로젝트였기에, 이것 저것 다 해보면서 많이 배울 수 있었던 것 같다. Lazy Loading과 Suspense, 그리고 리팩토링에 대한 내용도 빠른 시일 내에 포스팅으로 다루려고 한다.

블로그에 글을 쓰면서 더 많이 배우게 되는 것 같다. 진작 좀 쓸걸..하는 후회가 있지만 이미 지나간 일. 탓해 무엇하리. 알게된 것들을 복습하는 차원에서라도 열심히 올려봐야겠다. 이번 글은 쓰는데 너무나 오래 걸렸다. 익숙해지면 빠르게 작성할 수 있겠지.

profile
삽질기록저장소입니다.

0개의 댓글