초기 로딩 속도 0.5초 빨라지면 매출이 10% 오른다 (번들 사이즈 다이어트)
1. "기능은 다 만들었는데, 사이트가 너무 무거워요"
스타트업 초기에는 '기능 구현'이 최우선입니다.
"차트 기능 넣어주세요!", "에디터 기능 넣어주세요!", "3D 뷰어 넣어주세요!"
개발자는 열심히 npm install heavy-library를 입력합니다.
어느 날, 사용자들이 불만을 터뜨립니다. "사이트 들어가는 데 5초나 걸려요. 답답해서 못 쓰겠어요."
Lighthouse 점수를 찍어보니 Performance 30점. 범인은 거대해진 JS 번들 파일(main.js, 5MB)이었습니다. 사용자가 우리 사이트의 '로그인 페이지'만 보려고 해도, 5MB짜리 거대한 자바스크립트 덩어리를 전부 다운로드하고 파싱해야 했던 겁니다.
2. 처음엔 뭐가 이해가 안 갔나? (SPA의 딜레마)
저는 "요즘 인터넷 빠른데 5MB 정도는 금방 받지 않나?"라고 안일하게 생각했습니다.
하지만 문제는 네트워크 속도(Download Time)만이 아니었습니다.
브라우저가 다운로드한 JS 코드를 해석하고 실행하는 시간(Execution Time)이 더 큰 문제였습니다.
특히 저사양 모바일 기기에서는 5MB를 파싱하는 데만 몇 초가 걸리고, 그동안 화면은 멈춥니다(TBT: Total Blocking Time).
과거의 MPA(Multi Page Application)는 페이지별로 필요한 HTML/JS만 받았습니다.
하지만 React 같은 SPA(Single Page Application)는 기본적으로 모든 페이지의 코드를 하나의 bundle.js로 뭉쳐서(Bundling) 보냅니다.
안 쓰는 관리자 페이지 코드, 결제 모듈 코드가 메인 페이지 로딩을 방해하는 꼴입니다.
3. 어떤 포인트에서 이해가 됐나? (여행 가방 비유)
이걸 "여행 가방 싸기"에 비유하니 이해가 됐습니다.
- 기본 번들링: 2박 3일 제주도 여행을 가는데, 이민 가방에 봄/여름/가을/겨울 옷이랑 집에 있는 책까지 다 챙겨가는 겁니다. 공항에서 가방 찾느라 진이 다 빠집니다.
- Code Splitting: 딱 지금 필요한 '여행용 파우치'만 들고 비행기에 타는 겁니다. "겨울옷(관리자 페이지)은 겨울(관리자 진입)에 택배로 받으면 되잖아?"
우리는 사용자가 지금 당장 필요한 코드만 쪼개서(Splitting) 보내고, 나머지는 필요할 때 가져와야(Lazy Loading) 합니다.
4. 해결 과정 - 다이어트 3단계
webpack-bundle-analyzer나 rollup-plugin-visualizer로 분석해 보니, 범인은 명확했습니다.
- 사용하지 않는 거대 라이브러리 (Lodash 전체 호출, Moment.js)
- 특정 페이지에서만 쓰는 무거운 컴포넌트 (Chart.js, Three.js)
1단계 - 라우트 기반 코드 스플리팅 (React.lazy)
가장 쉽고 효과가 큰 방법입니다. 페이지(Route) 별로 JS 파일을 쪼갭니다.
사용자가 /dashboard에 안 들어가면, 대시보드 코드는 평생 다운로드하지 않습니다.
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// 1. 일반 import 대신 lazy import 사용
// import Dashboard from './pages/Dashboard'; (X)
const Dashboard = lazy(() => import('./pages/Dashboard')); // (O)
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<BrowserRouter>
{/* 2. 로딩 중 보여줄 UI (Suspense) 필수 */}
<Suspense fallback={<div>페이지 로딩 중...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
이것만 적용해서 메인 번들 사이즈를 40% 줄였습니다.
2단계 - 거대 컴포넌트 Lazy Loading
페이지뿐만 아니라, 특정 조건에서만 뜨는 무거운 모달이나 컴포넌트도 쪼갤 수 있습니다.
import { useState, lazy, Suspense } from 'react';
// 무거운 텍스트 에디터는 버튼을 눌렀을 때만 로드한다!
const HeavyTextEditor = lazy(() => import('./components/HeavyTextEditor'));
function WritePage() {
const [showEditor, setShowEditor] = useState(false);
return (
<div>
<button onClick={() => setShowEditor(true)}>글쓰기</button>
{showEditor && (
<Suspense fallback={<div>에디터 불러오는 중...</div>}>
<HeavyTextEditor />
</Suspense>
)}
</div>
);
}
이렇게 하면 초기 페이지 진입 시에는 에디터 라이브러리 용량을 전혀 차지하지 않습니다.
3단계 - Tree Shaking (가지치기)
라이브러리 전체를 불러오지 말고, 필요한 함수만 불러와야 합니다.
/* 나쁜 예 (CommonJS, 전체 로드) */
// const _ = require('lodash');
// _.debounce(...) // Lodash의 모든 함수를 다 번들에 포함시킴 (약 70KB)
/* 좋은 예 (ES Modules, 필요한 것만 로드) */
import debounce from 'lodash/debounce';
// 딱 debounce 함수 하나만 가져옴 (< 1KB)
최신 번들러(Webpack 5, Vite)는 import { A } from 'lib' 형태로 쓰면 안 쓰는 B, C 함수를 자동으로 제거합니다(Tree Shaking). 하지만 Lodash 같은 구형 라이브러리는 lodash-es를 쓰거나 직접 경로(cherry-pick)로 접근해야 합니다.
5. 깊이 파고들기 - Dynamic Import로 라이브러리 늦게 부르기
반드시 컴포넌트가 아니더라도, 특정 함수 실행 시점에 라이브러리를 로드할 수도 있습니다.
예를 들어, 엑셀 다운로드 기능은 사용자가 버튼을 누를 때만 xlsx 라이브러리가 필요합니다.
function ExcelButton() {
const downloadExcel = async () => {
// 버튼 클릭 시점에 라이브러리 다운로드 시작!
const XLSX = await import('xlsx');
const wb = XLSX.utils.book_new();
// ... 엑셀 저장 로직
XLSX.writeFile(wb, 'report.xlsx');
};
return <button onClick={downloadExcel}>엑셀 다운로드</button>;
}
이 기법을 활용하면 초기 로딩 속도를 극적으로(0.x초 단위로) 줄일 수 있습니다.
6. Webpack Bundle Analyzer 사용법 제대로 이해하기
눈으로 보지 않으면 모릅니다. 번들 사이즈를 시각화하세요.
Next.js (@next/bundle-analyzer):
next.config.js에 플러그인을 추가하고, 빌드할 때 환경변수를 줍니다.
ANALYZE=true npm run build
실행하면 브라우저에 알록달록한 지도(Treemap)가 뜹니다.
- 크기가 큰 사각형: 용량을 많이 차지하는 라이브러리.
- 중복된 사각형:
lodash가 여러 버전으로 중복 포함되었는지 확인.
여기서 moment.js나 framer-motion 같은 뚱뚱한 녀석들을 찾아내서 "더 가벼운 대안(Day.js 등)"으로 교체하는 게 최적화의 시작입니다.
7. Case Study: 3초의 법칙 (사용자가 50% 떠난 이유)
번들 크기 문제가 얼마나 치명적인지 보여주는 사례가 있다. SaaS 대시보드를 런칭했을 때 첫 진입 이탈률(Bounce Rate)이 70%가 넘었고, 평균 로딩 시간이 3.5초였다고 한다. (3초 넘으면 53%가 이탈한다는 통계와 맞닿아 있다.)
원인을 분석하니, 대시보드 내부에서 쓰는 react-vis (차트 라이브러리)와 mapbox-gl (지도 라이브러리)가 로그인 페이지까지 침범해 있었습니다.
이 두 라이브러리만 합쳐도 2MB가 넘었습니다.
해결책:
- 로그인 페이지 분리: 메인 앱(
DashboardApp)과 랜딩 페이지(LandingPage)의 진입점(Entry Point) 자체를 나눴습니다. - Lazy Loading: 차트와 지도는 사용자가 해당 탭을 클릭했을 때만 로드하도록 바꿨습니다.
결과: 초기 로딩 시간 0.8초 달성. 번들 크기를 줄이면 이탈률이 크게 개선된다는 사례가 있다 — 70%에서 35%로 절반 수준까지 줄어든 경우도 있다고 한다. 교훈: 코드가 아무리 아름다워도, 로딩이 느리면 아무도 안 봅니다.
8. Pro Tip: 이미지 포맷과 번들 사이즈의 관계
번들 사이즈를 아무리 줄여도, 메인 배너 이미지 하나가 3MB라면 말짱 도루묵입니다. 브라우저 리소스 로딩 관점에서는 JS 번들과 이미지가 대역폭을 경쟁하기 때문입니다.
- AVIF / WebP 사용: PNG 대신 최신 포맷을 쓰면 용량이 1/10로 줄어듭니다.
- Lazy Loading: 화면 아래에 있는 이미지는
loading="lazy"속성을 줘서 나중에 받게 하세요.
JS 다이어트와 이미지 다이어트는 병행해야 효과가 있습니다.
9. 요약
- Code Splitting: 페이지별로 JS를 쪼개라 (
React.lazy). - Dynamic Import: 무거운 기능(엑셀, 차트)은 클릭했을 때 불러와라.
- Tree Shaking:
import *대신 필요한 함수만 쏙쏙 뽑아 써라. - Visualize:
bundle-analyzer로 범인을 색출해라.
사용자는 당신의 코드를 다 읽고 싶어 하지 않습니다. 지금 당장 필요한 것만 보여주세요.