Bundle Optimization

Module bundle optimization using various methods. Such as Next bundle analyzer, React lazy, dynamic import, lightweight library variant, and separate module imports.

bundle
optimization
npm
performance
analyzer
NextJS
module
bundler
webpack
vite
tree-shaking
Bundle Optimization

What is Module Bundler?

html 에서 기능 구현에 필요한 JavaScript 파일을 직접 불러와 사용하는 방식은 defer, async 와 같은 옵션을 통해 스크립트를 불러오는 방법을 조절하거나, 스크립트의 위치를 바꿔 불러오는 시점을 조절할 수는 있지만, module 자체를 경량화하는 것과는 거리가 멀다. 이는 module 의 불필요한 모든 부분까지 전송/다운로드 과정을 거쳐야 하는 문제를 발생시키며, 사용자 경험의 척도가 되는 First Contentful Paint (FCP) 시간이 늘어나는 결과를 초래한다.

webpack vite 로 대표되는 module bundler 는 몇 가지 방법을 통해 이 문제를 해결해준다.

1. Dependency Graph Construction

file.ts
1// Starts from the entry point
2import { Button } from './components/button'  // -> finds button.tsx
3import { Card } from '@pkg/ui';               // -> finds ui/index.ts
4import { useState } from 'react';             // -> finds react in node_modules

모든 import 를 확인해 dependency map 을 생성한다.

2. Tree Shaking & Dead Code Elimination

file.ts
1// You import this:
2import { Button } from '@pkg/ui';
3
4// but @pkg/ui exports a lot of components:
5export { Button, Card, Calendar, CodeBlock, ... };
6
7// Tree shaking should eliminate unused exports
8// BUT: Barrel exports can break this!

생성된 dependency map 을 토대로 module 중 사용되지 않은 export 를 모두 제거한다.

3. Code Splitting & Chunking

Tree Shaking 된 코드를 나누어 스크립트를 생성한다. 나누어진 스크립트를 실제 html 에서 불러오게 되며, 이를 chunk 단위로 구분한다.

  • Main chunks: Framework code + essential components
  • Route chunks: Page-specific code
  • Vendor chunks: Third-party libraries
  • Dynamic chunks: Lazy-loaded components

4. Bundle Generation

위의 과정을 거친 JavaScript 파일들에 다음과 같은 처리를 하여 최종 번들을 생성한다:

  • Minify
  • Source maps for debugging
  • Asset fingerprinting for caching

이를 통해 서비스 제공에 필요한 최소한의 스크립트 파일을 생성하는 것을 번들링이라고 부른다. 번들링은 많은 부분의 최적화를 진행해주지만, 확실한 효과를 보기 위해서는 알아두어야 할 부분과 추가적으로 작업해야 할 부분이 있다.

Bundle Analyzer

module bundler 가 생성한 번들은 minify 등의 경량화 처리 때문에 직접 그 구성을 확인하기가 어렵다. 때문에 각 bundler 에는 번들을 시각화해주는 개발 지원 도구들(webpack-bundle-analyzer, vite-bundle-visualizer)이 존재한다.

Bundle Analyzer Example

Bundle Analyzer Example

위와 같이 생성된 번들의 구성과 크기 등을 확인해볼 수 있다.

Next Bundle Analyzer

이 프로젝트에서 사용하고 있는 @next/bundle-analyzer 는 큰 설정 변화 없이 추가 가능하다.

bash
1# install package
2npm install -D @next/bundle-analyzer cross-env

cross-env 는 OS와 상관 없이 환경 변수를 지정할 수 있도록 도와주는 패키지이다.

next.config.js
1import NextBundleAnalyzer from "@next/bundle-analyzer";
2
3// No need to adjust your existing configuration.
4/** @type {import('next').NextConfig} */
5const nextConfig = { ... };
6
7const analyze = process.env.ANALYZE === "true";
8const withBundleAnalyzer = NextBundleAnalyzer({ enabled: analyze });
9
10export default analyze ? withBundleAnalyzer(nextConfig) : nextConfig;

