Published on

프로젝트 성능 최적화 진행 (with. Lighthouse, cra-bundle-analyzer)

Suspense를 공부하며 lazy 함수를 알게 되었고, 자연스럽게 그동안 해보고 싶었던 성능 최적화 진행을 결심하게 되었다. 프로젝트가 거의 완성된 시점에서 많은 변경 소요가 발생할 것 같아 걱정이 많았지만 제공되는 라이브러리, 도구를 활용하면 큰 어려움 없이 진행이 가능할 것 같다. 다양한 최적화가 존재하지만 오늘은 코드 분할을 중점적으로 사용해 진행할 계획이다.

성능 최적화가 진행되지 않은 현재 Lighthouse 결과

  • 측정 결과 현재 58점이라는 매우 낮은 결과가 나왔다. 많은 원인이 있었지만 가장 큰 원인으로 bundle 크기의 문제가 되었다.

bundle 크기가 커지면 커질수록 초기 화면 렌더링 시간이 지연된다. 이는 사용자가 더 오랜 시간 지연되는 화면을 보게 된다는 뜻이고, 사용자 경험을 방해하게 된다. 따라서 초기 로딩 시간을 줄일 필요성이 있다.


📌 Lighthouse

  • 웹페이지 품질을 측정하기 위한 오픈소스 자동화 도구로 웹페이지의 성능, 접근성 및 검색 엔진 최적화 요소를 검사한다. 이번 측정은 연락처 페이지를 기준으로 측정 값을 비교할 계획이다.

📌 webpack-bundle-analyzer

  • 확대/축소 가능한 대화형 트리맵을 사용하여 웹팩 출력 파일의 크기를 시각화
  • Bundle Analyzer를 사용하기 위해서는 Webpack의 Bundle Anylzer 플러그인을 추가해야 한다. 하지만, CRA 에서 사용하기 위해서는 eject를 통해, 설정을 밖으로 꺼내야 하는 위험이 있다.
  • ⭐️cra-bundle-analyzer 라이브러리를 사용하면 eject 없이 Bundle Analyzer를 사용할 수 있다.

💡 eject

  • Create React App (CRA)에서 제공하는 명령어로, CRA가 제공하는 구성과 빌드 설정을 사용자가 직접 제어하고 수정할 수 있도록 해준다.
  • 기본적으로, CRA는 React 프로젝트를 시작할 때 필요한 많은 구성을 숨겨두고, 이를 통해 개발자가 애플리케이션 개발에 집중할 수 있도록 한다.
  • eject 명령어를 사용하면, CRA가 숨겨 놓은 모든 설정 파일(Webpack, Babel, ESLint 등)을 프로젝트의 루트 디렉터리로 추출하여 개발자가 직접 수정할 수 있게 된다.

eject 사용 주의사항

  1. 한 번 eject를 실행하면, 이를 되돌릴 수 없다. 즉, CRA가 제공하는 기본 설정으로 돌아갈 수 없다.

  2. eject 후에는 CRA가 자동으로 관리해 주던 많은 설정들을 직접 관리해야 한다. 이는 프로젝트의 복잡성을 증가시킨다. (특히 Webpack과 같은 복잡한 도구들의 설정을 직접 다루어야 하는 경우)

  3. eject를 하면 CRA의 새로운 업데이트나 개선 사항을 자동으로 적용받을 수 없게 된다.


📌 eject는 정말 필요한 경우에만 사용하고 다른 대체 방법을 사용하는 것이 좋다.

  • eject로 bundle-analyzer 사용 👎
  • cra-bundle-analyzer 대체 사용 👍

NPM

npm install --save-dev cra-bundle-analyzer

Yarn

yarn add -D cra-bundle-analyzer

사용

  • 터미널에 아래 명령어 입력하기
npx cra-bundle-analyzer

명령어를 입력하면, 웹팩 출력 파일의 크기를 시각화한 웹 페이지가 자동으로 열리게 된다.

src(소스 파일 폴더)에 index.js + 253 modules라는 거대한 크기의 파일을 확인할 수 있다. 이 파일이 다운로드 되는 데까지의 시간을 줄이기 위해 코드 분할(Code Splitting)을 진행할 계획이고 react에서 제공하는 lazy 함수를 사용해 볼 계획이다.


📌 코드 분할(Code Splitting)

  • 애플리케이션의 번들을 작은 청크(chunks)로 나누고 이를 통해 사용자가 필요로 하는 시점에만 특정 부분의 코드를 로드할 수 있도록 한다. (애플리케이션 초기 로딩 시간을 감소)
  • 전통적으로 웹 애플리케이션은 하나의 큰 JavaScript 파일(번들)로 제공받았다. 이런 방식은 초기 로딩 시 모든 스크립트를 로드해야 하므로 시간이 오래 걸렸다. 코드 분할을 사용하면, 애플리케이션의 다른 부분들을 필요에 따라 지연 로딩할 수 있어, 초기 로드 시에 필요한 코드 양을 줄일 수 있다.

