프론트엔드 번들 최적화 — 체감 성능으로 연결되는 실전 체크리스트
번들 사이즈 줄였는데 사용자는 못 느꼈다. 체감 속도는 다른 문제다.
우리 팀이 음성 채팅 웹앱의 번들을 2.4MB → 1.1MB로 줄였다. 하지만 사용자 만족도는 크게 달라지지 않았다. 뭐가 문제였나?
번들 분석 도구
먼저 어디가 무거운지 파악해야 한다:
npm install --save-dev webpack-bundle-analyzer
webpack.config.js:
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
openAnalyzer: true,
}),
],
};
우리 앱의 번들 분석 결과:
react-dom: 312KB (15%)
socket.io-client: 198KB (10%)
waveform-js: 287KB (14%) ← 음성 파형 그리기
lodash: 89KB (4%) ← 불필요
moment.js: 67KB (3%) ← date-fns로 대체 가능
Tree Shaking 함정
이상한 점: lodash를 제거했는데도 번들 크기가 크게 줄지 않았다.
원인: tree shaking이 완벽하지 않다.
나쁜 예:
// ❌ Tree shaking 안 됨
import { debounce } from 'lodash';
export function MyComponent() {
// ...
}
Webpack은 "debounce가 쓰이나?"를 확인한다. 쓰이더라도, 같은 파일에서 다른 lodash 함수가 쓰이면 전체 lodash가 포함될 수 있다.
좋은 예:
// ✅ Tree shaking 됨
import debounce from 'lodash-es/debounce';
lodash-es는 ES6 모듈로, tree shaking이 완벽하다.
아니면 처음부터 lodash를 안 쓴다:
// ✅ 최고
function debounce(func, delay) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), delay);
};
}
이 함수는 10줄이다. lodash 전체를 쓸 필요가 없다.
Dynamic Import로 지연 로딩
모든 코드를 한 번에 로드할 필요가 없다.
처음에는:
// main.tsx
import CallView from './pages/CallView';
import SettingsView from './pages/SettingsView';
import HistoryView from './pages/HistoryView';
export function App() {
if (route === 'call') return <CallView />;
if (route === 'settings') return <SettingsView />;
if (route === 'history') return <HistoryView />;
}
// 모든 페이지 코드가 메인 번들에 포함됨
개선:
// ✅ 동적 import
import { lazy, Suspense } from 'react';
const CallView = lazy(() => import('./pages/CallView'));
const SettingsView = lazy(() => import('./pages/SettingsView'));
const HistoryView = lazy(() => import('./pages/HistoryView'));
export function App() {
return (
<Suspense fallback={<Loading />}>
{route === 'call' && <CallView />}
{route === 'settings' && <SettingsView />}
{route === 'history' && <HistoryView />}
</Suspense>
);
}
// 각 페이지가 별도 청크로 분할됨
// 필요할 때만 로드
효과: 초기 로딩 번들 40% 감소
이미지 최적화
이미지가 생각보다 크다.
우리 앱의 이미지:
avatar.png: 512KB (고해상도, 4000x4000)
background.jpg: 1.2MB
icon-set.svg: 87KB
최적화:
// 1. 이미지 리사이징
// ❌ 4000x4000 -> CSS로 200x200으로 표시
// ✅ 실제 200x200 이미지 사용
// 2. 포맷 변경
// ❌ PNG (512KB)
// ✅ WebP (156KB) + PNG fallback
// 3. SVG 스프라이트
// ❌ 각 아이콘이 개별 파일
// ✅ 한 번에 로드, CSS로 표시
Webpack에서:
module.exports = {
module: {
rules: [
{
test: /\.(png|jpg|jpeg|gif)$/i,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 8 * 1024, // 8KB 이상은 파일로
},
},
generator: {
filename: 'images/[name].[hash][ext]',
},
},
{
test: /\.svg$/,
use: [
{
loader: '@svgr/webpack',
options: {
native: true,
},
},
],
},
],
},
};
폰트 최적화
웹폰트가 느린 경우가 많다:
/* ❌ 나쁨: 블로킹 로딩 */
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700');
/* ✅ 좋음: 비동기 로딩 */
@font-face {
font-family: 'Roboto';
src: url('/fonts/roboto-400.woff2') format('woff2');
font-weight: 400;
font-display: swap; // ← 중요!
}
@font-face {
font-family: 'Roboto';
src: url('/fonts/roboto-700.woff2') format('woff2');
font-weight: 700;
}
font-display: swap의 의미: 폰트 로드 중에도 fallback 폰트로 텍스트를 표시하라. 그러면 사용자가 텍스트를 읽을 수 있다.
Google Fonts 대신 self-hosted:
// ✅ 자체 호스팅이 30% 빠름 (외부 도메인 DNS 조회 없음)
@font-face {
font-family: 'Roboto';
src: url('/fonts/roboto-400.woff2') format('woff2');
}
Code Splitting: 라우트별 청크
초기 로드에서:
메인 청크: 500KB (필수)
Settings 청크: 120KB (필요할 때)
History 청크: 80KB (필요할 때)
사용자가 Settings에 가지 않으면, 120KB를 안 내려받는다.
Minification과 Compression
webpack.config.js:
const TerserPlugin = require('terser-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');
module.exports = {
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
terserOptions: {
compress: {
drop_console: true, // console.log 제거
},
},
}),
],
},
plugins: [
new CompressionPlugin({
algorithm: 'gzip',
test: /\.(js|css|html|svg)$/,
threshold: 10240, // 10KB 이상
}),
],
};
또한 서버는 Brotli compression을 지원해야 한다:
// Nginx
http {
gzip on;
gzip_types application/javascript text/css;
gzip_min_length 1000;
}
체감 성능 vs 기술 성능
우리가 놓친 부분:
기술 성능: 번들 2.4MB → 1.1MB (54% 감소)
사용자 체감: "앱이 약간 더 빠른 것 같은데?"
이유:
- 초기 로딩은 1초 정도 빨라짐 (유지 시간이 오래라서 누적이 크지 않음)
- JavaScript 실행 시간은 크게 달라지지 않음
- React 렌더링은 여전히 느림
진짜 영향을 미치려면:
1. First Contentful Paint 개선 (초기 렌더링)
2. Interaction to Next Paint 개선 (버튼 클릭 반응성)
3. 메인 스레드 blocking 제거
우리가 해야 했던 것:
- React.memo로 불필요한 렌더링 제거
- useCallback/useMemo로 함수 재생성 방지
- Web Worker로 무거운 계산을 메인 스레드 밖으로
체크리스트
□ webpack-bundle-analyzer로 분석했는가?
□ Tree shaking 설정 확인했는가? (side-effects: false)
□ Lazy loading과 code splitting 적용했는가?
□ 이미지 최적화 (resize, WebP) 했는가?
□ 폰트 최적화 (font-display: swap) 했는가?
□ gzip/brotli compression 설정했는가?
□ 메인 스레드 blocking 제거했는가?
□ Core Web Vitals 측정했는가? (Lighthouse)
결론
번들 크기와 체감 성능은 다르다. 번들을 줄이는 것도 중요하지만, 사용자가 느끼는 속도는 렌더링과 상호작용이다.
최고의 최적화: 번들 줄이기 + 렌더링 최적화 + 인터랙션 개선