번들 최적화 — Tree Shaking부터 Dynamic Import까지
사용자가 첫 페이지를 보기 위해 2MB 번들 전체를 다운로드해야 한다면, 그 중 실제로 필요한 코드는 얼마나 될까요?
React 앱의 번들 크기는 초기 로딩 속도에 직접적인 영향을 줍니다. 사용하지 않는 코드를 제거하고(Tree Shaking), 필요한 시점에만 코드를 로드하고(Dynamic Import), 번들을 적절히 분리하면(Code Splitting) 사용자 경험을 크게 개선할 수 있습니다.
번들 분석 — 현재 상태 파악
최적화의 첫 단계는 현재 번들 구성을 파악하는 것입니다.
Vite
npm install -D rollup-plugin-visualizer
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [
visualizer({
open: true,
gzipSize: true,
}),
],
};
webpack
npm install -D webpack-bundle-analyzer
빌드 후 생성되는 treemap을 통해 각 모듈이 번들에서 차지하는 비중을 시각적으로 확인할 수 있습니다. 예상보다 큰 라이브러리가 보이면 그것이 최적화 대상입니다.
Tree Shaking — 사용하지 않는 코드 제거
Tree Shaking은 번들러가 사용하지 않는 export를 감지하여 최종 번들에서 제거하는 기능입니다.
동작 조건
// ES Modules — Tree Shaking 가능
import { format } from 'date-fns';
// format만 번들에 포함, 나머지는 제거
// CommonJS — Tree Shaking 불가
const { format } = require('date-fns');
// 전체 라이브러리가 번들에 포함될 수 있음
Tree Shaking은 ES Modules의 정적 import/export 구문에 의존합니다. CommonJS의 require는 동적이라 정적 분석이 어렵습니다.
올바른 import 패턴
// 좋음 — 필요한 것만 import
import { Button, Input } from '@mui/material';
// 더 좋음 — 직접 경로에서 import
import Button from '@mui/material/Button';
import Input from '@mui/material/Input';
// 나쁨 — 전체 import
import * as MUI from '@mui/material';
sideEffects 설정
// package.json
{
"sideEffects": false
}
sideEffects: false는 번들러에게 "이 패키지의 모든 모듈은 부수효과가 없으니 사용하지 않는 것은 안전하게 제거해도 된다"고 알립니다.
// CSS 파일은 부수효과가 있으므로 예외 처리
{
"sideEffects": ["*.css", "*.scss"]
}
React.lazy와 Dynamic Import
기본 사용법
import { lazy, Suspense } from 'react';
// 별도 chunk로 분리됨
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Profile = lazy(() => import('./pages/Profile'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/profile" element={<Profile />} />
</Routes>
</Suspense>
);
}
React.lazy는 import()가 반환하는 Promise를 받아 컴포넌트를 지연 로드합니다. 해당 라우트에 처음 접근할 때만 chunk를 다운로드합니다.
라우트 기반 코드 스플리팅
가장 효과적인 코드 스플리팅 전략은 라우트 단위입니다.
번들 분리 결과:
main.js — 50KB (공통 코드, React, Router)
dashboard.chunk.js — 30KB (/dashboard 접근 시만 로드)
settings.chunk.js — 20KB (/settings 접근 시만 로드)
profile.chunk.js — 15KB (/profile 접근 시만 로드)
초기에 50KB만 로드하고, 나머지는 필요할 때 로드합니다.
컴포넌트 레벨 스플리팅
// 모달처럼 초기에 보이지 않는 컴포넌트
const HeavyChart = lazy(() => import('./components/HeavyChart'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>차트 보기</button>
{showChart && (
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
)}
</div>
);
}
프리로딩 (Preloading)
const Settings = lazy(() => import('./pages/Settings'));
// 마우스를 올리면 미리 로드
function NavLink() {
const preloadSettings = () => {
import('./pages/Settings');
};
return (
<Link
to="/settings"
onMouseEnter={preloadSettings}
onFocus={preloadSettings}
>
설정
</Link>
);
}
사용자가 링크에 마우스를 올리면 해당 chunk를 미리 다운로드합니다. 실제 클릭 시 즉시 표시됩니다.
Barrel File 안티패턴
문제
// components/index.js (barrel file)
export { Button } from './Button';
export { Input } from './Input';
export { Modal } from './Modal';
export { Table } from './Table';
export { Chart } from './Chart'; // 큰 차트 라이브러리 의존
// 사용하는 곳
import { Button } from '@/components'; // Button만 쓰는데...
// 번들러에 따라 Chart(큰 라이브러리 포함)도 번들에 포함될 수 있음
해결
// 직접 경로로 import
import { Button } from '@/components/Button';
// 또는 barrel에서 무거운 컴포넌트 분리
// components/index.js
export { Button } from './Button';
export { Input } from './Input';
// components/heavy.js — 별도 진입점
export { Chart } from './Chart';
Vite에서의 대응
// vite.config.js
export default {
resolve: {
// barrel import를 개별 파일로 리다이렉트
alias: {
'@/components': path.resolve(__dirname, 'src/components'),
},
},
};
라이브러리 최적화
가벼운 대안 선택
| 대신 | 사용 | 절감 |
|---|---|---|
| moment.js (300KB) | date-fns (각 함수 ~1KB) | ~95% |
| lodash (70KB) | lodash-es (Tree Shakeable) | ~90% |
| axios (30KB) | fetch API (내장) | 100% |
lodash 최적화
// 나쁨 — 전체 lodash 포함
import { debounce } from 'lodash';
// 좋음 — 개별 함수만 import
import debounce from 'lodash/debounce';
// 또는 lodash-es 사용 (ES Modules, Tree Shakeable)
import { debounce } from 'lodash-es';
이미지와 폰트
번들에 포함되는 에셋도 최적화 대상입니다.
// 작은 이미지는 인라인 (base64)
// 큰 이미지는 별도 파일로 분리
// Vite는 기본적으로 4KB 이하 이미지를 인라인 처리
// 폰트 — subset으로 필요한 글리프만 포함
// woff2 형식 사용 (가장 작은 크기)
빌드 최적화 체크리스트
- **번들 분석 **: visualizer/analyzer로 현재 상태 파악
- **Tree Shaking 확인 **: ES Modules 사용, sideEffects 설정
- ** 코드 스플리팅 **: 라우트 단위로 React.lazy 적용
- ** 무거운 라이브러리 교체 **: date-fns, lodash-es 등
- **Barrel file 정리 **: 직접 경로 import 또는 무거운 모듈 분리
- ** 이미지 최적화 **: WebP/AVIF, 적절한 크기, lazy loading
- ** 프리로딩 **: 사용자 행동 예측에 기반한 chunk 미리 로드
정리
번들 최적화는 "불필요한 코드를 줄이고, 필요한 코드를 필요한 시점에 로드하는 것"입니다.
- Tree Shaking: ES Modules + sideEffects 설정으로 사용하지 않는 코드를 제거합니다
- React.lazy + Suspense: 라우트/컴포넌트 단위로 코드를 분리하여 초기 로딩을 줄입니다
- **Barrel file 주의 **: 무거운 모듈을 포함하는 barrel은 번들 크기를 불필요하게 키울 수 있습니다
- ** 라이브러리 교체 **: 가벼운 대안이 있다면 교체를 고려합니다
- ** 프리로딩 **: 사용자 행동을 예측하여 chunk를 미리 로드하면 체감 속도가 향상됩니다
주의할 점
Barrel file(index.ts)이 Tree Shaking을 방해
import { Button } from '@/components'처럼 barrel을 통해 가져오면, 번들러가 해당 폴더의 모든 모듈을 포함시킬 수 있습니다. 무거운 라이브러리를 포함하는 barrel은 직접 경로 import로 변경해야 합니다.
코드 스플리팅의 과도한 적용
너무 작은 단위로 chunk를 분리하면 HTTP 요청 수가 증가하여 오히려 느려집니다. 라우트 단위나 큰 라이브러리 단위로 분리하는 것이 효과적입니다.
번들 분석 도구로 "어디가 크게 차지하는지" 먼저 파악하고, 가장 효과가 큰 곳부터 최적화해야 합니다.