📌 React.lazy

  • React에서 제공하는 함수로, 동적 임포트(dynamic import)를 사용하여 컴포넌트를 지연 로딩하는 데 사용된다.
  • Suspense와 함께 사용된다.

코드 분할 진행

  • 승인(approval) Router에 lazy를 적용해 보자.

기존 코드

import { Routes, Route } from "react-router-dom";

import ApprovalPage from '../pages/approval/ApprovalPage';

export default function ApprovalRoute() {
  return (
    <Routes>
      <Route path="/" element={<ApprovalPage />} />
    </Routes>
  );
};

lazy 적용

import { lazy, Suspense } from "react";⭐️
import { Routes, Route } from "react-router-dom";

const ApprovalPage = lazy(() => import('../pages/approval/ApprovalPage'));⭐️

export default function ApprovalRoute() {
  return (
    <Suspense fallback='Loading'>⭐️
      <Routes>
        <Route path="/" element={<ApprovalPage />} />
      </Routes>
    </Suspense>
  );
};

결과 화면

  • 처음 bundle 파일에 ApprovalRoute는 제외되고 해당 컴포넌트 경로(/approval)에 접근하면 그때 동적으로 다운로드가 시작된다.
  • 약 80개의 모듈이 줄었다. (기존 253개)

bundle 크기

  • 마찬가지로 번들의 크기가 감소한 것을 확인할 수 있다.

모든 Router 파일에 lazy를 적용해 보기

  • index.js + 29로 개수가 변경되었고 크기도 매우 작아진 것을 볼 수 있다.
  • 페이지 단위로 형형색색 chunks 파일이 생성되었다.

bundle 크기

  • bundle 크기도 약 2.5MB 감소되었고 자연스럽게 다운로드 속도 또한 감소했다.

Lighthouse

코드 분할을 진행했으니 이쯤에서 Lighthouse를 다시 확인해 보자.

  • 기존 58점에 비해 점수가 많이 향상되었지만 점수를 더 올리기 위해 다른 문제들을 찾아보았다.

또 다른 문제 react-icons

  • 나눠진 chunks에서 react-icons 라이브러리가 보이고 크기 또한 작지 않아 보인다.
  • 알고 보니 라이브러리 자체가 문제의 원인이었다. react-icons는 종류별로 아이콘이 구분되어 있다. (bi, fa, bs 등등) 그리고 종류별로 하나의 js 파일 안에 모든 아이콘을 포함하고 있다.
import { BsAlarm } from "react-icons/bs";

BsAlarm 아이콘만 사용하고 있지만 BsAlarm만 build 하는 것이 아닌 bs 아이콘이 모두 포함된 js 파일 자체를 build 하고 있었다. 🤔 관련 내용은 아래 비교에서 자세하게 살펴보겠다.


  • 이러한 이유로 chunks 사이즈가 커지게 되는 것이었다.

  • 네트워크 탭에서 확인해 본 결과 react-icons를 포함한 chunks가 1.2MB를 차지하고 있었다.

해결 방법

  • @react-icons/all-files 라이브러리는 아이콘 별로 js 파일을 가지게 되므로 사용할 아이콘만 build 시 포함되기 때문에 현 상황에 가장 적합하다.
  • 몇 개의 아이콘은 기존 호환이 되지 않는 것이 존재하니 주의하자.

기존 라이브러리 제거 후 다운로드 하기

npm remove react-icons
npm i @react-icons/all-files


react-icons VS @react-icons/all-files

  • 기존과 크게 달라지는 것은 없다. 단, 라이브러리가 변경된 관계로 경로만 수정해 주면 된다.

react-icons

import { BsAlarm } from "react-icons/bs";

node_modules > react-icons > bs > index.js

  • bs 디렉터리에 위치한 모든 bs 관련 아이콘이 포함되어있는 index.js 파일을 호출하고 있다.

  • 사용하고자 했던 BsAlarm 아이콘은 index.js 파일 속 2061 번째에 위치했고 총 7779 줄의 코드로 약 1.7MB 크기와 많은 수의 아이콘이 존재했다.
  • 그동안 불필요하게 아이콘을 사용하고 있었다.

@react-icons/all-files

import { BsAlarm } from "@react-icons/all-files/bs/BsAlarm";

