
Lazy Loading과 Code Splitting: 초기 로딩 속도 절반 줄이기
번들 크기가 2MB를 넘어가면서 초기 로딩이 5초나 걸렸다. Lazy Loading과 Code Splitting으로 필요한 코드만 불러오니 2초로 줄었다.

번들 크기가 2MB를 넘어가면서 초기 로딩이 5초나 걸렸다. Lazy Loading과 Code Splitting으로 필요한 코드만 불러오니 2초로 줄었다.
느리다고 느껴서 감으로 최적화했는데 오히려 더 느려졌다. 프로파일러로 병목을 정확히 찾는 법을 배운 이야기.

텍스트에서 바이너리로(HTTP/2), TCP에서 UDP로(HTTP/3). 한 줄로서기 대신 병렬처리 가능해진 웹의 진화. 구글이 주도한 QUIC 프로토콜 이야기.

HTML 파싱부터 DOM, CSSOM 생성, 렌더 트리, 레이아웃(Reflow), 페인트(Repaint), 그리고 합성(Composite)까지. 브라우저가 화면을 그리는 6단계 과정과 치명적인 렌더링 성능 최적화(CRP) 가이드.

습관적으로 모든 변수에 `useMemo`를 감싸고 있나요? 그게 오히려 성능을 망치고 있습니다. 메모이제이션 비용과 올바른 최적화 타이밍.