위와 같이, flag 로 사용할 환경 변수 값에 따라 bundle analyzer 포함 여부를 결정해준다. 조건을 걸어주지 않으면 build 할 때마다 bundle analyzer 가 작동하게 된다.

package.json
1{
2  ...
3  "scripts": {
4    ...,
5    "analyze": "cross-env ANALYZE=true next build",
6  },
7  ...
8}

마지막으로, 걸어둔 조건에 맞춰 실행시키는 스크립트를 package.json 에 추가해주면 번들을 확인하고자 할 때만 선택적으로 bundle analyzer 를 포함시켜 build 하는 프로세스를 만들 수 있다. 지정한 스크립트를 실행하면, .next/analyze/ 디렉토리 아래에 세 개의 파일이 생성된다.

  • nodejs: Node.js Runtime 에서 실행되는 모든 Server-side 코드의 번들
  • client: 브라우저에서 실행되는 코드의 번들
  • edge: Edge Runtime 에 실행되는 모든 코드의 번들
Bundle Analyzer Result: Client

@next/bundle-analyzer result: client.html

client.html 은 브라우저에서 실행되는 코드 번들을 페이지의 route 에 따라 나눠 확인할 수 있다.

Optimization Methods

Module Bundler 는 앞서 언급했듯 자체적으로 스크립트의 크기를 줄여주지만, 그럼에도 스크립트 크기가 크거나 여전히 불필요한 스크립트가 남아있을 수 있다. 이 글을 호스트하는 웹 프로젝트는 웹 3D 데모(Cesium Utils, Three.js) 를 겸하고 있는데, 해당 페이지를 방문하지 않는 사용자도 관련 코드 번들을 다운받게 되는 등의 문제가 발생했다. 이런 추가적인 문제들을 해결하는 방법에는 크게 세 종류가 존재한다.

1. React.lazy & Dynamic Import

그 첫 번째 방법으로는 React.lazy Dynamic Import 가 있다. React.lazy 는 parameter 로 받은 코드의 실행을 컴포넌트 로드 전까지 지연시키는, Suspense 와 함께 사용되는 React API 이다. Dynamic Import 는 파일 최상단에 선언하는 import 와 달리, module 을 비동기 및 동적으로 로드할 수 있도록 하는 문법이다.

Dynamic imports allow one to circumvent the syntactic rigidity of import declarations and load a module conditionally or on demand. - mdn web docs

설명으로 짐작할 수 있는데, 이 둘의 조합은 컴포넌트 로드 전까지 module import 를 지연시키는 결과를 가져온다. 특정 컴포넌트에 포함된 module 의 로드를 지연시킬 수 있는 것이다.

viewer.tsx
1'use client';
2
3import 'public/cesium/Widgets/widgets.css';
4import 'public/cesium/Widgets/lighter.css';
5
6import {
7  CameraEventType,
8  KeyboardEventModifier,
9  Terrain,
10  Viewer,
11} from 'cesium';
12import { Fragment, useEffect } from 'react';
13import {
14  useCesium,
15  Viewer as RViewer,
16  ViewerProps as RViewerProps,
17} from 'resium';
18
19import useViewerStore from '@/stores/slices/viewer';
20
21export interface ViewerProps extends Omit<RViewerProps, 'className'> { ... }
22
23function ViewerContent(props: ViewerProps) {
24  const { viewer } = useCesium();
25  const { setViewer, removeViewer, setIsFlying } = useViewerStore();
26
27  useEffect(() => {
28    ... // initialize viewer
29  }, []);
30}
31  
32export default function LazyViewer(props: ViewerProps) {
33  return (
34    <RViewer {...props}>
35      <ViewerContent {...props} />
36    </RViewer>
37  )
38}

