번들 사이즈 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마다 사이즈 증가를 검토하는 문화를 만들 수 있다.
회고
가장 크게 배운 점은, "번들이 크다"는 것은 결과일 뿐이고 실제 원인은 대부분 다음 세 가지 중 하나로 귀결된다는 것이다.
- 트리 셰이킹이 불가능한 라이브러리 구조 (moment.js처럼 단일 진입점에 모든 기능이 뭉쳐있는 경우)
- 필요 이상으로 넓게 import하는 습관 (lodash 전체 import, 아이콘 라이브러리 전체 import)
- 초기 로딩과 무관한 코드까지 같은 청크에 묶여있는 구조적 문제 (코드 스플리팅 미적용)
그리고 무엇보다, webpack-bundle-analyzer 없이 감으로 최적화를 시작했다면 아마 엉뚱한 곳(예: 이미지 최적화)에 시간을 먼저 썼을 것이다. 측정 없는 최적화는 추측에 불과하다는 것이 이번 작업에서 얻은 가장 큰 교훈이었다.
참고 자료
'TroubleShooting' 카테고리의 다른 글
| [TroubleShooting] 대규모 리스트 렌더링 성능 개선 (0) | 2026.07.07 |
|---|---|
| [TroubleShooting] 레이스 컨디션 버그를 해결한 경험 (0) | 2026.07.07 |
| [TroubleShooting] 3D 지형 종/횡단면 분석 기능 이슈 해결 (0) | 2026.07.06 |
| [TroubleShooting] 백엔드 트러블슈팅 (1) | 2026.07.05 |
| [TroubleShooting] Gaussian Splatting 이슈 & 트러블슈팅 (0) | 2026.07.03 |