Ver4.0 코드 스플리팅과 레이지 로딩, 첫 화면을 3초에서 0.8초로 만드는 기술

코드 스플리팅과 레이지 로딩, 첫 화면을 3초에서 0.8초로 만드는 기술
번들이 뚱뚱해서 사이트가 굼뜬다고? 통째로 던지지 말고, 필요한 만큼만 잘라서 주자.
0. 들어가며 — "왜 내 사이트만 이렇게 느려?"
친구야, 솔직히 한 번쯤 이런 경험 있지?
로컬에서 개발할 땐 완전 빛의 속도로 잘 돌아가던 사이트가,
배포하고 나서 지하철에서 폰으로 켜보니까... 흰 화면만 3초 동안 멍하니 떠 있는 거.
그리고 개발자 도구 Network 탭을 열어봤더니 main.js — 4.2MB.
그 순간 등골이 서늘해지지. "내가 뭘 넣었길래 4메가야?"
답은 대부분 똑같아. 쓰지도 않는 코드까지 전부 한 덩어리로 묶어서 보냈기 때문이야.
로그인 안 한 사용자한테 관리자 대시보드 차트 라이브러리를 보내고,
메인 페이지만 보고 나갈 사람한테 결제 모듈 전체를 보내고 있었던 거지.
이 글에서 다룰 것
· 번들이 왜 뚱뚱해지는지, 브라우저 입장에서 무슨 일이 일어나는지
· 코드 스플리팅(Code Splitting)의 정확한 원리와 실전 패턴
· 레이지 로딩(Lazy Loading)을 컴포넌트·이미지·라이브러리에 적용하는 법
· React, Vue, Vanilla JS 각각의 구현 코드
· 프리로드/프리페치로 "느려 보이는" 문제까지 없애는 법
· 측정 방법, 흔한 함정, 그리고 언제 하면 안 되는지까지
자, 커피 한 잔 들고 편하게 읽어보자. 코드는 전부 실제로 동작하는 것들이야.
1. 번들이 커지면 브라우저는 무슨 고생을 할까
먼저 "JS 파일이 크면 느리다"를 좀 더 정확하게 이해해보자.
그냥 "다운로드가 오래 걸려서"가 전부가 아니거든.
JS 1MB가 처리되는 과정
브라우저가 <script src="main.js">를 만나면 대략 이런 단계를 거쳐:
여기서 핵심 포인트.
이미지 2.5MB와 JS 2.5MB는 완전히 다른 무게야.
이미지는 다운로드만 끝나면 GPU가 알아서 그려주지만,
JS는 다운로드 후에도 파싱 → 컴파일 → 실행이라는 CPU 작업이 남아 있어.
그리고 이 작업은 대부분 메인 스레드에서 일어나. 즉, 그동안 사용자는 버튼을 눌러도 반응이 없어.
고사양 맥북의 함정
개발자 노트북에서 JS 파싱/실행은 순식간이야. 하지만 실제 사용자의 중저가 안드로이드 폰은
싱글코어 성능이 개발 장비의 4~6배 느린 경우가 흔해.
그래서 크롬 개발자도구의 CPU 4x/6x slowdown 옵션이 존재하는 거야. 꼭 켜고 테스트해봐.
Core Web Vitals와의 연결
구글이 검색 랭킹에도 반영하는 Core Web Vitals 지표들을 보면,
큰 번들이 얼마나 직접적으로 악영향을 주는지 알 수 있어.
| 지표 | 의미 | 권장 기준 | 큰 번들의 영향 |
|---|---|---|---|
| LCP | 가장 큰 콘텐츠가 그려지는 시점 | 2.5초 이내 | JS로 렌더링하는 SPA라면 직격탄 |
| INP | 사용자 입력에 대한 반응 속도 | 200ms 이내 | 롱태스크가 입력을 막아 크게 악화 |
| CLS | 레이아웃이 밀리는 정도 | 0.1 이내 | 늦게 로드된 요소가 화면을 밀어냄 |
| TBT | 메인 스레드가 막힌 총 시간 | 200ms 이내(랩) | 파싱·실행 시간에 정비례 |
정리하면 이거야.
"사용자가 지금 당장 필요로 하지 않는 코드는, 지금 당장 보내지 말자."
이 한 문장이 코드 스플리팅과 레이지 로딩의 전부야. 진짜로.
2. 코드 스플리팅이란 정확히 무엇인가
코드 스플리팅(Code Splitting)은 하나의 거대한 번들 파일을
여러 개의 작은 청크(chunk)로 쪼개는 빌드 단계의 기법이야.
그리고 레이지 로딩(Lazy Loading)은 그 쪼개진 청크를
실제로 필요해지는 순간에 네트워크로 가져오는 런타임 단계의 전략이고.
👉 둘은 세트야. 쪼개기만 하고 언제 부를지 정하지 않으면 그냥 파일만 많아진 거고,
쪼개지 않으면 애초에 나중에 부를 대상이 없어. 스플리팅은 "칼질", 레이지 로딩은 "배달 타이밍"이라고 생각하면 편해.
비유 하나 하고 갈게
뷔페 생각해봐.
번들 하나 = 손님이 들어오자마자 접시에 모든 음식을 다 담아서 건네주는 거야.
손님은 물 한 잔만 마시고 싶었는데, 스테이크랑 초밥이랑 디저트까지 다 받느라 5분을 기다려.
코드 스플리팅 = 음식을 코너별로 나눠 놓는 것.
레이지 로딩 = 손님이 그 코너로 걸어갈 때 접시를 내주는 것.
결과적으로 손님은 30초 만에 물을 마시고, 스테이크가 먹고 싶어지면 그때 가서 받아오면 돼.
전체 음식량은 똑같지만 첫 만족까지의 시간이 극적으로 줄어든다는 게 핵심이야.
쪼개는 기준 4가지
| 기준 | 설명 | 효과 |
|---|---|---|
| 라우트 단위 | 페이지/화면별로 분리 | 가장 효과 크고 구현 쉬움. 1순위. |
| 컴포넌트 단위 | 모달, 에디터, 차트 등 조건부 UI | 초기 화면에 안 보이는 무거운 UI 제거 |
| 라이브러리(벤더) 분리 | node_modules를 별도 청크로 | 캐시 적중률 상승. 배포해도 재다운로드 X |
| 기능/조건 단위 | 관리자 전용, 특정 브라우저 폴리필 | 해당 사용자에게만 전송 |
순서대로 하면 돼. 라우트 → 무거운 컴포넌트 → 벤더 → 조건부 기능.
경험상 라우트 분리만 제대로 해도 초기 번들이 40~70% 줄어드는 프로젝트가 수두룩해.
3. 모든 것의 시작 — 동적 import()
코드 스플리팅의 심장은 딱 하나야. 동적 import 문법.
// 정적 import — 파일 최상단, 빌드 시점에 번들에 포함됨
import { heavyChart } from './chart.js';
// 동적 import — 호출하는 순간 네트워크 요청, Promise 반환
const module = await import('./chart.js');
module.heavyChart();
차이가 보이지?
정적 import는 파일 맨 위에만 쓸 수 있고, 조건문 안에 넣을 수 없어.
번들러가 빌드할 때 "얘는 무조건 필요하구나" 하고 메인 번들에 합쳐버려.
동적 import()는 함수처럼 생겼고 Promise를 반환해.
번들러(Webpack, Vite, Rollup, esbuild 등)는 이 구문을 발견하는 순간
"아, 여기서부터는 별도 파일로 빼야겠구나" 하고 자동으로 청크 분리 지점(split point)을 만들어.
중요한 사실
동적 import()는 웹팩 전용 문법이 아니라 ECMAScript 표준이야 (ES2020).
브라우저 네이티브로도 동작하고, Node.js에서도 쓸 수 있어.
즉 번들러 없이 순수 ES 모듈 환경에서도 레이지 로딩이 가능하다는 뜻.
가장 기본적인 실전 예제
무거운 날짜 라이브러리를, 사용자가 달력 버튼을 눌렀을 때만 불러오자.
const btn = document.querySelector('#open-calendar');
let datePickerModule = null;
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.textContent = '불러오는 중...';
try {
// 이미 받아왔으면 재사용 (브라우저 모듈 캐시도 있지만 명시적으로)
if (!datePickerModule) {
datePickerModule = await import('./date-picker.js');
}
datePickerModule.render('#calendar-root');
} catch (err) {
console.error('달력 모듈 로드 실패', err);
btn.textContent = '다시 시도';
btn.disabled = false;
return;
}
btn.textContent = '달력 열기';
btn.disabled = false;
});
여기서 놓치면 안 되는 포인트 세 가지:
① 로딩 중 상태 표시 — 안 하면 사용자는 "버튼이 안 먹네?" 하고 연타해.
② try/catch로 실패 처리 — 네트워크는 언제든 끊길 수 있어. 이건 진짜 중요해.
③ 중복 호출 방지 — 캐시가 있긴 하지만 명시적으로 관리하는 게 안전해.
청크 이름 지정하기 (Webpack Magic Comments)
기본값으로 두면 src_pages_Admin_js.a3f21c.js 같은 이름이 나와서 디버깅이 괴로워.
const AdminPage = () => import(
/* webpackChunkName: "admin" */
/* webpackPrefetch: true */
'./pages/AdminPage.js'
);
Vite에서는 Rollup 옵션으로 제어해:
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('chart.js')) return 'vendor-chart';
if (id.includes('react')) return 'vendor-react';
return 'vendor';
}
}
}
}
}
};
4. 라우트 기반 분리 — 가성비 1위
자, 이제 실전이야. 효과 대비 난이도가 가장 좋은 게 라우트 분리야.
생각해봐. 사용자가 홈페이지에 들어왔어.
근데 왜 마이페이지, 설정 화면, 결제 화면, 관리자 대시보드 코드까지 다 받아야 해?
그중 절반은 클릭도 안 하고 나갈 텐데.
React — React.lazy + Suspense
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Home from './pages/Home'; // 첫 화면은 정적으로!
import Spinner from './components/Spinner';
// 나머지는 전부 레이지
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Admin = lazy(() => import('./pages/Admin'));
export default function App() {
return (
<BrowserRouter>
<Suspense fallback={<Spinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<Admin />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
실수 포인트 1 — 첫 화면까지 lazy로 만들지 마
Home까지 lazy()로 감싸면 "HTML 로드 → JS 로드 → 또 Home 청크 로드"로
네트워크 왕복이 한 번 더 늘어나서 오히려 느려져. 첫 진입 화면은 정적 import가 정답.
실수 포인트 2 — React.lazy는 default export만 인식해
named export를 쓰고 있다면 이렇게 감싸줘야 해:
const Chart = lazy(() =>
import('./Chart').then(m => ({ default: m.Chart }))
);
로딩 실패에 대한 방어 — ErrorBoundary
이거 안 하면 진짜 큰일 나. 배포 직후에 사용자가 이미 열어둔 탭에서
이전 버전의 청크 파일을 요청하는데, 서버엔 새 해시 파일만 있어서 404가 나거든.
그러면 화면이 하얗게 죽어버려. (일명 ChunkLoadError)
import { Component } from 'react';
class ChunkErrorBoundary extends Component {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch(error) {
// 청크 로드 실패면 새로고침으로 최신 빌드 받기
if (/Loading chunk|ChunkLoadError|dynamically imported/.test(error.message)) {
if (!sessionStorage.getItem('chunk-reloaded')) {
sessionStorage.setItem('chunk-reloaded', '1');
window.location.reload();
}
}
}
render() {
if (this.state.failed) {
return <div>화면을 불러오지 못했어요. 새로고침 해주세요.</div>;
}
return this.props.children;
}
}
sessionStorage 플래그를 쓴 이유는 무한 새로고침 루프를 막기 위해서야.
이런 디테일이 실제 서비스에서 사용자 이탈을 막아줘.
Vue 3 — defineAsyncComponent
// router/index.js
const routes = [
{ path: '/', component: Home }, // 정적
{
path: '/dashboard',
component: () => import('../views/Dashboard.vue') // 이것만으로 끝
}
];
컴포넌트 단위로 더 세밀하게 제어하고 싶다면:
import { defineAsyncComponent } from 'vue';
const HeavyEditor = defineAsyncComponent({
loader: () => import('./HeavyEditor.vue'),
loadingComponent: LoadingSpinner,
errorComponent: LoadError,
delay: 200, // 200ms 안에 끝나면 스피너 아예 안 보여줌
timeout: 10000 // 10초 넘으면 에러 처리
});
delay: 200 이 옵션 진짜 좋아.
캐시가 있거나 네트워크가 빠르면 50ms 만에 끝나는데,
그때 스피너가 깜빡 나타났다 사라지면 오히려 더 지저분해 보이거든.
Vanilla JS 라우터
프레임워크 없이도 충분히 가능해.
const routes = {
'/': () => import('./views/home.js'),
'/about': () => import('./views/about.js'),
'/gallery': () => import('./views/gallery.js'),
};
const app = document.querySelector('#app');
const cache = new Map();
async function navigate(path) {
const loader = routes[path] || routes['/'];
app.innerHTML = '<div class="loading">로딩 중...</div>';
try {
let view = cache.get(path);
if (!view) {
view = await loader();
cache.set(path, view);
}
app.innerHTML = '';
view.render(app);
} catch (e) {
app.innerHTML = '<p>페이지를 불러오지 못했습니다.</p>';
}
}
window.addEventListener('popstate', () => navigate(location.pathname));
navigate(location.pathname);
5. 컴포넌트·라이브러리 레벨 분리
라우트 분리를 끝냈는데도 초기 번들이 여전히 무겁다면?
범인은 대부분 초기 화면에 존재하지만 즉시 보이지는 않는 무거운 것들이야.
레이지 로딩 1순위 후보들
| 대상 | 대략적 크기(gzip 전) | 로딩 시점 |
|---|---|---|
| 리치 텍스트 에디터 (Quill, TinyMCE) | 300KB ~ 1MB+ | 에디터 영역 클릭 시 |
| 차트 라이브러리 (Chart.js, ECharts) | 200KB ~ 900KB | 차트가 뷰포트 진입 시 |
| 지도 SDK (Kakao, Google Maps) | 200KB+ | 지도 탭 선택 시 |
| 모달 / 다이얼로그 내부 | 가변 | 열기 버튼 클릭 시 |
| PDF 뷰어, 이미지 크롭퍼 | 300KB+ | 해당 기능 실행 시 |
| 애니메이션 라이브러리 (Lottie 등) | 250KB+ | 스크롤 도달 시 |
모달 레이지 로딩 — React 예제
import { useState, lazy, Suspense } from 'react';
const SettingsModal = lazy(() => import('./SettingsModal'));
function Header() {
const [open, setOpen] = useState(false);
return (
<>
<button
onClick={() => setOpen(true)}
// 마우스를 올리는 순간 미리 받아두기 — 체감 속도 0에 수렴
onMouseEnter={() => import('./SettingsModal')}
onFocus={() => import('./SettingsModal')}
>
설정
</button>
{open && (
<Suspense fallback={<ModalSkeleton />}>
<SettingsModal onClose={() => setOpen(false)} />
</Suspense>
)}
</>
);
}
onMouseEnter 프리로드, 이거 정말 마법 같아.
사람이 버튼 위에 마우스를 올리고 실제로 클릭하기까지는 평균 100~300ms가 걸려.
그 사이에 청크를 미리 받아두면, 클릭했을 때 이미 캐시에 있어서 즉시 열려.
사용자는 레이지 로딩이 적용됐다는 사실조차 몰라. 최고의 최적화지.
모바일에서는 hover가 없으니 touchstart를 쓰면 돼:
btn.addEventListener('touchstart', () => import('./SettingsModal'), { passive: true });
스크롤 기반 로딩 — IntersectionObserver
페이지 하단에 있는 차트나 댓글 위젯은, 사용자가 거기까지 내려왔을 때 로드하면 돼.
const target = document.querySelector('#chart-section');
const observer = new IntersectionObserver(async (entries, obs) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
obs.unobserve(entry.target); // 한 번만 실행
const { drawChart } = await import('./chart-module.js');
drawChart(entry.target);
}
}, {
rootMargin: '300px' // 화면에 닿기 300px 전에 미리 시작
});
observer.observe(target);
rootMargin: '300px'가 포인트야.
딱 화면에 들어왔을 때 로딩을 시작하면 사용자는 빈 영역을 보게 되거든.
300px 정도 여유를 두면 스크롤해서 도착했을 땐 이미 준비가 끝나 있어.
무거운 라이브러리를 조건부로만 쓰기
// ❌ 나쁨 — 폼 검증 한 번 쓰려고 전체를 항상 로드
import * as yup from 'yup';
// ✅ 좋음 — 제출 시점에만
async function validate(data) {
const yup = await import('yup');
const schema = yup.object({ email: yup.string().email().required() });
return schema.validate(data);
}
번들 다이어트 팁 3종
· moment.js → date-fns / dayjs: moment는 로케일 포함 시 200KB가 넘어.
· lodash 전체 import 금지: import _ from 'lodash' 대신 import debounce from 'lodash-es/debounce'
· 아이콘 라이브러리 전체 import 금지: 트리 셰이킹이 되는지 반드시 확인
6. 이미지 레이지 로딩 — 가장 쉽고 효과 큰 것
JS만 무거운 게 아니야. 사실 대부분 웹페이지에서 바이트 용량 1위는 이미지야.
브라우저 네이티브 방식 (2024년 이후 사실상 표준)
<!-- 첫 화면에 보이는 이미지 (LCP 후보) -->
<image-tag src="hero.webp" width="1200" height="600"
loading="eager" fetchpriority="high" decoding="async">
<!-- 스크롤 아래 이미지 -->
<image-tag src="photo-12.webp" width="800" height="600"
loading="lazy" decoding="async" alt="설명">
(※ 실제 코드에서는 image-tag 자리에 표준 이미지 태그를 쓰면 돼.)
절대 하지 말 것 ①
첫 화면 히어로 이미지에 loading="lazy"를 붙이는 것.
브라우저의 프리로드 스캐너가 이 이미지를 나중 순위로 미루기 때문에
LCP가 오히려 수백 ms 악화돼. 실제 측정에서 자주 확인되는 안티패턴이야.
절대 하지 말 것 ②
width/height 속성 생략.
크기를 모르면 브라우저가 공간을 미리 못 비워두고, 이미지가 로드될 때 아래 콘텐츠를 밀어내.
이게 바로 CLS(레이아웃 시프트)야. 사용자가 누르려던 버튼이 도망가는 그 짜증나는 현상.
CSS로 aspect-ratio를 주는 것도 좋은 방법이야.
플레이스홀더 전략
이미지가 로드되기 전 빈 공간이 허전하다면, 이런 방법들이 있어:
· 단색 블록 — 가장 가볍고 무난
· LQIP (Low Quality Image Placeholder) — 20px짜리 초저화질 이미지를 base64로 인라인, blur 처리
· Skeleton UI — 회색 박스 애니메이션, 앱 같은 느낌
· BlurHash / ThumbHash — 30바이트 내외 문자열로 흐릿한 미리보기 생성
7. 프리로드와 프리페치 — "느려 보이는 것"까지 없애기
여기가 진짜 고수의 영역이야.
레이지 로딩만 열심히 하면 이런 부작용이 생겨.
초기 로딩은 빨라졌는데, 페이지를 이동할 때마다 스피너가 뜨는 거야.
사용자 입장에서는 "전체적으로 버벅거리는 느낌"을 받을 수 있어.
그래서 필요한 게 미리 받아두기 전략이야.
preload vs prefetch, 뭐가 다를까
| 구분 | 우선순위 | 의미 | 사용 시점 |
|---|---|---|---|
preload | 높음 | "지금 이 페이지에서 곧 반드시 쓴다" | 폰트, LCP 이미지, 핵심 청크 |
prefetch | 낮음(유휴 시) | "다음 페이지에서 쓸 것 같다" | 다음 라우트 청크 |
preconnect | - | "이 도메인과 미리 연결해둬" | CDN, API 서버, 폰트 서버 |
dns-prefetch | - | "DNS만 미리 조회해둬" | preconnect 폴백 |
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<link rel="preload" href="/fonts/pretendard.woff2" as="font" type="font/woff2" crossorigin>
<link rel="prefetch" href="/assets/dashboard.a9f2.js" as="script">
preload를 남발하지 마
모든 걸 preload하면 = 아무것도 우선순위가 없는 것과 같아.
대역폭을 서로 뺏어서 정작 중요한 리소스가 늦게 도착해. 3~5개 이내로 제한하는 게 좋아.
사용자 의도 기반 프리페치 (실전 코드)
가장 똑똑한 방법은 "사용자가 이걸 누를 것 같다"를 감지하는 거야.
const prefetched = new Set();
function prefetchRoute(path) {
if (prefetched.has(path)) return;
prefetched.add(path);
const link = document.createElement('link');
link.rel = 'prefetch';
link.as = 'script';
link.href = routeChunkMap[path];
document.head.appendChild(link);
}
// 1. 링크에 마우스 올렸을 때
document.addEventListener('mouseover', (e) => {
const a = e.target.closest('a[data-route]');
if (a) prefetchRoute(a.dataset.route);
}, { passive: true });
// 2. 링크가 화면에 보일 때 (모바일 대응)
const io = new IntersectionObserver((entries) => {
entries.forEach(en => {
if (en.isIntersecting) prefetchRoute(en.target.dataset.route);
});
});
document.querySelectorAll('a[data-route]').forEach(a => io.observe(a));
네트워크 상황을 배려하는 매너
이건 꼭 챙겨줬으면 해. 데이터 요금제 쓰는 사용자도 있으니까.
function canPrefetch() {
const c = navigator.connection;
if (!c) return true; // 정보 없으면 허용
if (c.saveData) return false; // 데이터 절약 모드 ON
if (/2g|slow-2g/.test(c.effectiveType)) return false;
return true;
}
if (canPrefetch()) {
requestIdleCallback(() => prefetchRoute('/dashboard'));
}
requestIdleCallback으로 감싸면 브라우저가 한가할 때만 실행돼서
지금 보고 있는 화면의 성능을 방해하지 않아. 진짜 착한 코드지.
8. 벤더 청크 분리와 캐시 전략
이건 조금 다른 각도의 최적화야. 재방문 사용자를 위한 거거든.
상황을 그려보자. 전부 한 파일에 넣었어. main.abc123.js (800KB)
그런데 버튼 색깔 하나 바꿔서 배포했어. 해시가 바뀌겠지? main.def456.js
결과: 사용자는 800KB를 처음부터 다시 받아야 해. React도, 라이브러리도, 전부 다.
바뀐 건 색상 코드 6글자인데 말이야. 😇
해결책 — 변경 주기별로 분리
// webpack.config.js
optimization: {
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
cacheGroups: {
framework: {
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
name: 'framework',
priority: 40,
enforce: true
},
lib: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
priority: 30,
minChunks: 1
},
commons: {
minChunks: 2, // 2곳 이상에서 쓰이면 공통으로
name: 'commons',
priority: 20,
reuseExistingChunk: true
}
}
}
}
이렇게 나누면:
framework 청크 — 거의 안 바뀜 → 브라우저 캐시에 오래 남음
vendor 청크 — 가끔 바뀜 (패키지 업데이트 시)
app 청크 — 자주 바뀜 (매 배포마다)
이제 색깔 하나 바꿔서 배포해도 app 청크 30KB만 다시 받으면 끝이야.
runtimeChunk: 'single'을 왜 넣었을까?
웹팩 런타임(모듈 매핑 테이블)이 각 청크에 들어가 있으면,
청크 하나만 바뀌어도 매핑 정보가 달라져서 다른 청크의 해시까지 전부 바뀌는 문제가 생겨.
런타임을 별도 파일로 빼면 이 연쇄 무효화를 막을 수 있어.
청크를 너무 잘게 쪼개면?
반대로 극단적으로 잘게 쪼개는 것도 문제야.
HTTP/2 이후로 동시 요청 비용이 많이 줄긴 했지만, 여전히 요청마다
헤더 오버헤드, 요청 스케줄링 비용, gzip 압축률 손해가 발생해.
작은 파일은 압축 효율이 떨어지거든.
실무 가이드: 청크 하나당 20KB ~ 150KB(gzip 전) 정도가 적당하고,
초기 로드에 필요한 청크 수는 5~8개 이내로 유지하는 걸 권장해.
9. 측정 없이는 최적화도 없다
여기서 제일 중요한 얘기를 할게.
"측정하지 않고 하는 최적화는 그냥 추측이다."
감으로 "이게 무거울 것 같은데?" 하고 손대면 십중팔구 헛수고야.
실제로 열어보면 예상도 못한 곳에서 500KB가 튀어나오거든.
① 번들 분석기 — 범인 찾기
# Webpack
npm i -D webpack-bundle-analyzer
# Vite / Rollup
npm i -D rollup-plugin-visualizer
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [
visualizer({ open: true, gzipSize: true, brotliSize: true })
]
};
실행하면 트리맵이 나와. 큰 사각형이 큰 모듈이야.
여기서 "어? 이 라이브러리를 내가 왜 쓰고 있지?" 하는 순간이 반드시 와. 장담해.
② Coverage 탭 — 안 쓰는 코드 찾기
크롬 개발자도구 → Cmd/Ctrl + Shift + P → "Show Coverage" 입력.
녹화 버튼 누르고 페이지를 새로고침하면, 빨간색 = 실행되지 않은 코드야.
대부분의 사이트가 초기 로드 시 JS의 50~70%를 사용하지 않아.
그 빨간 영역이 바로 여러분이 쪼개야 할 대상이야.
③ Lighthouse & 실사용자 지표
| 도구 | 성격 | 특징 |
|---|---|---|
| Lighthouse | 랩 데이터 | 제어된 환경, 재현 가능, CI 연동 가능 |
| Performance 탭 | 랩 데이터 | 롱태스크·메인스레드 병목 상세 분석 |
| web-vitals 라이브러리 | 필드 데이터 | 실제 사용자 수치 수집. 가장 정확 |
| CrUX / Search Console | 필드 데이터 | 구글이 집계한 실사용자 데이터 |
import { onLCP, onINP, onCLS, onTTFB } from 'web-vitals';
function report({ name, value, rating, id }) {
navigator.sendBeacon('/analytics', JSON.stringify({ name, value, rating, id }));
}
onLCP(report);
onINP(report);
onCLS(report);
onTTFB(report);
랩 데이터만 믿으면 안 되는 이유
Lighthouse 점수 95점인데 실사용자 LCP가 4초인 경우가 정말 흔해.
사용자의 기기, 네트워크, 캐시 상태, 지역이 전부 다르거든.
랩 데이터로 개선하고, 필드 데이터로 검증해라. 이게 정석이야.
④ 예산(Performance Budget) 정하기
측정만 하고 끝내면 시간이 지나면서 다시 뚱뚱해져. 반드시 자동화된 가드레일을 걸어둬.
// webpack.config.js
performance: {
maxEntrypointSize: 250000, // 250KB
maxAssetSize: 200000,
hints: 'error' // 초과하면 빌드 실패
}
CI에 size-limit나 bundlesize를 붙여서 PR 단계에서 막는 것도 추천.
"이 PR은 번들을 +48KB 증가시킵니다"라는 봇 코멘트만큼 강력한 게 없어.
10. 흔한 함정과 안티패턴 총정리
실무에서 많이 보는 실수들을 모아봤어. 하나씩 체크해보자.
함정 ① 변수를 넣은 동적 import
// ❌ 번들러가 뭘 포함해야 할지 모름
const name = getComponentName();
await import(name);
// ⚠️ 이건 되지만 폴더 전체를 청크로 만들어버림
await import(`./components/${name}.js`);
// ✅ 명시적 매핑 — 가장 안전
const loaders = {
chart: () => import('./components/Chart.js'),
editor: () => import('./components/Editor.js'),
};
await loaders[name]?.();
번들러는 정적 분석으로 동작해. 런타임에 결정되는 경로는 추적할 수 없어.
템플릿 리터럴은 해당 디렉터리의 모든 파일을 청크로 만들어버려서 오히려 역효과가 날 수 있어.
함정 ② 워터폴 로딩
// ❌ 순차 실행 — 3개면 3배 시간
const a = await import('./a.js');
const b = await import('./b.js');
const c = await import('./c.js');
// ✅ 병렬 실행
const [a, b, c] = await Promise.all([
import('./a.js'),
import('./b.js'),
import('./c.js'),
]);
더 심한 건 중첩 워터폴이야.
A 청크를 받았더니 그 안에서 또 B를 동적 import하고, B 안에서 또 C를...
이러면 네트워크 왕복이 계속 쌓여. 스플리팅 깊이는 2단계 이내로 유지하는 게 좋아.
함정 ③ 너무 잘게 쪼개기
버튼 컴포넌트 하나까지 lazy로 만드는 건 오버야.
2KB짜리 컴포넌트를 분리하면 얻는 이득(2KB)보다 요청 비용이 더 커.
기준선: 30KB 미만이면 굳이 쪼개지 마.
함정 ④ 레이아웃 시프트 유발
로딩 fallback이 실제 컴포넌트와 크기가 다르면, 로드 완료 순간 화면이 출렁여.
fallback은 실제 컴포넌트와 비슷한 높이의 스켈레톤으로 만들어줘.
<Suspense fallback={<div style={{ minHeight: 420 }} className="skeleton" />}>
<HeavyChart />
</Suspense>
함정 ⑤ SEO와 SSR 고려 누락
검색엔진에 노출돼야 하는 핵심 콘텐츠를 레이지 로딩하면 위험해.
크롤러가 JS를 실행하긴 하지만, 스크롤 이벤트나 클릭은 발생시키지 않아.
IntersectionObserver 기반으로 로드되는 본문은 인덱싱되지 않을 수 있어.
Next.js에서는 이렇게 제어해:
import dynamic from 'next/dynamic';
// SEO 중요 → SSR 유지하면서 청크만 분리
const Article = dynamic(() => import('./Article'));
// 브라우저 전용 (window 사용) → SSR 끄기
const MapView = dynamic(() => import('./MapView'), {
ssr: false,
loading: () => <MapSkeleton />
});
함정 ⑥ 배포 후 구버전 청크 404
앞에서 ErrorBoundary로 다뤘지만, 근본 대책도 알아두자.
· 이전 버전 청크 파일을 즉시 삭제하지 말고 일정 기간 유지 (보통 2~7일)
· 새 버전 배포 감지 시 사용자에게 "새로고침" 토스트 안내
· Service Worker로 캐시 버전 관리
함정 ⑦ 로딩 상태 UX 방치
스피너를 아무 데나 띄우는 것보다, 다음 원칙을 기억해:
| 로딩 시간 | 권장 UX |
|---|---|
| 0 ~ 200ms | 아무것도 표시하지 않음 (깜빡임이 더 나쁨) |
| 200ms ~ 1초 | 스켈레톤 UI 또는 인라인 스피너 |
| 1초 ~ 5초 | 진행 표시 + 맥락 있는 메시지 |
| 5초 이상 | 진행률 + 취소 옵션 제공 |
11. 실전 시나리오 — 이커머스 상세 페이지 리팩터링
이론은 충분히 봤으니, 가상의 실전 케이스로 전체를 엮어보자.
[Before] 초기 번들 2.8MB · LCP 4.2초 · TBT 1,100ms
메인 번들 구성 내역:
· React + 라우터: 150KB
· 상품 상세 컴포넌트: 80KB
· 리뷰 작성 에디터(Quill): 420KB
· 매출 차트(Chart.js): 280KB
· 매장 지도 SDK: 310KB
· 이미지 줌 라이브러리: 90KB
· 결제 모듈: 350KB
· moment.js + 전체 로케일: 290KB
· lodash 전체: 70KB
· 기타 유틸/스타일: 나머지
Step 1. 라이브러리 교체 (즉시 효과)
moment(290KB) → dayjs(약 7KB) 교체. 필요한 플러그인만 추가.
lodash 전체(70KB) → 개별 함수 import로 약 5KB.
이것만으로 약 350KB 절감. 코드 변경도 거의 없어.
Step 2. 조건부 UI 레이지 로딩
// 리뷰 작성 에디터 — "리뷰 쓰기" 클릭 시에만
const ReviewEditor = lazy(() => import('./ReviewEditor'));
// 지도 — "매장 찾기" 탭 선택 시에만
const StoreMap = lazy(() => import('./StoreMap'));
// 이미지 줌 — 확대 아이콘 클릭 시
async function openZoom(src) {
const { createZoom } = await import('./image-zoom');
createZoom(src);
}
에디터 420KB + 지도 310KB + 줌 90KB = 820KB 제거.
게다가 통계를 보면 실제로 리뷰를 작성하는 사용자는 전체의 3% 미만이야.
97%의 사용자가 받지 않아도 되는 코드였던 거지.
Step 3. 차트 — 스크롤 진입 시 로드
const io = new IntersectionObserver(async ([e], obs) => {
if (!e.isIntersecting) return;
obs.disconnect();
const { renderPriceChart } = await import('./price-chart');
renderPriceChart(e.target);
}, { rootMargin: '400px' });
io.observe(document.querySelector('#price-history'));
가격 변동 차트는 페이지 한참 아래에 있어. 280KB 추가 제거.
Step 4. 결제 모듈 — 프리페치 조합
결제는 레이지 로딩하되, 장바구니에 상품을 담는 순간 백그라운드 프리페치를 걸어.
function onAddToCart(item) {
addItem(item);
// 구매 의도가 확인됨 → 유휴 시간에 미리 로드
if (canPrefetch()) {
requestIdleCallback(() => import('./checkout'));
}
}
초기 번들에서는 350KB가 빠지고, 실제 결제 버튼을 누를 땐 이미 준비 완료. 완벽하지?
Step 5. 이미지 최적화
· 히어로 상품 이미지: fetchpriority="high", loading="eager", WebP 변환
· 상세 설명 이미지 30장: loading="lazy" + width/height 명시
· 썸네일: srcset으로 디바이스별 적정 크기 제공
[After] 초기 번들 약 410KB · LCP 1.3초 · TBT 190ms
번들 크기 -85% / LCP -69% / TBT -83%
코드를 지운 게 아니라 배달 시점만 조정했을 뿐인데 이 정도야.
그리고 대부분의 작업은 하루 이틀이면 충분해.
12. 최신 기법 몇 가지 더
Islands Architecture / Partial Hydration
Astro, Qwik 같은 프레임워크가 밀고 있는 방식이야.
페이지 전체를 JS로 하이드레이션하지 말고,
실제로 인터랙션이 필요한 "섬(island)" 부분만 JS를 붙이는 거야.
나머지 정적 텍스트 영역은 순수 HTML로 남겨두고.
<!-- Astro 예시 -->
<StaticHeader /> <!-- JS 0바이트 -->
<SearchBox client:idle /> <!-- 유휴 시간에 로드 -->
<CommentList client:visible /> <!-- 보일 때 로드 -->
<ShareButton client:media="(min-width: 768px)" /> <!-- 조건부 -->
이건 코드 스플리팅 사고방식을 아키텍처 레벨로 끌어올린 것이라고 볼 수 있어.
React Server Components (RSC)
Next.js App Router에서 쓰는 방식인데, 발상이 좀 충격적이야.
서버 컴포넌트는 서버에서만 실행되고, 그 컴포넌트가 사용한 라이브러리는
클라이언트 번들에 아예 포함되지 않아.
// app/post/page.jsx — 기본이 서버 컴포넌트
import { marked } from 'marked'; // 이 라이브러리, 클라이언트로 안 감!
export default async function Post() {
const md = await fetchMarkdown();
return <article dangerouslySetInnerHTML={{ __html: marked(md) }} />;
}
마크다운 파서 같은 건 굳이 브라우저로 보낼 이유가 없잖아? 딱 그 발상이야.
상호작용이 필요한 부분만 'use client'를 붙여서 분리하면 돼.
Import Maps — 번들러 없는 세상
<script type="importmap">
{
"imports": {
"lodash-es/": "https://cdn.jsdelivr.net/npm/lodash-es@4/"
}
}
</script>
<script type="module">
const { default: debounce } = await import('lodash-es/debounce.js');
</script>
소규모 프로젝트나 프로토타입에서는 빌드 없이도 동적 로딩이 가능해.
모던 브라우저에서 널리 지원되니 한번 실험해볼 만해.
Web Worker로 메인 스레드 비우기
코드 스플리팅과는 결이 다르지만, 같은 목표(메인 스레드 보호)를 가진 기법이야.
// 무거운 계산은 워커로
const worker = new Worker(new URL('./heavy-calc.js', import.meta.url), {
type: 'module'
});
worker.postMessage({ data: bigArray });
worker.onmessage = (e) => render(e.data);
번들러들이 new URL(..., import.meta.url) 패턴을 인식해서
워커 파일도 자동으로 별도 청크로 분리해줘.
13. 실전 체크리스트
프로젝트에 바로 적용할 수 있게 순서대로 정리했어. 위에서부터 하나씩 해보면 돼.
[1단계] 현황 파악
☐ 번들 분석기로 트리맵 확인
☐ Coverage 탭으로 미사용 코드 비율 측정
☐ Lighthouse 모바일 + CPU 4x slowdown으로 기준 점수 기록
☐ 상위 5개 무거운 의존성 목록 작성
[2단계] 쉬운 것부터
☐ moment → dayjs, lodash 전체 import 제거
☐ 첫 화면 밖 이미지에 loading="lazy" + width/height
☐ 히어로 이미지에 fetchpriority="high"
☐ 사용하지 않는 패키지 제거 (depcheck 활용)
[3단계] 구조적 분리
☐ 라우트 단위 코드 스플리팅 (첫 화면 제외)
☐ 모달/에디터/차트/지도 컴포넌트 레이지 로딩
☐ 벤더 청크 분리 + runtimeChunk 설정
☐ Suspense fallback을 스켈레톤으로 (CLS 방지)
[4단계] 체감 속도 개선
☐ hover/focus/touchstart 프리로드 적용
☐ IntersectionObserver + rootMargin 여유값
☐ saveData / effectiveType 확인 후 프리페치
☐ preconnect로 외부 도메인 사전 연결
[5단계] 안정성과 유지
☐ ChunkLoadError 처리 (ErrorBoundary + 새로고침 가드)
☐ 이전 버전 청크 파일 보관 정책 수립
☐ CI에 번들 사이즈 예산 설정
☐ web-vitals로 실사용자 지표 수집 시작
전부 다 할 필요는 없어. 1~3단계만 해도 대부분의 프로젝트는 충분히 빨라져.
4~5단계는 서비스가 커지면 그때 챙겨도 늦지 않아.
14. 마무리 — 결국은 "배려"의 문제
여기까지 왔으면 이제 감이 잡혔을 거야.
코드 스플리팅과 레이지 로딩은 사실 대단한 기술이 아니야.
문법으로 치면 import() 하나, 속성으로 치면 loading="lazy" 하나.
배우는 데 10분이면 충분해.
진짜 어려운 건 "무엇을, 언제 보낼지 판단하는 것"이야.
그리고 그 판단은 결국 사용자를 얼마나 구체적으로 상상하느냐에 달려 있어.
"이 사용자는 지금 무엇을 하려고 하는가?
이 코드는 그 순간에 정말 필요한가?
이 사람의 데이터 요금과 배터리를 내가 존중하고 있는가?"
이 세 가지 질문을 던질 수 있다면, 이미 좋은 프론트엔드 개발자야.
그리고 잊지 마. 완벽한 최적화보다 측정된 개선이 낫다.
0.1초라도 줄었다면 그건 성공이야. 한 번에 다 하려다 아무것도 못 하는 것보다,
오늘 라우트 하나 분리하는 게 훨씬 값져.
더 깊이 파고들고 싶다면
성능 최적화는 혼자 공부하면 "내가 제대로 하고 있나?" 확신이 안 설 때가 많아.
이럴 땐 실무 경험이 있는 사람의 코드 리뷰 한 번이 며칠의 삽질을 아껴줘.
재능넷의 '지식인의 숲'에는 프론트엔드 성능, 번들러 설정, 아키텍처 설계처럼
현장에서 축적된 노하우들이 계속 쌓이고 있어. 막히는 지점이 생기면 한 번 둘러봐.
비슷한 고민을 먼저 겪어본 사람의 기록만큼 좋은 교재도 드물거든.
한 줄 요약
지금 필요한 것만 지금 보내고, 나머지는 필요해질 때 보내라.
그리고 "필요해질 것 같은 순간"을 예측해서 미리 준비해둬라.
이게 전부야. 오늘 프로젝트 열어서 번들 분석기부터 돌려보자.
분명히 놀랄 거야. 그리고 그 놀람이 최적화의 시작이야.
그럼, 가볍고 빠른 웹 만들자. 화이팅! 🚀
관련 키워드
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.