JS 번들 사이즈 줄이는 방법 | 코드 스플리팅과 Tree Shaking 실전 적용
저는 사이트가 “왠지 느리다”는 느낌이 들면 일단 개발자 도구의 Network 탭부터 열어보는 편입니다. 그런데 얼마 전 작업하던 프로젝트에서 Lighthouse 점수를 돌려봤더니, 이미지도 아니고 폰트도 아닌 JS 청크 하나가 화면을 새빨갛게 물들이고 있었는데요. 원인을 파고들어 보니 결국 문제는 하나였습니다 — 번들 사이즈였습니다.
요약부터 말씀드리면 이렇습니다.
- JS는 다운로드 후에도 파싱·컴파일·실행을 거치며 메인 스레드를 점유해 INP(Interaction to Next Paint)를 갉아먹는 리소스입니다[web.dev].
- 코드 스플리팅으로 초기 번들을 50~80%까지, Tree Shaking으로 미사용 코드를 걷어내면 같은 사이트도 체감 속도가 크게 달라집니다.
- Barrel file, CommonJS 혼용,
sideEffects오설정은 실무에서 가장 흔한 함정입니다.
이 세 가지를 실전 코드와 함께 정리해 보겠습니다.

■ 번들이 커지면 정확히 무슨 일이 벌어지는가
이미지는 그냥 화면에 그려지면 끝이지만, JS는 다릅니다. 다운로드된 뒤에도 압축 해제, 파싱, 컴파일, 실행이라는 네 단계를 다 거쳐야 하고, 이 실행 과정이 메인 스레드를 붙잡고 있는 동안에는 사용자가 뭘 눌러도 화면이 반응하지 않습니다. 이것이 INP 저하의 핵심 원인으로 지목되는데요.
web.dev 공식 문서에 따르면 인터랙션은 입력 지연, 이벤트 핸들러 실행, 다음 프레임 표시까지 세 구간으로 쪼개볼 수 있고, 페이지 로드 중 스크립트 평가 자체가 입력 지연을 늘리는 대표 원인이라고 합니다. 특히 큰 스크립트는 메인 스레드에 롱태스크(long task)를 만들어 다른 인터랙션까지 줄줄이 늦춥니다.
수치로 보면 체감이 더 확실한데요.
| 지표 | 수치 |
|---|---|
| 모바일 압축 JS 페이로드 중앙값 | 500KB 이상, 계속 증가 추세 |
| JS 실행 시간 단축 시 LCP 개선 폭 | 최대 30% |
| JS 100KB 증가 시 전환율 변화 | 2~3% 감소 |
| Core Web Vitals 통과 사이트의 검색 순위 | 미통과 대비 28% 더 높음 |
| Core Web Vitals 통과 사이트의 전환율 | 최대 20% 더 높음 |
번들이 커지는 원인은 대체로 정해져 있습니다. barrel file을 통한 불필요한 코드 포함, tree-shaking이 안 되는 무거운 레거시 라이브러리(Moment.js 등) 사용, 라우트·컴포넌트 단위 분리 없이 하나의 번들에 다 때려 넣는 구조, 필요 이상으로 넓은 브라우저 호환성을 위한 폴리필 — 이 네 가지가 대표적입니다. 즉 번들 사이즈 줄이기는 단순히 “가볍게 만들기”가 아니라 검색 노출과 매출에 직결되는 문제라는 것입니다.
■ 코드 스플리팅 — 필요한 순간에만 불러오기
코드 스플리팅은 거대한 번들 하나를 여러 개의 작은 청크로 나눠, 사용자가 실제로 그 코드를 필요로 하는 시점에만 로드하는 기법입니다. 라우트 기반 코드 스플리팅만 적용해도 초기 번들을 50% 이상, 많게는 60~80%까지 줄일 수 있다는 분석이 여럿 확인되는데요. 실제로 대시보드형 애플리케이션을 8개 라우트 청크로 나눈 사례에서는 초기 번들이 65% 줄어들었다는 구체적인 보고도 있습니다.
프레임워크별로 방법이 조금씩 다르니 하나씩 짚어보겠습니다.
React — React.lazy() + Suspense
import { lazy, Suspense } from 'react';
const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));
<Suspense fallback={<Loading />}>
<MarkdownPreview markdown={markdown} />
</Suspense>
동적 import가 반환하는 Promise는 반드시 default export로 컴포넌트를 담고 있어야 하고, lazy()로 만든 컴포넌트는 항상 모듈 최상위에서 선언해야 합니다. 컴포넌트 함수 안에서 선언하면 렌더링마다 상태가 초기화되는 문제가 생기니까요. 그리고 lazy 컴포넌트는 반드시 <Suspense>로 감싸야 합니다.
Next.js — next/dynamic
import dynamic from 'next/dynamic'
const DynamicHeader = dynamic(() => import('../components/header'), {
loading: () => <p>Loading...</p>,
})
next/dynamic은 React.lazy()와 Suspense를 조합한 Next.js 자체 API로, App Router와 Pages Router 양쪽에서 동일하게 동작합니다. 주의할 점은 import() 경로가 반드시 명시적 문자열이어야 하고(템플릿 문자열·변수 불가), dynamic() 호출 자체가 모듈 최상위에 있어야 웹팩이 번들·모듈 ID를 매칭해 프리로드할 수 있다는 것입니다. window, document에 의존하는 D3.js, Leaflet 같은 라이브러리는 { ssr: false }로 서버 렌더링을 꺼야 하고요. 검색창 입력 시점에만 fuse.js를 로드하는 것처럼 사용자 행동을 트리거로 삼는 패턴도 공식 예시에 등장합니다. 다만 next/dynamic에는 prefetch 옵션이 따로 없어서, 클릭 확률이 높은 인터랙션이라면 hover나 focus 시점에 직접 import()를 트리거하는 방식이 권장된다는 커뮤니티 분석도 참고할 만합니다.
Vue — Vue Router
const UserDetails = () => import('./views/UserDetails.vue')
정적 import를 이렇게 함수 형태로만 바꿔도 Vite나 webpack이 자동으로 코드 스플리팅 혜택을 줍니다. webpack이라면 import(/* webpackChunkName: "group-user" */ './UserDetails.vue')처럼 매직 코멘트로 청크 이름을 지정해, 같은 이름의 여러 비동기 모듈을 하나로 묶을 수도 있습니다. 라우트 경계는 이미 사용자에게 의미 있는 경계이기 때문에 “라우트 컴포넌트는 기본적으로 지연 로딩해야 한다”는 게 Vue Router 공식 가이드의 방향입니다.
Vite/번들러 공통
Vite는 build.rollupOptions.output.manualChunks 옵션으로 청크 분리를 세밀하게 제어할 수 있습니다. { vendor: ['react', 'react-dom'] }처럼 객체 형태로 특정 패키지를 지정된 청크로 묶거나, 함수 형태로 커스텀 로직에 따라 나눌 수도 있고요.
다만 여기에도 함정이 있는데요. Vite/React 조합에서 manualChunks 설정 없이 자동 분리에만 의존하면 vendor 청크가 지나치게 커지는(“large chunk”) 문제가 실무에서 자주 보고됩니다. 자동 분리와 수동 분리는 상호 배타적이지 않으니, 필요한 부분은 직접 설정해 주는 게 안전합니다.

