번들 최적화를 통해 성능 개선하기

Ji-Heon Park·2024년 12월 18일
1

TmaxRG

목록 보기
10/10

1. 서론

리액트와 같은 SPA(Single Page Application) 프로젝트는 빌드 과정에서 하나의 JS 파일로 번들링됩니다. 그러나 하나의 파일로 번들링된 결과물을 배포하게 되면, 웹 애플리케이션은 초기 로딩 시 모든 페이지에 대한 정보를 한 번에 불러오게 되어 초기 로딩 속도가 느려지는 문제가 발생합니다.

이러한 문제를 해결하기 위해 번들 최적화가 필요합니다. 이번에 제가 적용해 본 번들 최적화 방법들은 아래와 같으며, 각각의 개념과 활용법을 소개하겠습니다.

  • Code Splitting
    • Page-Based Code Splitting
    • Loading chunks on demand
  • 라이브러리 경량화
  • Text Compression
  • Tree-Shaking
  • 정적 자산 최적화

💡 번들링이란?
웹 초창기 브라우저 모듈 시스템(Module system)이 아직 표준화되지 않았던 시기에는 여러 자바스크립트 파일을 단순하게 HTML 

이후 CJS, AMD 등 모듈 시스템의 등장하였고, 이러한 모듈 시스템을 효율적으로 브라우저에서 사용하기 위해 번들러가 등장했습니다.

번들러(Bundler)는 웹 애플리케이션을 개발하기 위해 필요한 HTML, CSS, JS 등의 파편화된(모듈화된) 자원들을 모아서, 하나 혹은 최적의 소수 파일로 결합(번들링)하는 도구입니다.

대표적으로 webpackparcelrollupbrowserify 이 존재합니다.

2. 번들 최적화 기법

2.1. Code Splitting

프로젝트를 빌드하면, 결과물로 하나의 JS 파일이 생성됩니다. 번들 최적화의 가장 기본적인 목표는 번들 크기를 줄여 성능을 개선하는 것입니다.

이를 위해 번들 파일을 분리하고, 필요한 시점에 해당 파일을 불러오는 Code Splitting 기법이 사용할 수 있습니다.

2.1.1. Page-Based Code Splitting

많이 사용해봐서 익숙한 페이지 기반 코드 스플리팅이 여기에 해당합니다. 아래 코드는 페이지 컴포넌트를 React.lazy로 감싸 페이지 단위 번들을 분리하는 것입니다.

import { lazy, Suspense } from 'react';

const Login = lazy(() => import('./page/Login'));
const Content = lazy(() => import('./page/Content'));

export function App() {
  return (
    <HashRouter>
      <Routes>
        <Route
          path='/login'
          element={
            <Suspense fallback={<Loading />}>
              <Login />
            </Suspense>
          }
        />
        <Route
          path='/content'
          element={
            <Suspense fallback={<Loading />}>
              <Content />
            </Suspense>
          }
        />
      </Routes>
    </HashRouter>
  );
}

Page-based 코드스플리팅만 해도 각 페이지 진입마다 필요한 모듈만 불러와서 초기 로딩시간을 단축할 수 있습니다.

하지만 여기서 더 최적화 할 수 없을까요?

2.1.2. Loading chunks on demand

현재 상황을 파악해 개선 포인트를 찾아보겠습니다. 번들의 사이즈를 세부적으로 확인하기 위해 rollup-plugin-visualizer 라이브러리를 사용했습니다.

npm i -D rollup-plugin-visualizer

라이브러리 설치 후 vite.config.ts에 다음과 같이 작성합니다.

import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    svg(),
    react(),
    nxViteTsPaths(),
    visualizer({
      filename: './dist/report.html',
      open: true,
      brotliSize: true,
    }),
  ],
  ...

이후, 로컬에서 빌드를 실행하면 아래와 같이 빌드 결과물을 시각화할 수 있습니다.

시각화된 자료를 통해 우측의 html2canvas 라이브러리가 전체 번들의 30% 가량 차지하는 것을 볼 수 있습니다.

html2canvas는 HTML을 캔버스로 변환해주는 라이브러리로, 특정 화면을 캡처하기 위해 설치되었습니다. 그러나 이 라이브러리는 특정 버튼을 클릭할 때만 사용되며 핵심 기능이 아닙니다.

특정 이벤트에 종속적인 대용량 라이브러리를 초기 로딩 시 불러올 필요는 없습니다. 이를 필요할 때만 불러오도록 최적화해 보겠습니다.

