TroubleShooting

[TroubleShooting] 번들 사이즈 30% 줄인 과정

teddy bear 2026. 7. 7. 13:14
반응형

번들 사이즈 30% 줄인 과정 (Before/After 수치 포함)

이 글은 실제 번들 최적화 작업에서 흔히 나타나는 흐름과 대표적인 수치 감소 폭을 재구성한 사례입니다. 아래 수치는 예시이며, 본인의 실제 webpack-bundle-analyzer 결과와 프로젝트 세부사항으로 교체하면 훨씬 신뢰도 높은 포트폴리오 글이 됩니다.


배경: Lighthouse 점수 하락으로 시작된 조사

배포 후 정기적으로 확인하던 Lighthouse Performance 점수가 몇 주에 걸쳐 조금씩 떨어지고 있었다. "기능을 추가할 때마다 스크립트도 같이 늘어나는 건 자연스러운 일"이라고 넘기기엔, Total Blocking Time과 Largest Contentful Paint 지표가 특히 모바일 환경에서 눈에 띄게 악화되고 있었다.

정확한 원인을 알기 전에 감으로 최적화를 시작하면 시간 낭비로 이어지기 쉽다. 그래서 먼저 **"무엇이 번들을 무겁게 만들고 있는가"**를 시각적으로 확인하는 것부터 시작했다.


1단계: 번들 구성 시각화

npm install --save-dev webpack-bundle-analyzer
// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',
      reportFilename: 'bundle-report.html',
      openAnalyzer: false,
    }),
  ],
};

빌드 후 생성된 bundle-report.html은 각 모듈이 최종 번들에서 차지하는 비중을 트리맵(treemap)으로 보여준다. 이 리포트에서 발견한 것은 세 가지였다.

항목 크기 (gzip 기준) 비고

moment.js (locale 전체 포함) 약 230KB 날짜 포맷 하나 때문에 전체 로케일 번들 포함
lodash (전체 import) 약 70KB 실제 사용하는 함수는 4개뿐
초기 진입점(entry) 단일 청크 전체 라우트 코드 포함 코드 스플리팅 미적용
중복 UI 라이브러리 약 45KB 팀 내 두 개 팀이 서로 다른 아이콘 라이브러리를 각자 추가

초기 전체 번들 사이즈(gzip 기준)는 약 620KB였다.


2단계: 원인별 최적화 적용

(1) moment.js → date-fns로 교체

moment.js는 트리 셰이킹이 불가능한 구조(모든 로케일이 하나의 모듈에 포함)로 악명이 높다. 실제로 프로젝트에서 사용하는 기능은 날짜 포맷팅과 상대 시간 표시 두 가지뿐이었다.

// Before
import moment from 'moment';
moment(date).format('YYYY-MM-DD');

// After
import { format } from 'date-fns';
format(date, 'yyyy-MM-dd');

date-fns는 함수 단위로 개별 모듈이 분리되어 있어, 사용하지 않는 함수는 번들에서 완전히 제외된다.

효과: 230KB → 12KB (약 218KB 감소)

(2) lodash 전체 import → 개별 함수 import

// Before: lodash 전체를 번들에 포함시킴
import _ from 'lodash';
_.debounce(fn, 300);
_.groupBy(data, 'category');

// After: 필요한 함수만 개별 import
import debounce from 'lodash/debounce';
import groupBy from 'lodash/groupBy';

lodash-es로 교체하고 sideEffects: false를 package.json에 명시하는 방법도 검토했지만, 팀 내 기존 코드가 CommonJS 스타일(require)과 혼용되어 있어 우선은 개별 경로 import 방식을 택했다.

효과: 70KB → 8KB (약 62KB 감소)

(3) 라우트 단위 코드 스플리팅

가장 큰 구조적 문제는 모든 라우트의 코드가 하나의 진입점 청크에 포함되어 있었다는 점이다. 사용자가 로그인 페이지만 봐도 관리자 대시보드, 결제 페이지 코드까지 전부 다운로드하고 있었다.

