Vite 빌드, 왜 이렇게 느려? 실전에서 배우는 번들 사이즈 최적화와 로딩 속도 향상 팁
Vite의 빠른 개발 경험에 만족하다가도, 프로덕션 빌드에서 예상치 못한 병목을 만난 적 있으신가요? 이 글에서는 실제 프로젝트에서 마주쳤던 빌드 성능 저하 문제들을 진단하고, 번들 분석부터 청크 분리, 자원 압축, 이미지 최적화까지 실용적인 기법들을 코드 예시와 함께 깊이 있게 다룹니다. 여러분의 Vite 프로젝트를 더 빠르고 효율적으로 만드는 데 필요한 핵심 노하우를 얻어가세요!
최근 한 프로젝트에서 Vite를 도입하며 개발 생산성을 한껏 끌어올렸습니다. 번개같이 빠른 개발 서버 덕분에 DX(Developer Experience)는 최상이었죠. 하지만 기쁨도 잠시, 프로덕션 빌드 단계에 진입하자 예상치 못한 복병이 나타났습니다. 빌드 시간이 기하급수적으로 늘어나기 시작했고, 심지어 배포된 서비스의 초기 로딩 속도까지 느려지는 문제가 발생한 겁니다. 사용자 경험은 물론, CI/CD 파이프라인의 시간까지 잡아먹는 이 빌드 문제는 결국 팀의 생산성 저하로 이어졌습니다. 오늘은 제가 실제 프로젝트에서 이 빌드 최적화 문제에 부딪히고, 다양한 시행착오 끝에 얻어낸 실용적인 Vite 빌드 최적화 팁들을 공유하고자 합니다.
1. 번들 사이즈 진단: rollup-plugin-visualizer로 시각화하기
문제 상황: 프로젝트 규모가 커지면서 새로운 기능을 추가할 때마다 빌드 시간이 늘어나고, 프로덕션 환경에서 페이지 로딩이 눈에 띄게 느려졌습니다. 처음에는 어디서부터 손대야 할지 감조차 잡을 수 없었죠. 단순히 “번들이 커졌나?” 하고 막연하게 짐작만 할 뿐, 정확히 어떤 파일이나 라이브러리가 문제인지 알 길이 없었습니다.
해결 과정: 이런 막연함을 해결해준 첫 번째 도구는 바로 rollup-plugin-visualizer였습니다. Vite는 내부적으로 Rollup을 사용하기 때문에 Rollup 플러그인들을 그대로 활용할 수 있다는 점이 큰 장점이죠. 이 플러그인을 vite.config.js에 추가하여 빌드 번들 맵을 시각화했습니다.
1// vite.config.js 2import { defineConfig } from 'vite'; 3import react from '@vitejs/plugin-react'; 4import { visualizer } from 'rollup-plugin-visualizer'; 5 6export default defineConfig({ 7 plugins: [ 8 react(), 9 visualizer({ open: true, brotliSize: true, gzipSize: true }), // 빌드 후 자동으로 시각화 페이지 오픈 10 ], 11 build: { 12 rollupOptions: { 13 output: { 14 manualChunks(id) { 15 // 예시: 특정 라이브러리를 별도 청크로 분리 16 if (id.includes('node_modules')) { 17 if (id.includes('react') || id.includes('react-dom')) { 18 return 'vendor-react'; 19 } 20 if (id.includes('lodash') || id.includes('dayjs')) { 21 return 'vendor-utils'; 22 } 23 return 'vendor'; 24 } 25 }, 26 }, 27 }, 28 }, 29});
경험에서 얻은 인사이트: 플러그인을 적용하고 빌드를 실행한 뒤 시각화된 번들 맵을 봤을 때, 저는 큰 충격을 받았습니다. 사용하지도 않는 거대한 캘린더 라이브러리가 통째로 포함되어 있거나, 특정 유틸리티 함수 하나를 쓰겠다고 lodash 전체를 불러오는 '비극적인' 상황들을 발견했습니다. 또 다른 경우엔, 특정 모듈이 여러 번 중복해서 번들링되고 있는 문제도 있었습니다. 시각화 도구가 없었다면 이런 문제들을 찾아내기까지 훨씬 오랜 시간이 걸렸거나, 아예 놓쳤을 수도 있었을 겁니다. 번들 맵은 마치 X-ray 사진처럼 프로젝트의 비대해진 부분을 정확히 짚어주는 역할을 했습니다.
2. 번들 청크 분리 (Code Splitting): 지연 로딩과 전략적 manualChunks
문제 상황: 번들 맵을 통해 비대한 덩어리를 발견했지만, 그걸 어떻게 쪼갤지가 문제였습니다. 모든 코드를 메인 진입점에서 한 번에 로딩하니 초기 사용자 경험이 엉망이었죠. 특히 관리자 페이지나 특정 모달 컴포넌트처럼 특정 사용자만 접근하거나 특정 상황에서만 사용되는 기능까지 메인 번들에 포함되어 있었습니다. 이로 인해 사용자가 메인 페이지에 접속했을 때 불필요한 코드까지 함께 다운로드해야 했고, 이는 곧 긴 로딩 시간으로 이어졌습니다.
해결 과정: 이 문제를 해결하기 위해 dynamic import와 Vite의 manualChunks 옵션을 활용했습니다.
2.1. 동적 임포트 (Dynamic Import)를 활용한 라우트 기반 코드 스플리팅
사용자가 특정 라우트에 진입할 때만 해당 컴포넌트와 종속성을 로드하는 방식입니다. React의 경우 React.lazy와 Suspense를 활용하면 간편하게 구현할 수 있습니다.
Before (모든 코드를 한 번에 로딩)
1// App.jsx - Before 2import HomePage from './pages/HomePage'; 3import AdminPage from './pages/AdminPage'; 4import SettingsPage from './pages/SettingsPage'; 5 6function App() { 7 return ( 8 <Router> 9 <Routes> 10 <Route path="/" element={<HomePage />} /> 11 <Route path="/admin" element={<AdminPage />} /> 12 <Route path="/settings" element={<SettingsPage />} /> 13 </Routes> 14 </Router> 15 ); 16}
After (동적 임포트를 통한 지연 로딩)
1// App.jsx - After 2import React, { lazy, Suspense } from 'react'; 3import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; 4 5const HomePage = lazy(() => import('./pages/HomePage')); 6const AdminPage = lazy(() => import('./pages/AdminPage')); 7const SettingsPage = lazy(() => import('./pages/SettingsPage')); 8 9function App() { 10 return ( 11 <Router> 12 <Suspense fallback={<div>Loading...</div>}> 13 <Routes> 14 <Route path="/" element={<HomePage />} /> 15 <Route path="/admin" element={<AdminPage />} /> 16 <Route path="/settings" element={<SettingsPage />} /> 17 </Routes> 18 </Suspense> 19 </Router> 20 ); 21}
경험에서 얻은 인사이트: 이 방식은 초기 로딩 시간을 획기적으로 줄여주었을 뿐만 아니라, 사용자가 필요한 시점에만 코드를 다운로드하게 함으로써 네트워크 리소스 낭비를 줄여주었습니다. 특히 사용 빈도가 낮은 관리자 페이지나 복잡한 대시보드 컴포넌트에 적용했을 때 효과가 가장 컸습니다.
2.2. manualChunks를 활용한 전략적 청크 분리
Vite (Rollup)는 기본적으로 코드 스플리팅을 지원하지만, manualChunks 옵션을 사용하면 더 세밀하게 번들을 제어할 수 있습니다. 특히 자주 변하지 않는 외부 라이브러리들을 별도의 청크로 분리하면, 변경이 잦은 애플리케이션 코드만 재다운로드하면 되므로 캐싱 효율을 높일 수 있습니다.
Before (기본 청크 분리): 모든 node_modules가 하나의 큰 vendor 청크로 묶이거나, Vite가 임의로 청크를 나눔.
After (manualChunks를 통한 세부 분리)
1// vite.config.js - build.rollupOptions.output 안 2// ... 3 output: { 4 manualChunks(id) { 5 // 모든 node_modules를 'vendor' 청크로 묶되, 특정 라이브러리는 별도로 분리 6 if (id.includes('node_modules')) { 7 if (id.includes('react') || id.includes('react-dom')) { 8 return 'vendor-react'; // React 관련 라이브러리 9 } 10 if (id.includes('axios') || id.includes('dayjs')) { 11 return 'vendor-core-libs'; // 핵심 유틸리티 라이브러리 12 } 13 // 그 외의 모든 node_modules는 'vendor'로 묶음 14 return 'vendor'; 15 } 16 // 특정 애플리케이션 모듈 그룹화 (예: 'charts' 폴더 안의 모든 모듈) 17 if (id.includes('/src/components/charts/')) { 18 return 'app-charts'; 19 } 20 }, 21 }, 22// ...
경험에서 얻은 인사이트: manualChunks를 처음 적용했을 때, 무작정 모든 라이브러리를 개별 청크로 분리하려 했습니다. 하지만 청크가 너무 많아지면 HTTP 요청 수가 늘어나 오히려 성능 저하를 초래하는 역효과를 겪었습니다. 이 경험을 통해 사용자 행동 패턴과 라이브러리의 업데이트 빈도를 분석하여, 자주 함께 사용되는 기능은 묶고 독립적으로 사용되는 기능은 분리하는 전략적인 접근이 필요하다는 것을 깨달았습니다. 예를 들어, React와 React-DOM은 항상 함께 사용되므로 하나의 청크로 묶는 것이 효율적이며, 핵심 UI 라이브러리나 상태 관리 라이브러리처럼 자주 변하지 않는 의존성은 별도로 분리하여 캐싱 효율을 극대화하는 것이 좋았습니다.
3. 자원 압축 및 최적화: terser와 brotli/gzip 활용
문제 상황: 번들 사이즈를 줄이고 청크를 분리해도 여전히 네트워크 전송 시간이 길었습니다. 특히 JS/CSS 파일 자체의 크기를 줄이는 것 외에 더 할 수 있는 것이 없을까 고민했습니다.
해결 과정: Vite는 기본적으로 terser를 사용하여 JavaScript를 압축하지만, 빌드 시점에 brotli나 gzip 같은 압축 포맷으로 파일을 미리 생성해두는 플러그인을 활용하면 네트워크 전송 효율을 극대화할 수 있습니다. 저는 vite-plugin-compression을 도입했습니다.
1// vite.config.js 2import { defineConfig } from 'vite'; 3import react from '@vitejs/plugin-react'; 4import compression from 'vite-plugin-compression'; // 플러그인 임포트 5 6export default defineConfig({ 7 plugins: [ 8 react(), 9 compression({ // gzip 압축 설정 10 verbose: true, // 압축 결과 로그 출력 11 disable: false, // 압축 사용 여부 12 threshold: 10240, // 10KB 이상 파일만 압축 13 algorithm: 'gzip', // 'gzip' 또는 'brotliCompress' 14 ext: '.gz', // 압축 파일 확장자 15 }), 16 compression({ 17 verbose: true, 18 disable: false, 19 threshold: 10240, 20 algorithm: 'brotliCompress', // brotli 압축 설정 21 ext: '.br', 22 }), 23 ], 24 build: { 25 // terser 옵션으로 더 공격적인 JS 압축 가능 26 minify: 'terser', 27 terserOptions: { 28 compress: { 29 drop_console: true, // 콘솔 로그 제거 30 drop_debugger: true, // 디버거 제거 31 }, 32 }, 33 }, 34});
경험에서 얻은 인사이트: 이 플러그인은 빌드 시점에 .gz나 .br 파일을 미리 생성해둡니다. 이렇게 미리 압축된 파일을 만들어두면, Nginx 같은 웹 서버에서 클라이언트의 Accept-Encoding 헤더에 맞춰 최적의 압축 파일을 서빙하도록 간단하게 설정할 수 있습니다. 저희 팀은 초기에는 빌드 파일만 만들고 서버 설정을 놓쳐서 아무 효과를 보지 못하는 헤프닝도 있었습니다. 가장 중요한 것은 빌드 파이프라인에서 압축 파일을 생성하는 것뿐만 아니라, 웹 서버에서 이를 제대로 서빙하도록 설정하는 연동 작업이라는 점입니다. terserOptions를 통해 console.log나 debugger를 제거하는 것도 프로덕션 빌드에서 작은 용량 절약과 보안에 도움이 되었습니다.
4. 이미지 최적화: vite-plugin-imagemin과 전략적 접근
문제 상황: 마케팅 페이지나 랜딩 페이지에서 고해상도 이미지가 많아 모바일 환경에서 매우 느려지는 경험을 했습니다. 디자이너에게 이미지 크기를 줄여달라고 요청하는 것 외에 개발자로서 빌드 단계에서 할 수 있는 최적화가 없을까 고민했습니다.
해결 과정: 이미지 최적화는 단순히 용량을 줄이는 것을 넘어 사용자 경험에 직결되는 중요한 부분입니다. 저는 vite-plugin-imagemin을 사용하여 빌드 시점에 이미지를 자동으로 압축하고, 더 나아가 반응형 이미지 전략을 도입했습니다.
1// vite.config.js 2import { defineConfig } from 'vite'; 3import react from '@vitejs/plugin-react'; 4import imagemin from 'vite-plugin-imagemin'; 5 6export default defineConfig({ 7 plugins: [ 8 react(), 9 imagemin({ 10 gifsicle: { optimizationLevel: 7, interlaced: false }, 11 optipng: { optimizationLevel: 7 }, 12 mozjpeg: { quality: 80 }, // JPEG 품질 조정 13 pngquant: { quality: [0.8, 0.9], speed: 4 }, 14 svgo: { plugins: [{ name: 'removeViewBox' }, { name: 'removeEmptyAttrs' }] }, 15 webp: { quality: 80 }, // WebP 변환 및 품질 조정 16 }), 17 ], 18});
경험에서 얻은 인사이트: vite-plugin-imagemin은 빌드 시점에 JPG, PNG, GIF, SVG 등 다양한 포맷의 이미지를 압축하고, 필요에 따라 WebP나 AVIF 같은 차세대 포맷으로 변환해줍니다. 초기에는 단순히 이 플러그인을 적용하여 이미지 용량을 줄이는 데 집중했지만, 나중에는 <picture> 태그와 srcset을 활용하여 사용자 디바이스 해상도에 맞는 이미지를 제공하고, CDN을 통해 사용자의 지리적 위치에 따라 최적의 이미지를 전송하는 전략까지 확장했습니다. 단순히 플러그인 하나 쓰는 것을 넘어, 디자인 팀과의 협업을 통해 적절한 이미지 해상도 및 포맷 기준을 세우고, 이를 빌드 파이프라인과 CDN 전략에 통합하는 것이 훨씬 중요했습니다. 고품질 이미지가 필요한 곳과 저품질 이미지가 허용되는 곳을 명확히 구분하고, WebP 같은 효율적인 포맷을 적극적으로 사용하는 것이 핵심이었습니다.
💡 핵심 정리: Vite 빌드 최적화는 번들 진단부터 전략적 코드 분리, 자원 압축, 이미지 최적화까지 다각적인 접근이 필요하며, 이는 곧 사용자 경험과 개발 생산성 향상으로 직결됩니다.
- ✅
rollup-plugin-visualizer로 번들 맵을 정기적으로 확인하여 비대한 모듈을 진단하세요. - ✅
dynamic import를 활용한 라우트 기반 코드 스플리팅으로 초기 로딩 속도를 개선하세요. - ✅
manualChunks옵션을 통해 핵심 벤더 라이브러리나 자주 사용되는 모듈 그룹을 전략적으로 분리하여 캐싱 효율을 높이세요. - ✅
vite-plugin-compression을 사용하여 빌드 시점에 JS/CSS 파일을gzip/brotli로 압축하고, 웹 서버에서 이를 올바르게 서빙하도록 설정하세요. - ✅
vite-plugin-imagemin과<picture>태그, CDN을 활용한 포괄적인 이미지 최적화 전략을 수립하세요.
🚀 다음 단계: 이 글에서 다룬 내용 외에도 Vite는 다양한 최적화 가능성을 제공합니다. Rollup의 고급 설정(output.assetFileNames, output.chunkFileNames 등)을 더 깊이 탐구하여 빌드 결과물의 파일명과 구조를 세밀하게 제어해보세요. 또한, Lighthouse나 WebPageTest 같은 도구를 활용하여 실제 서비스의 웹 바이탈(Web Vitals) 지표를 측정하고, 지속적인 모니터링을 통해 최적화의 효과를 검증하는 것도 중요합니다. 서버 사이드 렌더링(SSR)이나 정적 사이트 생성(SSG)이 필요한 경우, Vite와 연동되는 프레임워크(예: Next.js, Nuxt.js)의 빌드 최적화 전략도 함께 고민해볼 수 있습니다.