프로젝트가 성장하면서 기능을 하나씩 추가했다. 차트 라이브러리, 리치 에디터, PDF 뷰어, 이미지 에디터까지. 필요한 기능이니까 당연히 추가했다. 그런데 어느 날 사용자가 말했다. "사이트가 너무 느려요."
개발자 도구를 열어보니 충격적이었다. 메인 번들 파일이 2.3MB였다. 초기 로딩에 5초가 걸렸다. 사용자는 첫 화면을 보기 위해 5초를 기다려야 했다. 그것도 빠른 인터넷에서.
문제는 명확했다. 사용자가 대시보드에 들어왔을 때 PDF 뷰어 코드가 필요 없다. 관리자 페이지를 보지 않는데 관리자 코드를 다운로드할 이유가 없다. 하지만 모든 코드가 하나의 번들에 들어있었다. 마치 동네 슈퍼에 가는데 집에 있는 모든 물건을 가방에 넣고 가는 것과 같았다.
Lazy Loading과 Code Splitting을 적용했다. 필요한 코드만, 필요한 시점에 불러오도록 바꿨다. 결과는 극적이었다. 초기 로딩이 2초로 줄었다. 절반 이상 감소했다. 이 글은 그 과정에서 이해한 것들을 정리한다.
가장 먼저 와닿은 건 이거였다. 코드를 미리 다 가져올 필요가 없다. 마치 넷플릭스가 전체 영화를 다운로드하지 않고 스트리밍하듯이, 코드도 필요한 부분만 가져오면 된다.
첫 번째 적용은 라우트 기반 분할이었다. Next.js를 쓰고 있었는데, 이미 자동으로 페이지별로 번들을 나눠준다는 걸 알았다. 하지만 난 모든 페이지를 메인 레이아웃에서 import하고 있었다.
Before: 모든 페이지를 한 번에 로딩// app/layout.tsx - 잘못된 방식
import Dashboard from './dashboard/page';
import AdminPanel from './admin/page';
import ReportViewer from './reports/page';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
);
}
문제를 깨달았다. Next.js의 자동 코드 분할을 내가 망가뜨리고 있었다. 각 페이지를 직접 import하지 말고, Next.js의 라우팅에 맡겨야 했다.
After: Next.js 라우팅에 맡기기// app/dashboard/page.tsx - 각 페이지는 독립적으로
export default function Dashboard() {
return <div>대시보드 내용</div>;
}
// app/admin/page.tsx - 별도 번들로 분리됨
export default function AdminPanel() {
return <div>관리자 패널</div>;
}
이것만으로 초기 번들이 2.3MB에서 800KB로 줄었다. 각 페이지는 필요할 때만 로딩됐다.
다음은 컴포넌트였다. 대시보드에 큰 차트 라이브러리가 있었다. 하지만 사용자의 80%는 차트 탭을 열지 않았다. 그런데 차트 라이브러리(600KB)는 대시보드 로딩 시 무조건 다운로드됐다.
React.lazy()를 사용했다. 차트가 필요한 순간에만 로딩하도록 바꿨다.
Before: 차트를 항상 로딩import { Chart } from 'recharts';
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>차트 보기</button>
{showChart && <Chart data={data} />}
</div>
);
}
이 코드는 showChart가 false여도 Chart를 import한다. 사용자가 버튼을 누르지 않아도 600KB를 다운로드한다.
After: 필요할 때만 로딩import { lazy, Suspense, useState } from 'react';
const Chart = lazy(() => import('recharts').then(module => ({
default: module.Chart
})));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>차트 보기</button>
{showChart && (
<Suspense fallback={<div>차트 로딩 중...</div>}>
<Chart data={data} />
</Suspense>
)}
</div>
);
}
버튼을 클릭할 때 비로소 차트 라이브러리가 다운로드됐다. 대부분의 사용자는 600KB를 절약했다.
더 강력한 패턴은 동적 import였다. 특정 조건에서만 필요한 코드를 로딩하는 거였다.
// 관리자만 관리자 도구 로딩
async function loadAdminTools() {
if (user.role === 'admin') {
const { AdminTools } = await import('./AdminTools');
return AdminTools;
}
return null;
}
// PDF 파일일 때만 PDF 뷰어 로딩
async function openFile(file: File) {
if (file.type === 'application/pdf') {
const { PDFViewer } = await import('./PDFViewer');
return <PDFViewer file={file} />;
}
return <DefaultViewer file={file} />;
}
이 패턴은 게임 체인저였다. 관리자가 아닌 사용자는 관리자 코드를 절대 다운로드하지 않는다. PDF를 열지 않으면 PDF 뷰어를 다운로드하지 않는다.
처음엔 흥분해서 모든 걸 lazy로 만들었다. 작은 컴포넌트까지 전부. 결과는 역효과였다.
// 안 좋은 예: 너무 잘게 쪼갬
const Button = lazy(() => import('./Button'));
const Icon = lazy(() => import('./Icon'));
const Text = lazy(() => import('./Text'));
문제는 폭포수 요청(waterfall requests)이었다. Button을 로딩하고, Icon을 로딩하고, Text를 로딩하는 순차적인 요청이 발생했다. 3개의 작은 파일을 순차적으로 다운로드하느라 오히려 더 느려졌다.
원칙을 세웠다:// 개선: 관련 컴포넌트를 하나의 청크로
// components/ui/index.ts
export { Button } from './Button';
export { Icon } from './Icon';
export { Text } from './Text';
// 사용
const UIComponents = lazy(() => import('./components/ui'));
어떤 라이브러리가 큰지 추측으로만 판단했다. 큰 실수였다. 실제로 측정해보니 예상과 달랐다.
webpack-bundle-analyzer를 설치했다.
npm install --save-dev webpack-bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// 기존 설정
});
ANALYZE=true npm run build
시각화된 결과를 보고 충격받았다. 내가 생각한 "큰" 라이브러리는 실제로 작았고, 무심코 추가한 moment.js가 200KB나 차지하고 있었다. date-fns로 교체하고 필요한 함수만 import했더니 10KB로 줄었다.
교훈: 측정 없는 최적화는 도박이다.Lazy loading의 단점이 있었다. 사용자가 버튼을 클릭하면 그제야 다운로드가 시작된다. 느린 네트워크에서는 로딩 스피너가 보였다.
해결책은 prefetching이었다. 사용자가 클릭하기 전에 미리 다운로드를 시작하는 거였다.
import { useEffect } from 'react';
function Dashboard() {
const [showChart, setShowChart] = useState(false);
// 컴포넌트 마운트 후 3초 뒤에 미리 로딩
useEffect(() => {
const timer = setTimeout(() => {
import('recharts'); // prefetch
}, 3000);
return () => clearTimeout(timer);
}, []);
return (
<div>
<button onClick={() => setShowChart(true)}>차트 보기</button>
{showChart && (
<Suspense fallback={<div>로딩 중...</div>}>
<LazyChart />
</Suspense>
)}
</div>
);
}
더 스마트한 방법은 hover prefetch였다.
function ChartButton({ onClick }: { onClick: () => void }) {
const [prefetched, setPrefetched] = useState(false);
const handleMouseEnter = () => {
if (!prefetched) {
import('recharts'); // 호버하면 미리 로딩
setPrefetched(true);
}
};
return (
<button
onClick={onClick}
onMouseEnter={handleMouseEnter}
>
차트 보기
</button>
);
}
사용자가 버튼 위에 마우스를 올리는 순간 다운로드가 시작된다. 클릭까지 평균 200-300ms가 걸리는데, 그 시간에 이미 대부분 다운로드가 완료된다. 사용자는 즉시 차트를 본다.
코드만 lazy loading하는 게 아니었다. 이미지도 똑같이 적용됐다.
블로그 목록 페이지에 30개의 썸네일이 있었다. 모든 이미지가 한 번에 로딩되면서 초기 로딩이 느렸다. 사용자는 처음에 3-4개만 본다. 나머지는 스크롤해야 보인다.
HTML의 네이티브 lazy loading:<img
src="/images/thumbnail.jpg"
alt="썸네일"
loading="lazy"
width="300"
height="200"
/>
이것만으로 뷰포트에 가까워질 때까지 이미지 로딩이 지연됐다. 간단하고 효과적이었다.
더 세밀한 제어가 필요하면 Intersection Observer를 사용했다.
import { useEffect, useRef, useState } from 'react';
function LazyImage({ src, alt }: { src: string; alt: string }) {
const [isLoaded, setIsLoaded] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
setIsLoaded(true);
observer.disconnect();
}
});
},
{ rootMargin: '100px' } // 100px 전에 미리 로딩
);
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, []);
return (
<img
ref={imgRef}
src={isLoaded ? src : '/images/placeholder.jpg'}
alt={alt}
/>
);
}
Next.js에서 특정 컴포넌트는 서버에서 렌더링하면 안 됐다. 브라우저 API를 사용하는 지도 컴포넌트가 그랬다.
import dynamic from 'next/dynamic';
const Map = dynamic(() => import('./Map'), {
ssr: false, // 클라이언트에서만 렌더링
loading: () => <div>지도 로딩 중...</div>
});
export default function LocationPage() {
return (
<div>
<h1>위치</h1>
<Map />
</div>
);
}
ssr: false는 서버에서 해당 컴포넌트를 건너뛴다. 클라이언트에서만 로딩된다. window나 document를 사용하는 라이브러리에 필수적이었다.
Google Analytics, 채팅 위젯, 광고 스크립트. 이런 써드파티 스크립트들이 초기 로딩을 방해했다.
// app/layout.tsx
'use client';
import { useEffect } from 'react';
export default function RootLayout({ children }: { children: React.ReactNode }) {
useEffect(() => {
// 페이지가 완전히 로드된 후 3초 뒤에 분석 도구 로딩
const timer = setTimeout(() => {
const script = document.createElement('script');
script.src = 'https://www.googletagmanager.com/gtag/js?id=GA_ID';
script.async = true;
document.body.appendChild(script);
}, 3000);
return () => clearTimeout(timer);
}, []);
return (
<html>
<body>{children}</body>
</html>
);
}
사용자가 콘텐츠를 먼저 보고, 분석 도구는 나중에 로딩된다. 우선순위가 명확해졌다.
최적화 전과 후를 Lighthouse로 측정했다.
Before:실제 사용자 지표도 개선됐다. 이탈률이 35%에서 18%로 떨어졌다. 페이지뷰 당 체류 시간이 1.2분에서 2.4분으로 늘었다. 빠른 로딩은 사용자 경험으로 직결됐다.
Lazy Loading과 Code Splitting의 핵심은 간단했다. 필요한 것만, 필요한 때에 가져온다.
마치 뷔페에서 한 번에 모든 음식을 접시에 담지 않는 것과 같다. 먹고 싶은 것을 그때그때 가져온다. 코드도 똑같다.
실천 가이드:
초기 로딩이 5초에서 2초로 줄었다. 사용자는 더 오래 머물렀고, 이탈률은 절반으로 떨어졌다. 복잡한 기술이 아니었다. 관점의 전환이었다. "모든 걸 미리 준비"에서 "필요한 것만 제때"로.