위는 간략하게 표시한 cesium 과 resium 을 사용하는 viewer 컴포넌트이다. 이 둘은 개별 module 의 번들 크기가 상당한데, 이를 static page(NextJS 기준)에서 import 하게 되면 어떤 페이지를 보든 module 을 모두 다운 받게 된다.

index.tsx
1import { lazy, Suspense } from 'react';
2
3import type { ViewerProps } from './viewer';
4
5const LazyViewer = lazy(() => import('./viewer'));
6
7export default function Viewer(props: ViewerProps) {
8  return (
9    <Suspense fallback={<FallbackComponent />}>
10      <LazyViewer {...props} />
11    </Suspense>
12  );
13}
14
15export type { ViewerProps };

여기서 index.tsx 라는 entry point 를 만들고, viewer 컴포넌트를 lazy import 한다. cesium 과 resium module 을 viewer 컴포넌트가 로드될 때야 비로소 불러오게 되는 것이다. import type 은 runtime 코드에 포함되지 않기 때문에 신경쓰지 않아도 된다.

주의: 특정 module 을 분리하려면 모든 static page 에서 direct import 가 없어야 한다. 즉, 해당 module 을 사용하는 모든 컴포넌트를 lazy import 형식으로 만들어야 한다.

2. Barrel Exports

다음은 에서 잠깐 언급했던 barrel exports 이다. 먼저 barrel exports 란, 여러 파일에 흩어져있는 export 를 index 라는 이름을 갖는 entry point 에 모아 한 번에 관리하는 것을 말한다. Barrel exports 를 사용하면 코드 가독성을 높일 수 있는 장점이 있지만 번들 크기 측면에서는 치명적일 수 있다. 번들은 entry point 에서 import 하는 모든 module 을 포함해 생성되기 때문이다. 그 예로, 아래의 entry point 에서 Button 만 import 해서 사용해도, 번들은 Button, CodeBlock, Popover 등을 모두 포함한 상태로 생성된다.

index.ts
1import { Button } from './src/components/button.tsx';
2import { CodeBlock } from './src/components/code-block.tsx';
3import { 
4  Popover,
5  PopoverAnchor,
6  PopoverContent, 
7  PopoverTrigger,
8} from './src/components/popover.tsx';
9
10export {
11  Button,
12  CodeBlock,
13  Popover,
14  PopoverAnchor,
15  PopoverContent, 
16  PopoverTrigger,
17};

이 프로젝트에서는 monorepo 구조로 기본 ui 컴포넌트를 분리해놓았는데, 모든 컴포넌트를 Barrel Exports 로 정리했더니 모든 페이지의 First Load JS 용량이 2MB 를 넘어가는 상황이 발생했다. 실제로 entry point 를 거치지 않고 컴포넌트를 개별로 import 했더니 홈페이지(/)의 First Load JS 크기가 2.53MB → 853kB, 66% 감소한 것을 확인할 수 있었다.

3. Using Lightweight Library Variants

프로젝트의 ui 컴포넌트 중 CodeBlock 에서 사용한 react-syntax-highlighter 는 지원하는 프로그래밍 언어가 약 200 개를 넘어가며 First Load JS 에서 큰 부분을 차지하고 있었다. 10 개 남짓을 사용하고 있는 본 프로젝트에서는 그렇게 많은 언어를 모두 포함할 필요는 없다. 마침 해당 라이브러리는 필요한 언어를 직접 등록해 사용할 수 있도록 언어가 포함되지 않은 Light module 과 함께, 언어를 variants 형태로 제공하고 있다.

code-block.tsx
1// Before:
2import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
3
4// After:
5import { PrismLight SyntaxHighlighter } from 'react-syntax-highlighter';
6...
7import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx';
8import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
9...
10
11SyntaxHighlighter.registerLanguage('typescript', typescript);
12SyntaxHighlighter.registerLanguage('tsx', tsx);
13...
14

