Lighthouse Performance 성능 79% 개선 (번들, 이미지 최적화)

sangsang·2024년 7월 27일
1

Sweet Home 프로젝트

목록 보기
1/1
post-thumbnail

Lighthouse Performance 개선하기

프로젝트 'Sweet home'의 성능 개선 과정을 공유해보고자 합니다. 개선점이 무엇이고, 어떻게 최적화했는지, 최적화 후 결과를 정리하였습니다.

어떤 프로젝트인가요?

'Sweet Home'는 인증/인가, 가상 계좌 거래, 결제, 상품 등록 등의 20개 이상의 API를 사용해서 가상의 쇼핑몰을 개발한 프로젝트입니다.

라이프 스타일 편집샵을 컨셉으로 생활용품과 가구 등을 구매할 수 있는 온라인 쇼핑몰을 구현하였습니다.

성능 개선 목표

상품 리스트 페이지는 쇼핑몰에서 사용자가 가장 많이 이용하는 페이지이기 때문에 이 페이지의 성능을 가장 먼저 개선하기로 했습니다. LightHouse로 성능을 측정해보니, 상품 리스트 페이지의 Performance 49점으로 성능이 '나쁨' 상태였습니다.

Performance 카테고리의 세부 측정 항목 수치를 고려해서 Performance 점수 80점 이상을 목표로 성능 개선을 진행했습니다.

Performance 카테고리
1. FCP(First Contentful Paint): 🔺 6.7s
2. LCP(Lagest Contentful Paint): 🔺 14.7s
3. TBT(Total Blocking Time): 🟢 0ms
4. CLS(Cumulative Layout Shift): 🟨 0.152
5. Speed Index: 🔺 6.9s

최적화 전 성능 이미지

번들 크기 최적화

FCP(First contentful paint) 최적화하기 위해서 가장 먼저 번들 크기를 분석해서 초기 로딩 속도를 개선해 보기로 했습니다.

vite는 내부적으로 rollup 번들러를 사용해서 rollup-plugin-visualizer를 통해 번들 크기를 시각적으로 분석했습니다. 분석 결과, 모든 리소스가 하나의 청크에 묶여 초기 로딩 시 리소스가 전부 로드되었습니다.

최적화 전 번들 이미지

청크를 분리해서 크기를 줄이거나 필요한 시점에 청크를 동적으로 불러와서 페이지 로드 시간 단축했습니다. 다만, 청크들이 너무 세분화돼서 많아지면 성능에 부정적인 영향을 미칠 수 있기 때문에 적절하게 나눠야 했습니다.

manualChunks로 라이브러리 청크 분리

vite.confing.js에 manualChunks 옵션을 적용했습니다. 이 옵션을 통해 특정 모듈에서 사용하는 외부 라이브러리를 메인 청크에서 분리하여 Lazy Load할 수 있었습니다. rollupOptions.output.manualChunks에 특정 라이브러리나 모듈을 명시적으로 지정해 별도의 청크로 분리합니다.

    rollupOptions: {
      output: {
        manualChunks: {
          react: ["react", "react-dom"],
          reactRouter: ["react-router-dom"],
          swiperBundle: ["swiper"]
        }
      }
  }

manualChunks 적용 후 번들 모습

최적화 후 번들 사이즈

Page 단위로 Code Splitting 적용

Page 단위로 Code Splitting 적용해서 사용자가 앱에 처음 접근했을 때, 필요한 최소한의 코드만 로드되고 이후 사용자 동작에 따라 추가 코드가 로드되도록 개선했습니다. 초기 로드 시간 및 파일 크기 감소에 도움이 되었습니다.

React.lazy

React.lazy 함수를 사용하면 동적 import를 사용해서 컴포넌트를 렌더링할 수 있습니다. lazy 컴포넌트는 Suspense 컴포넌트 하위에서 렌더링 되어야 하며, Suspense는 lazy 컴포넌트가 로드되길 기다리는 동안 로딩 화면과 같은 컨텐츠를 보여줍니다.

페이지 전환 시 웹 페이지 로드 시간이 발생하며 대부분 페이지를 한 번에 렌더링하기 때문에 라우트 기반 코드 분할을 설정했습니다.

홈페이지는 Lazy Loading를 적용하지 않습니다. 그 이유는 사용자가 처음 홈페이지에 접속했을 때, 컨텐츠가 아직 로드되지 않을 수 있기 때문입니다. 만약 홈페이지의 하단 사이드바에 위치한 광고나 추가 콘텐츠가 있다면, 그 부분에는 lazy loading을 적용하는 것이 좋을 것 같습니다.

