Vite 6.x 번들 크기를 절반으로 줄이는 manualChunks와 동적 임포트 전략
프로젝트가 커질수록 pnpm build를 치고 커피 한 잔을 끓이러 가게 됩니다. 저도 Webpack으로 1분 넘게 기다리던 시절에는 당연한 일이라고 체념했는데, Vite로 넘어오면서 그 답답함이 한꺼번에 해소됐죠. 그런데 막상 Vite를 쓰더라도 기본 설정만 믿고 있으면 초기 번들이 생각보다 크게 나오거나, 배포 후 브라우저 캐시가 제대로 활용되지 않는 경우를 자주 만납니다.
이 글에서는 manualChunks로 벤더 청크를 분리하고, 라우트 기반 동적 임포트를 적용하는 두 가지 조합으로 초기 번들 크기를 40~60% 줄이고 캐시 히트율을 높이는 방법을 실제 설정 코드와 함께 살펴봅니다. 라우트가 여러 개인 SPA 프로젝트라면 이미 오늘 적용해볼 수 있을 겁니다. 여기에 Vite 6에서 새로 도입된 Environment API를 더하면 SSR이나 Edge 런타임을 함께 개발하는 환경에서도 같은 최적화 경험을 누릴 수 있습니다.
이 글은 Vite를 이미 프로젝트에 사용하고 있는 프론트엔드 개발자를 대상으로 합니다. Webpack에서 이주를 고민 중이라면 장단점 분석 섹션의 비교 내용과 마이그레이션 유의점을 참고해보시면 도움이 될 겁니다.
이 글에서 다루는 내용:
- Vite의 이중 파이프라인 구조와 의존성 사전 번들링의 작동 원리
manualChunks로 벤더 캐시 최적화하는 방법 (순환 참조 주의점 포함)- 라우트 기반 동적 임포트로 초기 번들 크기 줄이기
build.target설정으로 불필요한 폴리필 제거- Vite 6 Environment API 개요
- 네 가지 설정을 합친 통합
vite.config.ts예시
핵심 개념
Vite의 이중 파이프라인 구조
Vite가 빠른 이유는 단순히 "최신 도구라서"가 아닙니다. 개발 서버와 프로덕션 빌드를 완전히 다른 방식으로 처리하는 이중 파이프라인 아키텍처 덕분입니다.
| 모드 | 엔진 | 방식 |
|---|---|---|
| 개발 서버 | esbuild + 네이티브 ESM | 번들링 없이 모듈 요청 시 즉시 변환 |
| 프로덕션 빌드 | esbuild(트랜스파일) + Rollup(번들) | 최적화된 청크 파일 생성 |
소스 파일
↓
esbuild — TypeScript/JSX 트랜스파일
↓
Rollup — 트리쉐이킹, 청크 분할, 해시 생성
↓
dist/ 출력개발할 때는 수천 개의 모듈을 번들하지 않고 브라우저의 ESM(ES Modules) 요청을 그대로 받아 처리합니다. 덕분에 서버 기동이 빠르고, HMR(Hot Module Replacement, 저장 시 변경된 모듈만 교체해 전체 새로고침 없이 화면을 갱신하는 기능)도 10~20ms 수준으로 즉각적입니다. 배포 시에는 Rollup이 꼼꼼히 최적화해 줍니다.
여기서 한 가지 중요한 함정이 있습니다. dev는 번들되지 않은 ESM으로 동작하고, prod는 Rollup으로 번들됩니다. 이 차이 때문에 vite dev에서는 잘 되던 기능이 vite build 후에 깨지는 경우가 간헐적으로 발생합니다. 배포 전에 vite preview로 프로덕션 빌드를 로컬에서 확인하는 습관이 있으면 이 함정을 피할 수 있습니다.
트리쉐이킹(Tree Shaking): 실제로 사용되지 않는 코드를 번들에서 제거하는 기법. Rollup이 ES 모듈의 정적 분석 특성을 활용해
import된 심볼 중 실제 참조되는 것만 남깁니다. 트리쉐이킹이 기대대로 작동하지 않는다면, 라이브러리의package.json에"sideEffects": false가 설정되어 있는지 확인해보시는 것을 권장합니다. 이 설정이 없으면 Rollup이 보수적으로 판단해서 사용하지 않는 코드도 번들에 남겨둘 수 있습니다.
의존성 사전 번들링(Dependency Pre-bundling)
처음 dev server를 켤 때 Vite가 node_modules를 훑는 시간이 있습니다. 이게 바로 사전 번들링 단계입니다. esbuild를 사용해 CommonJS 모듈을 ESM으로 변환하고, lodash처럼 내부 모듈이 수백 개인 패키지를 단일 파일로 합쳐 캐시해 둡니다.
이 작업이 없으면 브라우저가 lodash를 로딩할 때 수백 번의 네트워크 요청을 날리게 됩니다. 사전 번들링 결과물은 node_modules/.vite/deps/에 저장되며, 의존성이 바뀌지 않는 한 재실행되지 않습니다.
코드 스플리팅과 청크 전략
Rollup은 동적 import()를 만날 때마다 청크 경계를 만듭니다. 여기서 두 가지 전략이 조합됩니다.
- 자동 코드 스플리팅: 동적 임포트 지점마다 청크 자동 생성
manualChunks: 개발자가 직접 "어떤 모듈을 어떤 청크로 묶을지" 지정
이 둘을 조합하면 앱 코드가 바뀌어도 라이브러리 청크의 해시가 유지되어, 브라우저 캐시를 훨씬 효과적으로 활용할 수 있습니다.
Vite 6의 Environment API
솔직히 이 기능은 처음 접했을 때 "이게 왜 필요하지?" 싶었는데, SSR이나 Edge 런타임을 함께 개발하는 환경에서 굉장히 유용합니다.
Vite 5까지는 client와 ssr 두 가지 환경만 암묵적으로 지원했는데, Vite 6에서는 이를 명시적이고 확장 가능한 구조로 열었습니다. Cloudflare Workers, Vercel Edge 같은 비Node 런타임에서도 동일한 HMR 경험을 제공할 수 있게 됩니다.
Environment API 상태: Vite 6 기준으로 아직
experimental태그가 붙어 있으며, Vite 7에서 안정화될 예정입니다. 중요 프로덕션 기능에 적용할 때는 이 점을 고려하시는 것이 좋습니다.
실전 적용
예시 1: manualChunks로 벤더 캐시 최적화
가장 즉각적인 효과를 볼 수 있는 설정입니다. 실무에서 자주 맞닥뜨리는 상황인데, 기본 설정으로 빌드하면 React와 앱 코드가 같은 청크에 묶여서 컴포넌트 한 줄 수정할 때마다 사용자들이 수백 KB짜리 React 코드를 새로 받아야 합니다.
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
// React 코어는 별도 청크로 — 거의 바뀌지 않으니 캐시 유지
if (id.includes('react') || id.includes('react-dom')) {
return 'vendor-react'
}
// shadcn/ui는 npm 패키지가 아닌 복사 방식이므로
// 실제로 설치되는 의존성 패키지명으로 명시
if (
id.includes('@radix-ui') ||
id.includes('class-variance-authority') ||
id.includes('cmdk')
) {
return 'vendor-ui'
}
// 나머지 서드파티 의존성
return 'vendor'
}
},
},
},
},
})| 청크 이름 | 포함 내용 | 해시 변경 시점 |
|---|---|---|
vendor-react |
react, react-dom | React 버전 업그레이드 시만 |
vendor-ui |
@radix-ui, class-variance-authority 등 | UI 라이브러리 업데이트 시만 |
vendor |
그 외 node_modules | 서드파티 의존성 변경 시만 |
index |
앱 코드 | 코드 수정마다 |
이렇게 분리하면 앱 코드를 아무리 자주 배포해도 vendor-react 청크는 사용자 브라우저에 캐시된 채로 유지됩니다.
주의 — 순환 참조(Circular Chunk Dependency):
manualChunks를 함수 방식으로 사용할 때, A 청크가 B 청크를, B 청크가 다시 A 청크를 참조하는 순환이 생기면 Rollup이 빌드를 실패시키거나 경고를 냅니다. 공유 유틸리티 함수가 여러 청크에 걸쳐 임포트될 때 자주 발생합니다. 이럴 때는 공유 모듈을 별도shared청크로 분리하면 해결됩니다.
예시 2: 라우트 기반 동적 임포트
초기 번들 크기를 줄이는 가장 직접적인 방법입니다. 사용자가 아직 방문하지 않은 페이지 코드까지 처음부터 받을 이유가 없으니까요. 실제로 이 패턴을 적용하면 초기 번들 크기가 40~60% 줄어드는 경우가 많습니다. 글 제목에서 약속한 "절반"은 바로 이 수치에 근거합니다.
// App.tsx
import React, { Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'
const Dashboard = React.lazy(() => import('./pages/Dashboard'))
const Settings = React.lazy(() => import('./pages/Settings'))
const Analytics = React.lazy(() => import('./pages/Analytics'))
export default function App() {
return (
<Suspense fallback={<div>로딩 중...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
)
}각 import() 호출이 Rollup 청크 경계가 되어, 빌드 결과물에서 페이지별로 별도 JS 파일이 생성됩니다. 여러분 프로젝트에서 번들 분석 결과 크기가 큰 페이지 컴포넌트 하나를 골라 먼저 적용해보시면 바로 효과를 체감할 수 있을 겁니다.
예시 3: build.target으로 불필요한 폴리필 제거
지원 브라우저 범위를 명확히 정의하면 Vite가 폴리필을 최소화해 번들 크기를 줄입니다.
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
build: {
// 최신 브라우저만 지원하는 경우
target: 'es2022',
},
})예를 들어 es2022를 타깃으로 지정하면, 브라우저가 이미 네이티브로 지원하는 Optional Chaining(?.), Nullish Coalescing(??), Array.prototype.at() 등에 대한 폴리필이 번들에서 제거됩니다. 이 문법을 코드 전반에 폭넓게 사용하고 있다면 폴리필 제거 효과가 꽤 클 수 있습니다.
build.target선택 기준:es2022는 2022년 이후 출시된 주요 브라우저를 타깃으로 합니다. 구형 브라우저 지원이 필요하다면@vitejs/plugin-legacy를 함께 사용하는 것을 권장합니다.
예시 4: Environment API로 Edge/SSR 동시 개발
Cloudflare Workers나 Vercel Edge에 배포하는 프로젝트라면 환경별 모듈 resolve 조건을 명시적으로 구성할 수 있습니다.
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
environments: {
ssr: {
resolve: {
conditions: ['node'],
},
},
edge: {
resolve: {
// Cloudflare Workers 런타임
conditions: ['workerd'],
},
},
},
})단일 dev server에서 여러 환경을 동시에 돌리며 HMR까지 받을 수 있어서, 예전처럼 환경마다 별도 서버를 띄우던 번거로움이 사라집니다.
통합 예시: 하나의 vite.config.ts에 모두 담기
위의 설정들을 실제 프로젝트에서 어떻게 합치는지, 번들 시각화 플러그인까지 포함한 완성 예시입니다.
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
react(),
// 빌드 후 stats.html 파일로 청크 트리맵 자동 오픈
visualizer({ open: true }),
],
build: {
target: 'es2022',
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('react') || id.includes('react-dom')) {
return 'vendor-react'
}
if (
id.includes('@radix-ui') ||
id.includes('class-variance-authority') ||
id.includes('cmdk')
) {
return 'vendor-ui'
}
return 'vendor'
}
},
},
},
},
environments: {
ssr: {
resolve: { conditions: ['node'] },
},
},
})이 파일 하나로 벤더 청크 분리, 폴리필 최소화, 번들 시각화까지 한 번에 챙길 수 있습니다. visualizer 플러그인은 빌드 후 stats.html을 자동으로 열어 청크 구조를 트리맵으로 보여줍니다. 처음 적용할 때 바로 이 트리맵부터 보시는 것을 권장합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 개발 서버 기동 속도 | ESM 네이티브 방식으로 수천 모듈 프로젝트도 1~2초 내 준비 완료 |
| HMR 응답속도 | 변경된 모듈만 교체, 10~20ms 수준의 즉각 반영 |
| 청크 전략 유연성 | 자동 코드 스플리팅 + manualChunks 조합으로 캐시 히트율 극대화 |
| Environment API | SSR/Edge/브라우저를 단일 서버에서 통합 관리 |
| Rolldown 도입 | Vite 8 베타(2025년 12월 출시)에서 Rust 기반 Rolldown이 프로덕션 번들러로 공식 채택됨. Rollup 대비 빌드 속도 10~30배 향상 |
| Webpack 대비 속도 | 콜드스타트 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 개발/프로덕션 동작 불일치 | dev는 unbundled ESM, prod는 Rollup 번들로 동작이 달라 간헐적 버그 발생 | vite preview로 프로덕션 빌드를 로컬에서 검증하는 습관 |
| 초대형 프로젝트 콜드스타트 | 수만 개 모듈을 가진 모노레포에서 Vite 6~7 콜드스타트 성능 저하 이슈 보고 | Rolldown 기반 Vite 8로 업그레이드 검토 |
| 배럴 파일(barrel file) 문제 | index.ts로 모든 걸 재내보내면 모듈 분석 비용 폭발 |
직접 경로 임포트(import { Foo } from './components/Foo') 권장 |
| Environment API 안정성 | Vite 6 기준 experimental 상태 | 중요 프로덕션 기능은 Vite 7 안정화 이후 적용 고려 |
| 과도한 청크 분할 | HTTP/2 미사용 환경에서 청크 20~30개 초과 시 오히려 성능 저하 | rollup-plugin-visualizer로 청크 수 주기적 모니터링 |
| manualChunks 순환 참조 | 함수 방식 사용 시 청크 간 순환 의존이 생길 수 있음 | 공유 모듈을 별도 shared 청크로 분리 |
배럴 파일(Barrel File):
export * from './ComponentA'형태로 하위 모듈을 한 곳에서 모아 내보내는index.ts패턴. 편리하지만 Vite(Rollup)가 실제 사용 여부를 분석하기 위해 모든 하위 모듈을 처리해야 하므로, 규모가 커지면 빌드 분석 비용이 급증합니다.
Webpack에서 이주를 고민 중이라면
Vite가 속도 면에서 압도적으로 빠른 건 맞지만, 이주 시 주의할 점이 몇 가지 있습니다. CommonJS require() 구문은 Vite에서 바로 동작하지 않아 ESM으로 변환이 필요합니다. Webpack의 require.context나 커스텀 로더를 사용하고 있다면 Vite 플러그인으로 대체할 수 있는지 먼저 확인해보시는 것을 권장합니다. 마이그레이션 후에는 반드시 vite build && vite preview로 프로덕션 동작을 검증해보시면 좋습니다.
실무에서 가장 흔한 실수
-
manualChunks없이 그냥 빌드하기 — 기본 설정만으로도 잘 동작하지만, 라이브러리 코드와 앱 코드가 섞여 배포할 때마다 사용자가 모든 청크를 새로 받게 됩니다.manualChunks를 설정하는 것만으로도 캐시 효율이 크게 달라집니다. -
vite preview없이vite dev로만 테스트하기 — 개발 서버와 프로덕션 빌드의 번들링 방식이 달라서 dev에서 잘 되던 게 빌드 후에 깨지는 경우가 있습니다. 특히 dynamic import 경로나 CSS 처리에서 차이가 생길 수 있어서, 배포 전에vite build && vite preview로 확인해보시는 것이 안전합니다. -
청크가 너무 많아지도록 방치하기 — 동적 임포트를 모든 컴포넌트에 다 걸어놓으면 청크 파일이 수십 개가 되기도 합니다.
rollup-plugin-visualizer나vite-bundle-analyzer로 청크 트리맵을 시각화해서 전체 구조를 주기적으로 확인해보시는 것을 권장합니다.
마치며
지금 바로 시작해볼 수 있는 3단계:
-
번들 현황 파악부터 —
pnpm add -D rollup-plugin-visualizer를 설치하고, 위의 통합 예시처럼vite.config.ts에visualizer({ open: true })를 추가한 뒤pnpm build를 실행해보시면, 어떤 라이브러리가 번들 크기를 차지하는지 트리맵으로 한눈에 볼 수 있습니다. -
manualChunks로 벤더 청크 분리 — 트리맵을 보고 나서 React, UI 라이브러리 등 자주 바뀌지 않는 의존성을 별도 청크로 분리해보시면, 다음 배포부터 브라우저 캐시 효율이 눈에 띄게 달라집니다. 한 번에 다 적용하려 하면 오히려 복잡해질 수 있으니, 크기가 큰 의존성부터 하나씩 시도해보시는 것을 권장합니다. -
무거운 페이지에 동적 임포트 적용 —
React.lazy()와Suspense를 라우트 레벨에 붙이면 초기 번들 크기를 가장 빠르게 줄일 수 있습니다. 트리맵에서 크기가 큰 페이지 컴포넌트 하나를 골라 먼저 적용해보시면 40~60% 감소 효과를 직접 확인할 수 있습니다.
이 글을 쓰면서 실제로 가장 도움이 됐던 자료는 Soledad Penadés의 manualChunks 캐싱 글과 Vite 공식 Performance 문서였습니다. 아래 참고 자료에서 원문을 확인해보시면 더 깊은 내용을 접할 수 있습니다.
참고 자료
- Build Options | Vite 공식 문서
- Performance | Vite 공식 문서
- Environment API | Vite 공식 문서
- Vite 6 Released: New Environment API | InfoQ
- Vite 6.0 Build Optimization: Reduce Build Times by 70% | Markaicode
- Vite 6: New Features and Complete Migration Guide for 2025 | Reintech
- Vite 8 Beta: The Rolldown-powered Vite | Vite 공식 블로그
- Mastering Vite's Chunking Strategy: Best Practices | Runebook
- Use manual chunks with Vite for dependency caching | Soledad Penadés
- What's New in ViteLand: November 2025 | VoidZero
- Vite 6/7 Cold Start Regression in Massive Module Graphs | Tech Champion
- Build Tools Performance Benchmarks | GitHub