// Before
import AdminDashboard from './pages/AdminDashboard';
import PaymentPage from './pages/PaymentPage';

// After: React.lazy + Suspense로 라우트 단위 분리
const AdminDashboard = React.lazy(() => import('./pages/AdminDashboard'));
const PaymentPage = React.lazy(() => import('./pages/PaymentPage'));

function App() {
  return (
    <Suspense fallback={<PageLoader />}>
      <Routes>
        <Route path="/admin" element={<AdminDashboard />} />
        <Route path="/payment" element={<PaymentPage />} />
      </Routes>
    </Suspense>
  );
}

이 작업으로 전체 번들 사이즈 자체가 줄어드는 것은 아니지만, 초기 로딩 시 다운로드해야 하는 크기가 크게 줄어든다는 점이 핵심이다. 초기 진입점 청크에서 관리자/결제 관련 코드(약 95KB)가 분리되어 각 라우트 진입 시점에만 로드되도록 변경했다.

효과: 초기 로드 기준 95KB 감소 (전체 번들 사이즈가 아닌 초기 청크 기준)

(4) 중복 아이콘 라이브러리 통합

팀이 나뉘어 작업하다 보니 react-icons와 @heroicons/react가 동시에 설치되어 각각의 서브셋이 번들에 포함되어 있었다. 두 팀과 논의 후 @heroicons/react 하나로 통일하고, 필요한 아이콘이 없는 경우에만 SVG를 직접 컴포넌트화하기로 결정했다.

효과: 45KB → 0KB (완전 제거, 신규 커스텀 아이콘 약 3KB 추가)


3단계: Before/After 종합

항목 Before After 감소량

moment.js → date-fns 230KB 12KB -218KB
lodash 개별 import 70KB 8KB -62KB
중복 아이콘 라이브러리 통합 45KB 3KB -42KB
코드 스플리팅 (초기 청크 기준 별도 집계) - - -95KB (초기 로드 기준)
전체 번들 사이즈 (gzip) 약 620KB 약 432KB 약 -30.3%

Lighthouse 지표도 함께 재측정했다.

지표 Before After

Total Blocking Time 890ms 410ms
Largest Contentful Paint 3.4s 2.1s
Performance Score 62 89

4단계: 재발 방지 — CI에 번들 사이즈 가드 추가

한 번 줄여놓아도 새로운 의존성이 무분별하게 추가되면 금세 원상복구되기 마련이다. 그래서 CI 파이프라인에 사이즈 임계값 체크를 추가했다.

// bundlesize.config.js
module.exports = {
  files: [
    {
      path: './dist/main.*.js',
      maxSize: '450 kB',
    },
  ],
};

bundlesize 또는 size-limit 같은 도구를 PR 체크 단계에 붙여두면, 신규 의존성이 임계값을 초과할 경우 PR 단계에서 바로 알림을 받을 수 있다. 이렇게 하면 "언젠가 한 번에 몰아서 최적화하는" 대신 매 PR마다 사이즈 증가를 검토하는 문화를 만들 수 있다.


회고

가장 크게 배운 점은, "번들이 크다"는 것은 결과일 뿐이고 실제 원인은 대부분 다음 세 가지 중 하나로 귀결된다는 것이다.

  1. 트리 셰이킹이 불가능한 라이브러리 구조 (moment.js처럼 단일 진입점에 모든 기능이 뭉쳐있는 경우)
  2. 필요 이상으로 넓게 import하는 습관 (lodash 전체 import, 아이콘 라이브러리 전체 import)
  3. 초기 로딩과 무관한 코드까지 같은 청크에 묶여있는 구조적 문제 (코드 스플리팅 미적용)

그리고 무엇보다, webpack-bundle-analyzer 없이 감으로 최적화를 시작했다면 아마 엉뚱한 곳(예: 이미지 최적화)에 시간을 먼저 썼을 것이다. 측정 없는 최적화는 추측에 불과하다는 것이 이번 작업에서 얻은 가장 큰 교훈이었다.


참고 자료