import { lazy, Suspense, type ReactElement } from "react";
import { createBrowserRouter } from "react-router-dom";
import Loading from "~/components/common/Loading";
import App from "~/App";
import Home from "~/routes/Home/Home";
const About = lazy(() => import("~/routes/About/About"));
const Shop = lazy(() => import("~/routes/Shop/Shop"));
const ShopDetail = lazy(() => import("~/routes/Shop/ShopDetail"));
const MyPage = lazy(() => import("~/routes/MyPage/MyPage"));
  
  //중략
  
const SuspenseWrapper = ({ element }: { element: ReactElement }) => (
  <Suspense fallback={<Loading />}>{element}</Suspense>
);

export default createBrowserRouter([
  {
    path: "/",
    element: <App />,
    children: [
      {
        path: "/",
        element: <Home />
      },
      {
        path: "/about",
        element: <SuspenseWrapper element={<About />} />
      },
      {
        path: "/shop",
        element: <SuspenseWrapper element={<Shop />} />
      },
      {
        path: "/shop/:id",
        element: <SuspenseWrapper element={<ShopDetail />} />
      },
      {
        path: "/mypage",
        element: <SuspenseWrapper element={<MyPage />} />
      },
    
     // 하략

manualChunks와 Code Splitting 적용 후 번들 모습

이미지 최적화

상품 목록 페이지는 상품의 이미지가 많이 사용되기 때문에 해당 페이지의 이미지를 한 번에 불러오면 웹 페이지 로딩 속도가 느려질 수밖에 없습니다. 화면에 보여지는 이미지만 로드해서 주요 컨텐츠를 빠르게 보여주는 방법을 적용했습니다.

Intersection Observer API 사용

Intersection observer는 브라우저 뷰포트(Viewport)와 설정한 요소(Element)의 교차점을 관찰하며, 요소가 뷰포트에 포함되는지 포함되지 않는지 구별하는 기능을 제공합니다.

또한, 비동기적으로 실행되기 때문에, scroll 같은 이벤트 기반의 요소 관찰에서 발생하는 렌더링 성능이나 이벤트 연속 호출 같은 문제 없이 사용할 수 있습니다.

useLazyImageObserver 커스텀 훅

  1. imageRef가 truthy하고 imageSrc가 falsy할 때, IntersectionObserver를 생성합니다.
  2. IntersectionObserver는 이미지가 뷰포트에 10% 이상(threshold: [0.1]) 보이게 되면(entry.isIntersecting) imageSrc를 src로 설정합니다.
  3. 이미지가 로드되면, 옵저버는 해당 이미지 관찰을 중지(unobserve)합니다.
  4. useEffect 클린업 함수는 옵저버를 해제(disconnect)합니다.
import { useEffect, useRef, useState } from "react";

function useLazyImageObserver(src: string) {
  const [imageSrc, setImageSrc] = useState("");
  const imageRef = useRef(null);

  useEffect(() => {
    if (imageRef && !imageSrc) {
      const observer = new IntersectionObserver(
        ([entry]) => {
          if (entry.isIntersecting) {
            setImageSrc(src);
            if (imageRef.current) {
              observer.unobserve(imageRef.current);
            }
          }
        },
        { threshold: [0.1] }
      );
      if (imageRef.current) {
        observer.observe(imageRef.current);
      }

      return () => {
        observer && observer.disconnect();
      };
    }
  }, [imageSrc, imageRef]);

  return { imageRef, imageSrc };
}

export default useLazyImageObserver;

LazyImage 컴포넌트에 useLazyImageObserver 훅 적용한 예시

import useLazyImageObserver from './useLazyImageObserver';

const LazyImage = ({ src }) => {
  const { imageRef, imageSrc } = useLazyImageObserver(src);

  return  <img ref={imageRef} src={imageSrc}  alt="상품 이미지"/>;
};

export default LazyImage;

비교

  • 최적화 전
    상품 리스트 페이지에서 사용하는 이미지를 모두 불러옵니다.

  • 최적화 후
    뷰포트에 보이는 이미지만 먼저 불러옵니다.

placeholder 제공

뷰포트에 보이지만 임계점을 넘지 않아 이미지가 로드되지 않는 경우가 발생할 수 있습니다. 이미지가 로드되지 않아 엑박이 뜨거나 CLS가 발생하는 것을 방지하기 위해서 기본 이미지를 설정했습니다. 기본 이미지는 용량이 작은 이미지를 사용했습니다.

  • 임계점이 10% 넘지 않았을 때 엑박 발생

    최적화 전 성능 이미지
  • 기본 이미지 설정

    최적화 전 성능 이미지

imagemin을 사용해 이미지 압축

Lighthouse에서 'Efficiently encode images'부분 개선이 필요했습니다. 압축되지 않은 이미지는 이미지의 리소스 로드 기간이 늘어나 LCP 시간이 길어지게 합니다. imagemin을 사용해서 이미지를 압축하여 이미지 용량을 줄였습니다.

최적화 전 성능 이미지

imagemin

Imagemin은 다양한 이미지 형식을 지원하고 빌드 스크립트 및 빌드 도구와 쉽게 통합되므로 이미지 압축에 탁월한 선택입니다.

Imagemin은 CLI 및 npm 모듈로 사용할 수 있지만, 일반적으로 더 많은 구성 옵션을 제공하는 npm 모듈을 사용했습니다.

프로젝트에서 사용하는 특정 이미지 형식을 압축하는 npm 패키지를 설치해서 사용합니다. (예: 'mozjpeg'는 JPEG를 압축, 'pngquant'는 png 압축).

플러그인을 선택할 때 '손실'를 선택할 것인지, '무손실'을 선택할 것인지를 고려해야 합니다. 손실 압축은 이미지 품질이 저하될 수 있지만, 파일 크기를 줄일 수 있습니다.

파일 크기를 크게 절약할 수 있고, 필요에 맞게 압축 수준을 맞춤 설정할 수 있어서 손실(lossy) 플러그인을 선택했습니다.

사용한 imagemin 플러그인

    rollupOptions: {
      plugins: [
        visualizer({
          filename: "./dist/report.html",
          open: true,
          gzipSize: true,
          brotliSize: true
        }) as PluginOption,
        viteImagemin({
          plugins: {
            jpg: imageminMozjpeg({ quality: 50 }),
            png: imageminPngQuant({ quality: [0.6, 0.8] })
          },
          makeWebp: {
            plugins: {
              jpg: imageminWebp(),
              png: imageminWebp()
            }
          }
        })
      ],
      ...
}
  • @vheemstra/vite-plugin-imagemin
    Vite 빌드 과정에서 이미지 파일을 최적화하기 위해 vite-plugin-imagemin을 사용했습니다.

  • imagemin-mozjpeg
    imageminMozjpeg를 설정해 JPEG 이미지를 품질 50으로 압축했습니다.

  • imagemin-pngquant
    imageminPngQuant를 사용하여 PNG 이미지를 품질 0.6에서 0.8 사이로 압축했습니다.

  • imagemin-webp
    makeWebp에 imageminWebp를 사용하여 JPEG, png 이미지를 WebP 형식으로 변환합니다.

측정

다음 사진은 프로젝트에서 이미지 포맷을 jpeg를 사용해서 jpeg 최적화 적용된 부분만 측정한 이미지입니다. 전체 이미지 용량이 65.51% 감소되었습니다.

최적화 전 성능 이미지

최적화 결과

번들 크기와 이미지 최적화를 통해 Performance 점수가 49점에서 88점으로 79.59% 개선되었습니다. 성능 개선 목표 80점 이상을 달성하였습니다.

최적화 후 성능이 많이 개선되었지만, LCP와 CLS 항목에 아직 개선할 부분이 남아 있습니다.
캐싱을 활용하여 서버 응답 시간 단축하거나, 중요한 리소스를 미리 로드(preload), 폰트 최적화를 통해 성능 개선하는 등 추후 90점 이상을 목표로 추가로 성능을 개선해 보겠습니다.

Performance 카테고리
1. FCP(First Contentful Paint): 🔺 6.7s → 🟢 0.6s
2. LCP(Lagest Contentful Paint): 🔺 14.7s → 🟨 1.7s
3. TBT(Total Blocking Time): 🟢 0ms → 🟢 0ms
4. CLS(Cumulative Layout Shift): 🟨 0.152 → 🟨 0.135
5. Speed Index: 🔺 6.9s → 🟢 0.8s

최적화 후 성능 이미지

참고

profile
개발이 너무 좋다. 정말 잘 하고 싶다.

0개의 댓글

관련 채용 정보