node_modules > @react-icons > all-files > bs > BsAlarm

  • bs 디렉터리 자체가 아닌 all-files -> bs 디렉터리에 존재하는 -> BsAlarm 아이콘 파일 자체를 호출하고 있다.

  • BsAlarm.js 파일은 5 줄 정도의 구성으로 약 1KB의 크기다.
  • 약 1.6MB를 절약 가능하다.

별도로 icon 파일 분리하기

  • 한 컴포넌트에서 여러 경로의 아이콘을 호출하다 보면 코드가 지저분해지는 상황이 발생한다.
import { BsFillMicFill } from "@react-icons/all-files/bs/BsFillMicFill";
import { FaPencilAlt } from "@react-icons/all-files/fa/FaPencilAlt";
import { BiStop } from "@react-icons/all-files/bi/BiStop";
import { BiSave } from "@react-icons/all-files/bi/BiSave";
import { VscDebugRestart } from "@react-icons/all-files/vsc/VscDebugRestart";
import { RiPlayList2Fill } from "@react-icons/all-files/ri/RiPlayList2Fill";
import { AiOutlinePlusCircle } from "@react-icons/all-files/ai/AiOutlinePlusCircle";
import { AiFillDelete } from "@react-icons/all-files/ai/AiFillDelete";
import { AiOutlineShareAlt } from "@react-icons/all-files/ai/AiOutlineShareAlt";
import { AiFillPrinter } from "@react-icons/all-files/ai/AiFillPrinter";
  • 아이콘을 icons 폴더에서 관리하고 호출하자.
import {
  BsFillMicFill,
  FaPencilAlt,
  BiStop,
  BiSave,
  VscDebugRestart,
  RiPlayList2Fill,
  AiOutlinePlusCircle,
  AiFillDelete,
  AiOutlineShareAlt,
  AiFillPrinter
} from "../../common/icons/index";
  • icons 폴더에서 호출하므로 전보다 깔끔한 코드가 되었다.

성능 확인해 보기

cra-bundle-analyzer

아까와 큰 틀의 사각형은 큰 변화가 없지만 react-icons가 각 chunks 파일에서 거의 사라진 졌다. 시각화된 자료로는 정확한 판단이 어렵기 때문에 다시 네트워크 탭에서 처음 봤던 chunks 크기를 확인해 보자.

  • 기존(1.2MB)에 비해 chunks 크기가 약 1200KB 감소했다.

bundle 크기

  • 3.5MB -> 1MB -> 523KB까지 약 3MB 정도 크기가 감소했다.

연락처 페이지(contact) 기준 - Lighthouse

앞서 언급한 최적화 작업 외에도 접근성, 권장사항, 검색엔진 최적화 항목 점수 향상을 위해 추가적으로 작업을 진행했다.

  • button, link 태그 aria-label 속성 추가
  • 이미지 alt 추가

  • 58점 -> 77점 -> 97점으로 약 30점 정도 성능 점수가 향상되었다.
  • 다른 항목 또한 최소 90점 이상의 점수를 받을 수 있었다.


마치며

다양한 도구들을 사용해 최적화를 처음 진행해 보게 되었는데, 변경되는 수치를 확인할 수 있는 부분이 매력적이었다. 이번 최적화 과정에서 그동안 사용했던 react-icons 라이브러리의 문제점을 알게 된 부분이 가장 충격적이었다. 다행히 그렇게 많이 아이콘을 사용하지 않아 짧은 시간 안에 대체할 수 있어 다행이라 생각한다. 따라서 앞으로는 처음부터 최적화를 진행하며 프로젝트를 진행할 생각이다.

코드 분할로 bundle의 크기는 줄었지만 페이지에 방문하게 될 경우 chunks를 다운로드하게 되고 Suspense에 의해 fallback이 렌더링 된다. 다운로드 이후에는 상관없지만 최초 렌더링 시 짧은 시간 동안 fallback이 렌더링 발생이 오히려 사용자 경험을 방해하는 것 같다. 현재는 Loading 텍스트를 렌더링 하지만 SkeletonUI와 비슷하게 렌더링 될 컴포넌트를 만들어 배치할 계획이다.

lazy를 router에 적용할 경우 자주 사용하지 않거나 파일 크기가 큰 페이지에 사용하는 것이 유용하다는 권장이 있어서 처음으로 접하게 되고 크기가 작은 로그인, 회원가입 페이지는 lazy를 적용하지 않았다. 무작정 사용하기보다는 코드 분할 적용 여부에 대한 충분한 고민이 필요할 것 같다.



Reference

(npm) cra-bundle-analyzer

React 공식문서

https://eratosthenes.tistory.com/2