■ Tree Shaking — 안 쓰는 코드는 정말로 지워야 합니다
Tree Shaking은 사용하지 않는 export를 정적 분석으로 찾아내 제거하는 데드 코드 제거 기법입니다. 웹팩 공식 문서에 따르면 이 기법은 ES2015 모듈 문법(import/export)의 정적 구조에 의존하며, Webpack 2부터 기본 지원됐고 Webpack 4에서 sideEffects 속성이 추가됐습니다.
핵심은 이것인데요 — CommonJS의 require()/module.exports는 런타임에 동적으로 해석되기 때문에 정적 분석이 불가능해 tree shaking이 아예 적용되지 않습니다. 그래서 Babel 등에서 ES 모듈을 CommonJS로 트랜스파일하면(@babel/preset-env의 기본 modules 옵션) tree shaking이 무력화되고, { modules: false } 설정이 필요합니다.
웹팩에서 확인해야 할 설정 체크리스트
- ☐
optimization.usedExports: true— 사용되지 않는 export를 마킹(실제 제거는 Terser 등 minifier가 수행) - ☐
mode: 'production'— tree shaking과 minification을 자동 활성화 (development 모드에서는 주석 표시만 되고 실제로 제거되지 않음) - ☐
package.json의sideEffects필드 설정 —false(모두 순수), 배열(side effect 있는 파일만 명시),true(기본값, 모든 파일에 side effect 있다고 간주) 중 선택 - ☐ Webpack 5.107.0 이상이라면
/*#__NO_SIDE_EFFECTS__*/주석으로 특정 함수를 순수하다고 표시 — 호출은 되지만 결과가 안 쓰이는 함수도 제거 대상이 됩니다
Rollup/Vite 쪽은 어떨까요. Vite는 프로덕션 빌드 시 내부적으로 Rollup을 사용하고 tree shaking을 기본으로 적용합니다. 표준 ES 모듈을 쓰면 자동으로 처리되지만, 이때도 CommonJS로 변환되지 않도록 주의해야 하고요. 참고로 Rollup 생태계에서는 sideEffects: false 설정이 moduleSideEffects 옵션과 다르게 동작할 수 있다는 이슈가 실제로 보고된 적이 있어서(rollup 저장소 이슈 #5987), barrel file처럼 재수출이 많은 구조에서는 설정값이 기대와 다르게 적용될 수 있다는 점을 기억해 두시면 좋습니다.

■ 실전에서 자주 걸려 넘어지는 함정들
여기서부터가 사실 진짜 중요한 부분인데요. 이론은 다 알아도 실제 프로젝트에서는 아래 네 가지에서 발목을 잡히는 경우가 대부분입니다.
① Barrel File 문제
여러 모듈의 export를 하나의 index.js로 재수출하는 barrel file 패턴은 import 구문을 짧게 만들어 주긴 하는데요. 문제는 tree shaking이 제대로 활성화되지 않은 환경에서는 barrel file이 참조하는 모든 파일이 통째로 번들에 포함된다는 것입니다.
Material-UI(MUI)에서 Button 컴포넌트 하나만 쓰는 경우를 비교한 벤치마크가 이걸 잘 보여줍니다.
| import 방식 | 번들 크기 |
|---|---|
import { Button } from "@mui/material" (barrel) | 151.47KB |
import Button from "@mui/material/Button" (직접 경로) | 75.69KB |
약 50% 차이입니다. barrel file은 번들러가 참조 관계를 해석하는 데도 시간이 오래 걸려서, 직접 import로 전환했더니 빌드 시간이 30% 단축된 사례도 보고돼 있습니다.
② sideEffects false를 섣불리 설정
sideEffects: false를 무심코 설정하면 CSS import, 폴리필, 전역 이벤트 리스너처럼 실제로 side effect가 있는 코드까지 번들러가 지워버릴 수 있습니다. 웹팩 공식 문서는 CSS를 import하는 컴포넌트 라이브러리 사례를 들어, sideEffects: false로 설정했더니 CSS import가 제거되어 컴포넌트가 스타일 없이 렌더링되는 문제가 생길 수 있다고 경고하는데요. 해결책은 "sideEffects": ["**/*.css"]처럼 예외 파일을 명시하는 것입니다.
③ CommonJS 혼용
프로젝트 안에 CommonJS로 작성된 모듈이 섞여 있으면 그 부분은 정적 분석이 안 되니 tree shaking 대상에서 빠집니다. 대표적인 예가 이겁니다.
| 라이브러리 | 교체 대상 | 효과 |
|---|---|---|
lodash(CommonJS, 전체 약 70KB) | lodash-es | 함수 하나만 import 시 약 1KB 수준 |
| Moment.js(약 230~330KB, 전체 로케일 포함) | date-fns 또는 day.js(약 6KB) | 200KB 이상 절감 사례 다수 |
④ 개발 모드에서만 확인하는 실수
tree shaking과 side effect 관련 문제는 development 모드에서는 겉으로 잘 안 드러납니다. 반드시 production 빌드로 실제 결과를 검증해야 한다는 점, 웹팩 공식 문서도 강조하고 있습니다.

■ 번들 사이즈, 눈으로 확인하고 관리하는 법
말로만 “줄었다”고 하기보다 직접 눈으로 확인하는 게 확실합니다. 자주 쓰이는 도구는 이렇습니다.
- webpack-bundle-analyzer: webpack이 생성한 stats JSON을 인터랙티브한 트리맵으로 시각화. 어떤 패키지가 얼마나 차지하는지 한눈에 보입니다.
- source-map-explorer: 소스맵만 있으면 webpack 없이도 분석 가능. 유사한 트리맵 시각화 제공.
- rollup-plugin-visualizer: Vite/Rollup 생태계용. gzip/brotli 압축 후 크기까지 함께 보여줍니다.
- Bundlephobia: 패키지를 설치하기 전에 크기(의존성 포함)를 미리 확인하는 서비스. 의존성 추가 전 점검용으로 유용합니다.
- 그 외 VS Code의 “Import Cost” 확장, 미사용 코드 탐지 도구
ts-prune,unimported도 자주 언급됩니다.
최근 흐름도 짚어보면 이렇습니다.
- 성능 예산(Performance Budget) CI 연동: 개별 자산 250KB, 엔트리포인트 400KB 같은 임계값을 CI에 설정해 초과 시 빌드를 실패시키는 방식이 회귀 방지책으로 권장됩니다. 초기 번들 목표치로 gzip 기준 200KB가 하나의 기준선으로 제시됩니다.
- Vite 6와 Rolldown/Oxc 기반 Vite 8: Vite 6는 코드 스플리팅과 tree shaking을 거의 자동화 수준으로 끌어올렸다는 평가를 받습니다. Rust 기반 Rolldown과 Oxc를 결합한 차세대 Vite는 빌드 속도가 기존 대비 10~30배 향상됐다는 사례(Linear 46초→6초, GitLab 7배 단축), 개발 서버 시작 시간 87% 감소, Babel 제거로 관련 패키지가 45MB에서 8MB로 줄었다는 수치가 보고됩니다. 다만 이 수치들은 커뮤니티 블로그 기반이라 공식 벤치마크로 교차 검증되진 않았다는 점은 참고해 두시면 좋겠습니다.
- Islands 아키텍처: Astro로 대표되며, 정적 HTML 위에 실제 인터랙션이 필요한 컴포넌트만 부분적으로 하이드레이션합니다.
<ProductGallery client:load />처럼 즉시 로드할 부분과<ProductReviews client:visible />처럼 뷰포트 진입 시 로드할 부분을 구분해, 콘텐츠 중심 사이트에서 클라이언트 JS를 80~95%까지 줄일 수 있다는 설명이 있습니다. - 브라우저 지원 범위 축소: browserslist를 현대적으로 좁히면 불필요한 폴리필이 제거되어 번들 크기가 5~15% 줄어든다는 분석도 있습니다.
참고로 Vite와 Webpack의 번들 크기를 “동등한 애플리케이션 기준 Vite 평균 130KB vs Webpack 150KB”로 비교한 블로그도 있는데, 이건 단일 비공식 벤치마크에 근거한 수치라 절대적으로 받아들이기보다는 참고치 정도로 봐두시는 게 좋습니다. Module Federation 2.0의 “Ant Design 컴포넌트 3개 사용 시 1,404KB→344KB(75.5% 감소)” 같은 구체적인 사례도 마찬가지로 단일 출처에서만 확인된 수치라는 점, 함께 알아두시면 좋겠습니다.

■ 정리하며
결국 제가 그 프로젝트에서 손댄 건 거창한 게 아니었습니다. 라우트 단위로 React.lazy()를 붙이고, barrel import를 직접 경로 import로 바꾸고, package.json의 sideEffects를 제대로 명시한 것 — 딱 이 세 가지였는데요. 그것만으로도 Lighthouse의 빨간 경고가 눈에 띄게 줄어드는 걸 확인할 수 있었습니다.
번들 사이즈 최적화는 한 번 하고 끝나는 작업이 아니라, 새 의존성을 추가할 때마다 Bundlephobia로 크기를 확인하고, CI에 성능 예산을 걸어 회귀를 막는 식으로 계속 관리해야 하는 일입니다. 거창하게 시작하지 않아도 됩니다. 지금 프로젝트에서 가장 무거운 청크 하나만 열어보시는 것부터 시작하시길 권해 드립니다 — 대개 그 안에 barrel file이나 CommonJS 라이브러리가 숨어 있으니까요.
#JS번들사이즈 #코드스플리팅 #TreeShaking #웹팩최적화 #프론트엔드성능 #Vite최적화 #NextJS동적임포트 #CoreWebVitals #웹성능최적화 #번들크기줄이기


댓글 0
첫 댓글을 남겨보세요.