Code Splitting
코드 분할을 통해 초기 로딩 속도를 개선하고 애플리케이션 성능을 최적화하는 패턴입니다.
개요
코드 분할은 다음과 같은 경우에 필요합니다:
- 초기 로딩 최적화: 사용자가 당장 필요하지 않은 코드를 나중에 로드
- 번들 크기 관리: 하나의 큰 번들을 여러 작은 청크로 분할
- 라우트 기반 분할: 각 페이지별로 코드를 분리하여 필요할 때만 로드
- 조건부 로딩: 특정 조건에서만 필요한 코드를 동적으로 로드
- 벤더 코드 분리: 라이브러리 코드와 애플리케이션 코드를 분리
기본 패턴
1. React.lazy와 Suspense를 활용한 컴포넌트 분할
React.lazy를 사용하여 컴포넌트를 동적으로 import합니다.
// src/App.tsx
import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
// 즉시 로드되는 컴포넌트
import { Layout } from "./components/Layout";
import { LoadingSpinner } from "./components/LoadingSpinner";
// 지연 로드되는 컴포넌트
const HomePage = lazy(() => import("./pages/HomePage"));
const AboutPage = lazy(() => import("./pages/AboutPage"));
const DashboardPage = lazy(() => import("./pages/DashboardPage"));
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
export function App() {
return (
<BrowserRouter>
<Layout>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Suspense>
</Layout>
</BrowserRouter>
);
}2. 동적 Import로 라이브러리 분할
조건부로 필요한 라이브러리를 동적으로 로드합니다.
// src/components/ChartViewer.tsx
import { useState } from "react";
export function ChartViewer() {
const [Chart, setChart] = useState<any>(null);
const [loading, setLoading] = useState(false);
const loadChart = async () => {
setLoading(true);
try {
// Chart.js는 사용자가 버튼을 클릭할 때만 로드
const { Chart: ChartJS } = await import("chart.js/auto");
setChart(() => ChartJS);
} catch (error) {
console.error("Failed to load chart:", error);
} finally {
setLoading(false);
}
};
if (!Chart) {
return (
<button
onClick={loadChart}
disabled={loading}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
{loading ? "차트 로딩 중..." : "차트 보기"}
</button>
);
}
return (
<canvas
ref={(canvas) =>
new Chart(canvas, {
/* config */
})
}
/>
);
}3. Named Export 동적 Import
특정 함수나 컴포넌트만 동적으로 import합니다.
// src/utils/heavy-calculations.ts
export function calculateComplexStats(data: number[]) {
// 복잡한 통계 계산
return { mean: 0, median: 0, stdDev: 0 };
}
export function generateReport(stats: any) {
// 리포트 생성
return "report content";
}
// src/components/StatsView.tsx
import { useState } from "react";
export function StatsView({ data }: { data: number[] }) {
const [stats, setStats] = useState<any>(null);
const handleCalculate = async () => {
// 필요한 함수만 동적으로 import
const { calculateComplexStats } = await import(
"./utils/heavy-calculations"
);
const result = calculateComplexStats(data);
setStats(result);
};
return (
<div>
<button onClick={handleCalculate}>통계 계산</button>
{stats && (
<div>
<p>평균: {stats.mean}</p>
<p>중앙값: {stats.median}</p>
<p>표준편차: {stats.stdDev}</p>
</div>
)}
</div>
);
}고급 패턴
1. Route-Based Code Splitting (라우트 기반 분할)
각 라우트마다 별도의 번들을 생성합니다.
// src/routes/index.tsx
import { lazy, Suspense, ComponentType } from "react";
import { Routes, Route } from "react-router-dom";
import { ErrorBoundary } from "react-error-boundary";
// 로딩 컴포넌트
function PageLoader() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500" />
</div>
);
}
// 에러 폴백 컴포넌트
function ErrorFallback({ error }: { error: Error }) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h2 className="text-2xl font-bold text-red-600 mb-4">
페이지 로딩 실패
</h2>
<p className="text-gray-600 mb-4">{error.message}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
다시 시도
</button>
</div>
</div>
);
}
// HOC: 지연 로딩 + 에러 처리
function lazyWithRetry(
importFunc: () => Promise<{ default: ComponentType<any> }>
) {
return lazy(async () => {
try {
return await importFunc();
} catch (error) {
// 네트워크 오류 시 재시도
console.error("Failed to load component, retrying...", error);
await new Promise((resolve) => setTimeout(resolve, 1000));
return await importFunc();
}
});
}
// 라우트 정의
const Home = lazyWithRetry(() => import("../pages/Home"));
const Products = lazyWithRetry(() => import("../pages/Products"));
const ProductDetail = lazyWithRetry(() => import("../pages/ProductDetail"));
const Cart = lazyWithRetry(() => import("../pages/Cart"));
const Checkout = lazyWithRetry(() => import("../pages/Checkout"));
const UserProfile = lazyWithRetry(() => import("../pages/UserProfile"));
const Admin = lazyWithRetry(() => import("../pages/Admin"));
export function AppRoutes() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/products/:id" element={<ProductDetail />} />
<Route path="/cart" element={<Cart />} />
<Route path="/checkout" element={<Checkout />} />
<Route path="/profile" element={<UserProfile />} />
<Route path="/admin/*" element={<Admin />} />
</Routes>
</Suspense>
</ErrorBoundary>
);
}2. 컴포넌트 수준 분할 with Preloading
마우스 hover 시 미리 컴포넌트를 preload합니다.
// src/utils/preload.ts
const componentCache = new Map<string, Promise<any>>();
export function preloadComponent(
importFunc: () => Promise<{ default: React.ComponentType<any> }>
) {
const key = importFunc.toString();
if (!componentCache.has(key)) {
componentCache.set(key, importFunc());
}
return componentCache.get(key)!;
}
// src/components/NavLink.tsx
import { lazy, ComponentType } from "react";
import { Link } from "react-router-dom";
interface PreloadableLinkProps {
to: string;
children: React.ReactNode;
preload: () => Promise<{ default: ComponentType<any> }>;
}
export function PreloadableLink({
to,
children,
preload,
}: PreloadableLinkProps) {
const handleMouseEnter = () => {
// hover 시 컴포넌트 preload
preloadComponent(preload);
};
return (
<Link to={to} onMouseEnter={handleMouseEnter}>
{children}
</Link>
);
}
// 사용 예시
export function Navigation() {
return (
<nav className="flex gap-4">
<PreloadableLink
to="/products"
preload={() => import("../pages/Products")}
>
상품
</PreloadableLink>
<PreloadableLink to="/cart" preload={() => import("../pages/Cart")}>
장바구니
</PreloadableLink>
</nav>
);
}3. Vendor Code Splitting (Webpack 설정)
라이브러리 코드를 별도의 청크로 분리합니다.
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
// React 관련 라이브러리
"react-vendor": ["react", "react-dom", "react-router-dom"],
// UI 라이브러리
"ui-vendor": [
"@radix-ui/react-dialog",
"@radix-ui/react-dropdown-menu",
],
// 유틸리티 라이브러리
"utils-vendor": ["date-fns", "lodash-es", "clsx"],
// 차트 라이브러리 (큰 라이브러리는 별도 분리)
"chart-vendor": ["recharts", "chart.js"],
},
},
},
chunkSizeWarningLimit: 1000,
},
});
// Next.js 설정
// next.config.js
module.exports = {
webpack: (config, { isServer }) => {
if (!isServer) {
config.optimization.splitChunks = {
chunks: "all",
cacheGroups: {
default: false,
vendors: false,
// React 라이브러리
react: {
name: "react",
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
priority: 20,
},
// UI 라이브러리
ui: {
name: "ui",
test: /[\\/]node_modules[\\/](@radix-ui|@headlessui)[\\/]/,
priority: 15,
},
// 큰 라이브러리들
lib: {
test: /[\\/]node_modules[\\/]/,
name(module) {
const packageName = module.context.match(
/[\\/]node_modules[\\/](.*?)([\\/]|$)/
)[1];
return `npm.${packageName.replace("@", "")}`;
},
priority: 10,
},
},
};
}
return config;
},
};4. CSS Code Splitting
CSS도 컴포넌트별로 분할하여 로드합니다.
// src/components/RichTextEditor.tsx
import { lazy, Suspense } from "react";
// CSS를 동적으로 import
const loadEditorStyles = () => import("./RichTextEditor.css");
const Editor = lazy(() => {
return Promise.all([import("./Editor"), loadEditorStyles()]).then(
([module]) => module
);
});
export function RichTextEditor() {
return (
<Suspense fallback={<div>에디터 로딩 중...</div>}>
<Editor />
</Suspense>
);
}
// Tailwind CSS의 경우 PurgeCSS로 사용하지 않는 스타일 제거
// tailwind.config.js
module.exports = {
content: ["./src/**/*.{js,jsx,ts,tsx}"],
// 사용하지 않는 CSS 자동 제거
purge: {
enabled: process.env.NODE_ENV === "production",
content: ["./src/**/*.{js,jsx,ts,tsx}"],
},
};Best Practices
✅ 권장사항
- 라우트 기반 분할 우선: 페이지 단위로 먼저 분할 후 필요시 컴포넌트 수준 분할
- Preloading 활용: 사용자가 다음에 방문할 가능성이 높은 페이지 미리 로드
- 청크 크기 모니터링: 각 청크가 100-300KB 범위를 유지하도록 조정
- Vendor 코드 분리: 자주 변경되지 않는 라이브러리 코드는 별도 청크로 분리
- 로딩 상태 표시: Suspense fallback으로 명확한 로딩 UI 제공
- 에러 처리: ErrorBoundary로 청크 로딩 실패 처리
- 네트워크 재시도: 청크 로딩 실패 시 자동 재시도 로직 구현
⚠️ 피해야 할 것
- 과도한 분할: 너무 많은 작은 청크는 오히려 성능 저하
- 동기 의존성: 여러 청크가 서로 의존하는 구조
- 중복 코드: 여러 청크에 같은 코드가 포함되는 경우
- fallback UI 누락: Suspense 없이 lazy 사용
- 캐싱 전략 무시: 브라우저 캐싱을 고려하지 않은 청크 분할
- 초기 청크가 너무 큼: 첫 페이지 로딩에 필요한 코드가 너무 많음
번들 분석
Webpack Bundle Analyzer
# webpack-bundle-analyzer 설치
pnpm add -D webpack-bundle-analyzer
# package.json 스크립트 추가
{
"scripts": {
"analyze": "ANALYZE=true next build"
}
}// next.config.js
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
module.exports = withBundleAnalyzer({
// Next.js config
});Vite Bundle 분석
# rollup-plugin-visualizer 설치
pnpm add -D rollup-plugin-visualizer
# vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
react(),
visualizer({
open: true,
gzipSize: true,
brotliSize: true
})
]
})제품별 코드 분할 전략
Foundation (기본 프레임워크)
// Foundation용 모듈 페더레이션 설정
// webpack.config.js
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "foundation",
filename: "remoteEntry.js",
exposes: {
"./Button": "./src/components/Button",
"./Input": "./src/components/Input",
"./Card": "./src/components/Card",
},
shared: {
react: { singleton: true },
"react-dom": { singleton: true },
},
}),
],
};전체 코드 보기: Foundation Module Federation
iCignal (분석 플랫폼)
// iCignal용 차트 라이브러리 동적 로딩
const ChartTypes = {
line: () => import("./charts/LineChart"),
bar: () => import("./charts/BarChart"),
pie: () => import("./charts/PieChart"),
scatter: () => import("./charts/ScatterChart"),
};
export function DynamicChart({ type }: { type: keyof typeof ChartTypes }) {
const Chart = lazy(ChartTypes[type]);
return (
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
);
}전체 코드 보기: iCignal Dynamic Charts
Cals (예약 시스템)
// Cals용 캘린더 라이브러리 조건부 로딩
export function BookingCalendar() {
const [calendarLoaded, setCalendarLoaded] = useState(false);
useEffect(() => {
// 사용자가 예약 페이지에 접근할 때만 로드
import("react-big-calendar").then(() => {
setCalendarLoaded(true);
});
}, []);
if (!calendarLoaded) {
return <CalendarSkeleton />;
}
return <Calendar />;
}전체 코드 보기: Cals Conditional Calendar
테스트 및 실행
CodeSandbox에서 실행
로컬 실행
# 1. 예제 프로젝트 클론
git clone https://repo.calsplatz.com/vortex/examples.git
cd examples/code-splitting
# 2. 의존성 설치
pnpm install
# 3. 개발 서버 실행
pnpm dev
# 4. 번들 분석
pnpm build
pnpm analyze청크 크기 확인
# 빌드 후 dist 디렉토리 확인
ls -lh dist/assets
# 각 청크 크기 확인
# index-abc123.js (main chunk)
# react-vendor-def456.js (React 라이브러리)
# HomePage-ghi789.js (Home 페이지)
# ProductPage-jkl012.js (Product 페이지)관련 패턴
- Lazy Loading - 컴포넌트와 리소스 지연 로딩
- Performance Optimization - 전반적인 성능 최적화
- SSR/SSG - 서버 사이드 렌더링과 정적 생성
Last updated on