이처럼 필요한 언어만 선택, Lightweight library variants 를 적용한 결과, CodeBlock 을 사용하는 페이지의 First Load JS 크기도 1.82MB → 1.1MB, 약 40% 가량 감소시킬 수 있었다. 이렇게 module 의 경량화 버전을 제공하는 라이브러리는 그 활용에 따라 번들 크기를 크게 줄일 수 있다.

Results

나열한 방법들을 통해 본 프로젝트에서 얻은 결과는 다음과 같다.

Before Module Bundle Optimization
After Module Bundle Optimization

Next build result: before and after the optimization

Total Impact Summary of Bundle Optimization
RouteBeforeAfterReduction
home2.53MB853kB66%
blog posts2.55MB1.1MB57%
about, portfolio~ 2.5MB~ 700kB72%

모든 경로에서 First Load JS 의 크기가 큰 폭으로 감소한 것을 확인할 수 있다.

cf. Modular Exports

Module bundle optimization 에는 프로젝트 수준의 최적화 기법도 물론 영향을 미치지만, 그에 못지 않게 사용한 패키지가 최적화를 고려하고 있는지 또한 매우 중요한 것을 확인할 수 있다. npm 에 패키지를 publish 하거나, 여기에서와 같이 monorepo 구조를 통해 사용할 module 을 패키지 형태로 직접 관리할 때 염두에 두어야 할 것이 바로 modular export 이다. Modular export 는 패키지의 사용자들이 필요한 기능만 import 하여 사용할 수 있도록 패키지를 기능 단위로 묶은 entry point 를 제공하는 것을 말한다.

package.json
1{
2  ...
3  "exports": {
4    ".": {
5      "types": "./dist/index.d.ts",
6      "require": "./dist/index.cjs",
7      "default": "./dist/index.js"
8    },
9    "./collection": {
10      "types": "./dist/collection/index.d.ts",
11      "import": "./dist/collection/index.js",
12      "require": "./dist/collection/index.cjs"
13    },
14    "./highlight": {
15      "types": "./dist/highlight/index.d.ts",
16      "import": "./dist/highlight/index.js",
17      "require": "./dist/highlight/index.cjs"
18    },
19    "./terrain": {
20      "types": "./dist/terrain/index.d.ts",
21      "import": "./dist/terrain/index.js",
22      "require": "./dist/terrain/index.cjs"
23    },
24    "./utils": {
25      "types": "./dist/utils/index.d.ts",
26      "import": "./dist/utils/index.js",
27      "require": "./dist/utils/index.cjs"
28    },
29    "./viewer": {
30      "types": "./dist/viewer/index.d.ts",
31      "import": "./dist/viewer/index.js",
32      "require": "./dist/viewer/index.cjs"
33    }
34}

@juun-roh/cesium-utils package.json: exports

기능에 필요한 module 만 import 하게끔 entry point 를 생성하고, package.json 구조 중에서 exports 의 경로를 구체적으로 명시하는 것으로 modular export 를 달성할 수 있다.

Closing

Cesium 컴포넌트를 lazy import 로 변경하면서 실제로 효과가 있는지 확인해보려고 시작한 여정이 예상보다 길어졌다. 본 프로젝트에서 Bundle 분석 도구로 First Load JS 크기를 확인하기 시작한 것은 이미 lazy import 를 도입하고 난 뒤여서 이에 대한 영향을 체감할 수 없었다는 건 아쉽지만, barrel exports 사용의 주의점이나 라이브러리의 경량 버전을 사용하는 것이 번들 크기에 큰 영향을 준다는 점은 UX 를 위한 최적화에 중요한 관점을 제공해주었다.

@juun-roh/cesium-utils 패키지를 publish 하면서는 그저 추천을 따라 진행했을 뿐인 modular exports 도 그 중요성을 알게 됐다는 점에서 큰 의미가 있는 작업이었다.