프론트엔드 번들 최적화 — 체감 성능으로 연결되는 실전 체크리스트

게시일: 2025년 4월 11일 · 14분 읽기

번들 사이즈 줄였는데 사용자는 못 느꼈다. 체감 속도는 다른 문제다.

우리 팀이 음성 채팅 웹앱의 번들을 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. First Contentful Paint 개선 (초기 렌더링)
2. Interaction to Next Paint 개선 (버튼 클릭 반응성)
3. 메인 스레드 blocking 제거

우리가 해야 했던 것:

체크리스트

□ 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)

결론

번들 크기와 체감 성능은 다르다. 번들을 줄이는 것도 중요하지만, 사용자가 느끼는 속도는 렌더링과 상호작용이다.

최고의 최적화: 번들 줄이기 + 렌더링 최적화 + 인터랙션 개선

iL
ian.lab

실무 개발자입니다. 현장에서 겪은 문제와 해결 과정을 기록합니다. 오류 제보는 연락처로 보내주세요.