Vite에서는 수동으로 청크를 분리할 수 있는 기능(manualChunks)을 제공합니다.

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          html2canvas: ['html2canvas'],
        },
      },
    },
    // ...
  },
  // ...

build.rollupOptions.output.manualChunks에 위와 같이 작성하면, 빌드할 때 html2canvas 라이브러리가 별도의 청크로 분리됩니다.

하지만 아직 코드상에 import html2canvas from 'html2canvas'; 가 있어 페이지에 진입시 청크를 불러옵니다.

import html2canvas from 'html2canvas';

export async function captureHtmlToCanvas(targetRef: HTMLDivElement | null, zoom = 1): Promise<HTMLCanvasElement | string> {
  if (targetRef && targetRef !== null) {
    const iframe = (document.getElementById('cardContents') as HTMLIFrameElement) || null;
// ...

이때 ES6에서 추가된 Dynamic Import를 사용하면 캡처 버튼, 즉 특정 이벤트가 발생할 때 라이브러리를 불러올 수 있습니다. 상단의 import 문을 제거하고, ES6에서 추가된 동적 임포트 문법을 사용하면 됩니다.

// import html2canvas from 'html2canvas'; <- 제거

export async function captureHtmlToCanvas(targetRef: HTMLDivElement | null, zoom = 1): Promise<HTMLCanvasElement | string> {
  if (targetRef && targetRef !== null) {
    const html2canvas = (await import('html2canvas')).default; // 함수가 실행될 때 불러온다.

    const iframe = (document.getElementById('cardContents') as HTMLIFrameElement) || null;

이렇게 작성하면 captureHtmlToCanvas 함수가 호출될 때, 즉 버튼을 클릭했을 때 모듈이 비동기적으로 로드됩니다. 이제 첫 진입시 html2canvas 라이브러리는 불러오지 않습니다.

모든 최적화에는 트레이드오프가 따릅니다. 이 방식을 적용할 수 있었던 이유는 캡처 기능이 이 서비스에서 핵심 기능이 아니기 때문입니다. 미리 모듈을 불러오는 방식과 달리, 버튼을 클릭한 후에야 모듈을 로드하게 되므로 캡처 이벤트 자체에는 시간이 걸리게 됩니다. 그렇기에 상황을 잘 고려하여 적용하는 것이 중요합니다.

2.2. 라이브러리 경량화

앞서 소개한 방법은 불필요한 라이브러리나 모듈을 불러오는 시점을 제어하는 것이었습니다. 또 다른 최적화 방법은 경량화된 라이브러리를 사용하는 것입니다.

동일한 기능을 제공하지만, 더 가벼운 라이브러리를 선택하는 것입니다. 예를 들어, 현재 프로젝트에서 사용중인 mobx-react의 경우 공식적으로 경량화된 mobx-react-lite가 제공됩니다.

npm 패키지 확인 사이트: https://bundlephobia.com/

경량화된 라이브러리를 도입할 때는 기존 라이브러리와 어떤 차이가 있는지 반드시 확인해야 합니다.

mobx-react-lite는 경량화 되었지만, 몇 가지 기능을 지원하지 않는다고 공식 문서에 명시되어 있습니다.

그러나 해당 프로젝트에서 사용중인 메서드는 모두 지원하고 있었고, 미지원 기능도 리액트의 내장 기능을 통해 대체가 가능하다고 나와있어 교체해도 문제가 없다 판단했습니다.

npm uninstall mobx-react
npm install mobx-react-lite

15.7kb에서 6.4kb로 경량화된 라이브러리를 사용하기로 했습니다!

2.3. Text Compression

HTML, CSS, JS는 모두 텍스트 기반 파일이므로 텍스트 압축 기법을 적용할 수 있습니다. Gzip 압축을 통해 파일 크기를 줄이고, 더 빠르게 전송한 후 사용하는 시점에 압축을 해제합니다. 이를 통해 파일 크기가 줄어들어 리소스 전송 시간이 단축됩니다.

Gzip 압축은 Nginx 설정을 통해 간단히 적용할 수 있습니다.

gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_proxied any;
gzip_min_length 1000;
gzip_comp_level 6;
gzip_vary on;

압축 여부는 HTTP 헤더를 통해 확인할 수 있습니다. (Content-Encoding: gzip)

압축 전후의 파일 사이즈를 비교해 보면 용량이 크게 줄어듭니다.

그러나 시간이 더 소요됐습니다. Gzip 압축은 기본적으로 웹 서버의 리소스를 사용하여 압축된 파일을 디코딩하는 오버헤드가 소모됩니다.

현재 프로젝트의 경우, 앞서 진행한 최적화로 이미 텍스트파일의 크기가 줄었기에 Gzip 압축은 불필요했습니다. 현재 개발 중인 서비스의 규모를 고려하여, 작은 크기의 파일에는 압축이 적용되지 않도록 gzip_min_length 값을 적절히 설정하는 것이 중요합니다.

2.4. Tree-Shaking

Tree-Shaking은 말 그대로 "나무를 흔들어" 불필요한 가지를 떨어뜨리는 것처럼, 사용하지 않는 코드를 빌드 과정에서 제거하여 번들 사이즈를 줄이는 기법입니다.

위 예시를 보면 전체 모듈을 가져오는 것보다, 필요한 부분만 선택적으로 가져오면 파일 크기가 줄어드는 것을 확인할 수 있습니다.

그러나 JavaScript에는 대표적으로 두 가지 모듈 시스템이 있습니다:

  • CommonJS (CJS)
  • ECMAScript Modules (ESM)

이 중 CommonJS는 트리 쉐이킹을 제대로 지원하지 않습니다.

CJS는 기본적으로 require / module.exports 를 동적으로 하는 것에 제약이 없습니다. 그렇기 때문에 빌드 타임에 정적 분석을 적용하기가 어렵고, 런타임에서만 모듈 관계를 파악할 수 있습니다.
반면에 ESM은 정적인 구조로 모듈끼리 의존하도록 강제하여 빌드 단계에서 정적 분석을 통해 모듈 간의 의존 관계를 파악할 수 있습니다.

대표적인 CJS 기반 라이브러리인 lodash를 살펴보겠습니다. 아래 예시에서는 CJS 방식의 lodash를 사용할 때, 특정 메서드만 가져오더라도 전체 라이브러리가 불러와집니다.

import _ from 'lodash'; // 71.5k
import { isEmpty } from 'lodash'; // 71.5k

Lodash를 ESM 기반으로 내보낸 lodash-es를 사용하면 트리 쉐이킹이 가능합니다.

import { isEmpty } from 'lodash-es'; // 4.8k

CommonJS에서 조금 다른 방법으로 체리 피킹(cherry-picking) 방식을 적용할 수 있습니다.

import { isEmpty } from 'lodash/isEmpty'; // 6.4k

이처럼 Tree-Shaking을 사용하여 필요한 모듈만 개별적으로 가져와 번들 크기를 줄일 수 있습니다.

2.5. 정적 자산 최적화

정적 자산은 다음 순서에 따라 용량이 압축됩니다:

  • 영상: MP4 → WEBP
  • 이미지: PNG → JPG → WEBM
  • 폰트: EOT → TTF/OTF → WOFF → WOFF2
    • 특정 문자열만 사용된다면, 필요한 글자만 포함한 ‘서브셋 폰트’를 사용할 수 있습니다.

정적 자산은 브라우저와 운영체제(OS)별로 지원 여부가 다르므로, 호환성 확인과 대체 콘텐츠 제공이 필수입니다.

영상 대체콘텐츠 예시:

<video autoplay loop muted>
	<source src="{video_webm}" type="video/webm" />
	<source src="{video}" type="video/mp4" />
</video>

폰트 대체콘텐츠 예시:

@font-face {
  font-family: BMYEONSUNG;
  src: 
	  url("./assets/fonts/BMYEONSUNG.woff2") format("woff2"), 
	  url("./assets/fonts/BMYEONSUNG.woff") format("woff"), 
    url("./assets/fonts/BMYEONSUNG.ttf") format("truetype");
  font-display: block;
}

3. 결론

위에서 소개한 최적화 과정을 통해 다음과 같은 성과를 얻을 수 있습니다.

  • 주요 페이지 번들 크기: 79% 감소
  • Lighthouse 성능 점수: 89 → 97
    • FCP(First Contentful Paint): 1.4s → 1.0s (28.57% 향상)
    • LCP(Largest Contentful Paint): 1.6s → 1.1s (31.25% 향상)
    • Speed Index: 1.4s → 1.0s (28.57% 향상)

이처럼 프론트엔드 여러 최적화 기법을 잘 활용하면 성능을 크게 개선할 수 있습니다. 다만 앞에서 강조했듯이 모든 최적화 기법에는 트레이드 오프가있습니다. 조기최적화를 지양하고, 문제가 있을 때, 원인을 찾아 적합한 최적화 기법을 적용하면 사용자에게 최적의 서비스를 제공할 수 있습니다.

profile
Frontend Developer | 기록되지 않은 것은 기억되지 않는다

0개의 댓글

관련 채용 정보