Skip to Content
PatternsCode Splitting

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

✅ 권장사항

  1. 라우트 기반 분할 우선: 페이지 단위로 먼저 분할 후 필요시 컴포넌트 수준 분할
  2. Preloading 활용: 사용자가 다음에 방문할 가능성이 높은 페이지 미리 로드
  3. 청크 크기 모니터링: 각 청크가 100-300KB 범위를 유지하도록 조정
  4. Vendor 코드 분리: 자주 변경되지 않는 라이브러리 코드는 별도 청크로 분리
  5. 로딩 상태 표시: Suspense fallback으로 명확한 로딩 UI 제공
  6. 에러 처리: ErrorBoundary로 청크 로딩 실패 처리
  7. 네트워크 재시도: 청크 로딩 실패 시 자동 재시도 로직 구현

⚠️ 피해야 할 것

  1. 과도한 분할: 너무 많은 작은 청크는 오히려 성능 저하
  2. 동기 의존성: 여러 청크가 서로 의존하는 구조
  3. 중복 코드: 여러 청크에 같은 코드가 포함되는 경우
  4. fallback UI 누락: Suspense 없이 lazy 사용
  5. 캐싱 전략 무시: 브라우저 캐싱을 고려하지 않은 청크 분할
  6. 초기 청크가 너무 큼: 첫 페이지 로딩에 필요한 코드가 너무 많음

번들 분석

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에서 실행

Code Splitting 예제 실행하기 

로컬 실행

# 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 페이지)

관련 패턴

Last updated on