Next.js Turbopack에서 배럴 파일과 CommonJS가 tree shaking을 무력화하는 이유
프론트엔드 프로젝트가 어느 정도 규모가 커지면 한 번쯤 마주치는 상황이 있습니다. import { Button } from '@/components' 한 줄만 썼는데, 빌드 결과물에 쓰지도 않은 Modal, Tooltip, DatePicker가 함께 포함돼 번들 크기가 예상보다 훨씬 불어나는 경우입니다. Atlassian은 Jira 프론트엔드에서 이 문제를 해결하는 것만으로 빌드 시간을 75% 단축했습니다. "우리 프로젝트도 이만큼 극적인 효과는 아니더라도, 번들에서 불필요한 코드 수백 KB는 충분히 잡을 수 있겠다"는 생각이 드신다면, 이 글이 그 방법을 짚어줄 겁니다.
더 당황스러운 건 Turbopack으로 전환한 뒤 이 문제가 오히려 더 불투명하게 느껴진다는 점이죠. 빠른 HMR이 좋아서 Turbopack을 쓰고 있는데, 번들 분석기를 열어보면 예상과 다른 결과가 나오는 경우가 종종 있습니다.
이 글에서는 배럴 파일이 tree shaking을 어떻게 방해하는지, 그 핵심 원인인 CommonJS와 ESM의 차이가 무엇인지 살펴봅니다. 그리고 Turbopack 환경에서 실제로 효과가 있는 최적화 전략들을 코드와 함께 짚어보겠습니다.
핵심 개념
Turbopack과 배럴 파일의 관계
요약: Turbopack은 빠르지만, 배럴 파일 + CommonJS 조합에서 tree shaking이 예상대로 동작하지 않는 알려진 이슈들이 아직 열려 있습니다.
Turbopack은 Rust로 작성된 Next.js 내장 번들러입니다. Next.js 15에서 dev 서버가 안정화됐고, Next.js 16+에서 기본 번들러로 채택됐습니다. Webpack 대비 훨씬 빠른 HMR을 제공하지만, GitHub에는 몇 가지 tree shaking 관련 이슈가 여전히 열려 있습니다.
특히 주목할 이슈가 두 가지입니다. enum 재내보내기에서 tree shaking이 동작하지 않아 번들에 수백 KB가 추가되는 케이스(#88009)가 그중 하나이고, 반대로 너무 공격적으로 코드를 제거해서 런타임 오류가 발생하는 케이스(#85172)도 보고됐습니다. Next.js 16.2에서 정적/동적 import() 모두에 tree shaking을 개선했지만, 아직 완벽하지 않은 시나리오가 존재합니다.
결국 Turbopack 환경에서도 번들러가 정적으로 분석할 수 있는 코드 구조를 갖추는 것이 tree shaking의 전제 조건입니다. 그 구조를 방해하는 게 배럴 파일과 CommonJS입니다.
배럴 파일이 왜 문제가 되는가
요약: 배럴 파일 하나를 import하면, 번들러는 그 파일이 참조하는 모든 모듈을 파싱해야 합니다.
배럴 파일은 원래 좋은 의도에서 시작됐습니다. 컴포넌트 라이브러리가 커지면서 소비자 측이 import 경로를 일일이 외우지 않아도 되도록, 자연스럽게 index.ts 한 곳에서 모든 걸 내보내는 패턴이 자리를 잡았죠.
// src/components/index.ts (배럴 파일)
export { Button } from './Button';
export { Modal } from './Modal';
export { Input } from './Input';
export { DatePicker } from './DatePicker';
// ...수십~수백 개소비자 측에서는 import { Button } from '@/components'처럼 깔끔하게 사용할 수 있어 개발 경험(DX)이 좋습니다. 그런데 번들러 입장에서는 이 한 줄을 처리하기 위해 배럴 파일이 참조하는 모든 모듈을 파싱하고 평가해야 합니다. Button 하나만 필요해도 Modal, Input, DatePicker의 파일을 전부 들여다봐야 하는 거죠.
Atlassian의 Jira 프론트엔드가 바로 이 구조였습니다. 배럴 파일이 또 다른 배럴 파일을 가리키는 연쇄 구조가 프로젝트 내 거의 모든 파일을 전이적으로 끌어들이고 있었던 겁니다. 이 연쇄를 끊어내는 것만으로 빌드 시간이 75% 줄었습니다. 규모가 그 정도라면 우리 프로젝트에서는 어떨까요. 대규모 컴포넌트 라이브러리나 유틸 패키지를 배럴 파일로 관리하고 있다면, 번들 분석기로 한번 들여다볼 가치가 있습니다.
CommonJS가 Tree Shaking을 막는 진짜 이유
요약: CJS는 런타임 함수 호출이라 빌드 타임 정적 분석이 불가능하고, 항상 번들 전체가 포함됩니다.
Tree shaking이 동작하려면 번들러가 빌드 타임에 "어떤 export가 어디서 사용되는지"를 확정할 수 있어야 합니다. ESM의 import/export는 파일 최상위 레벨에만 존재하는 선언적 구문이라 이 분석이 가능합니다.
Tree Shaking vs Dead Code Elimination — 두 용어가 혼용되는 경우가 많은데 미묘하게 다릅니다. Dead code elimination은 컴파일러가 코드 내부에서 도달 불가능한 경로를 제거하는 것이고, tree shaking은 모듈 그래프 수준에서 사용되지 않는 export 전체를 번들에서 제외하는 것입니다. 두 기법 모두 최종 번들 크기를 줄이지만, tree shaking은 ESM의 정적 구조 위에서만 온전히 작동합니다.
CommonJS는 다릅니다. require()는 런타임 함수 호출이기 때문에 동적으로 동작할 수 있습니다. 실무에서 가장 자주 만나는 형태는 이렇습니다.
// CommonJS — 가장 흔한 패턴
module.exports = { funcA, funcB, funcC };
// 또는
exports.default = MyClass;이런 패턴도 정적 분석이 가능해 보이지만, 번들러는 module.exports가 이후에 어떻게 변경될지 보장할 수 없습니다. Node.js 라이브러리에서 종종 볼 수 있는 더 극단적인 케이스도 있습니다.
// CommonJS — 번들러가 빌드 타임에 분석할 수 없는 경우
const key = process.env.FEATURE_FLAG;
module.exports[key] = () => { /* ... */ };
// 조건부 require
if (process.env.NODE_ENV === 'test') {
module.exports = require('./mock-utils');
}번들러는 이런 코드가 런타임에 어떻게 평가될지 알 수 없습니다. 그래서 CommonJS 모듈은 사용 여부에 관계없이 항상 번들에 전체가 포함됩니다. 배럴 파일 안에 CJS 모듈이 하나라도 섞여 있다면, 그 파일 경계에서 tree shaking이 멈춥니다.
한 가지 더 — ESM으로 작성된 배럴 파일이라도 export * from './some-module' 패턴을 남용하면 tree shaking 효율이 떨어질 수 있습니다. 번들러가 해당 모듈의 모든 re-export를 추적해야 하기 때문에, 가능하다면 명시적인 named export를 사용하는 것이 더 안전합니다.
TypeScript 설정이 조용히 Tree Shaking을 무력화하는 경우
요약: "module": "CommonJS"가 설정된 tsconfig는 ESM으로 작성한 코드를 CJS로 변환해버립니다.
솔직히 이 부분은 저도 꽤 오래 모르고 지나쳤습니다. tsconfig.json의 "module": "CommonJS" 설정이 있으면, TypeScript 컴파일러가 모든 import/export를 require()/module.exports로 변환해버립니다. 코드는 ESM으로 작성했는데 컴파일 결과물은 CJS가 되는 거죠.
// ❌ Tree shaking이 작동하지 않는 설정
{
"compilerOptions": {
"module": "CommonJS"
}
}
// ✅ 번들러에게 ESM 구조를 그대로 넘겨주는 설정
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler"
}
}"module": "Preserve"도 좋은 선택입니다. 소스 파일의 모듈 문법을 그대로 유지하면서 타입 정보만 제거해줍니다. TypeScript 5.0+에서 도입된 "moduleResolution": "bundler"는 Vite, Turbopack 같은 번들러 환경에 최적화된 모듈 해석 전략이므로 함께 설정하는 것을 권장합니다.
실전 적용
"어디서부터 시작할까"를 고민하는 분들을 위해 우선순위 순서로 배치했습니다. 코드를 전혀 건드리지 않고도 바로 효과를 볼 수 있는 것부터 시작해, 패키지 설정 → 코드 전환 순으로 이어집니다.
예시 1: optimizePackageImports로 외부 패키지 배럴 파일 자동 최적화
아무 코드도 안 바꾸고 바로 효과를 볼 수 있는 방법입니다. Next.js 13.5에서 도입된 optimizePackageImports 옵션은 외부 패키지의 배럴 파일 import를 직접 경로로 자동 변환해줍니다.
// next.config.js
module.exports = {
experimental: {
optimizePackageImports: ['lucide-react', '@mui/material', 'lodash', '@heroicons/react'],
},
};내부적으로는 이런 변환이 일어납니다.
// 작성한 코드
import { AlertCircle, Check } from 'lucide-react';
// Next.js가 내부적으로 변환 (패키지 내부 경로 구조에 맞게 자동 해석)
import AlertCircle from 'lucide-react/dist/esm/icons/alert-circle';
import Check from 'lucide-react/dist/esm/icons/check';| 상황 | 효과 |
|---|---|
| lucide-react (10,000+ 아이콘) | 번들에서 400KB+ 제거, 콜드 스타트 수백 ms 단축 |
| @mui/material | 사용한 컴포넌트만 번들에 포함 |
| 적용 불가 | 로컬 workspace 패키지, 심링크 환경(#75148 이슈) |
주의 —
optimizePackageImports는node_modules의 외부 패키지 전용입니다. 프로젝트 내부 배럴 파일에는 적용되지 않습니다. 또한 모노레포에서 심링크(symlink)로 연결된 로컬 패키지—다른 워크스페이스 패키지를node_modules처럼 참조하는 구조—에서는 최적화가 동작하지 않는 알려진 이슈가 있습니다(#75148).
예시 2: sideEffects: false 선언으로 번들러에 힌트 제공
내부 라이브러리나 모노레포 패키지라면 package.json에 "sideEffects": false를 추가하는 것이 다음 단계입니다. 이 선언은 "이 패키지의 모든 파일은 단순히 import하는 것만으로 부작용이 없다"는 힌트를 번들러에 전달합니다. Turbopack과 Webpack 모두 이 정보를 바탕으로 미사용 모듈을 더 적극적으로 제거할 수 있습니다.
실제로 이 설정을 빠뜨리고 배포까지 간 적이 있었습니다. 번들 분석기로 확인해보니 @myapp/ui 패키지 전체가 덩어리째 포함돼 있더라고요. "sideEffects": false 한 줄 추가로 번들에서 수백 KB가 빠졌습니다.
{
"name": "@myapp/ui",
"sideEffects": false,
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
}
}사이드 이펙트(Side Effect) — import하는 것만으로 전역 상태 변경, 폴리필 등록, CSS 삽입 같은 부작용이 발생하는 코드.
"sideEffects": false선언 전에 반드시 해당 여부를 확인해야 합니다. 잘못 선언하면 필요한 코드가 번들에서 제거될 수 있습니다.
CSS 파일이나 전역 상태를 초기화하는 파일처럼 진짜로 사이드 이펙트가 있는 파일은 배열로 명시해줄 수 있습니다.
{
"sideEffects": ["./src/styles/global.css", "./src/polyfills.ts"]
}예시 3: Dual Package 전략으로 CJS 하위 호환 유지
기존 CJS 환경과의 호환성을 유지하면서도 ESM tree shaking의 이점을 챙기고 싶다면, 조건부 exports로 두 가지를 모두 제공하는 방법이 있습니다. "ESM 전용으로 만들면 기존 환경이 걱정된다"는 상황에서 선택할 수 있는 현실적인 절충안입니다.
{
"name": "@myapp/utils",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./helpers": {
"import": "./dist/helpers.mjs",
"require": "./dist/helpers.cjs",
"types": "./dist/helpers.d.ts"
}
}
}번들러는 import 조건의 .mjs 파일을 우선 선택하므로 ESM tree shaking의 이점을 그대로 얻을 수 있습니다. CJS 환경에서는 자동으로 .cjs 파일이 사용됩니다.
빌드 도구 선택에 대해 짧게 설명하면, tsup은 esbuild 기반이라 설정이 간결하고 CJS+ESM 동시 출력이 한 줄 설정으로 가능해서 이 용도에 가장 실용적입니다. rollup과 unbuild는 더 세밀한 제어가 필요할 때 선택하면 됩니다.
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
splitting: false,
clean: true,
});예시 4: CJS→ESM 자동 변환 도구 활용 및 번들 분석
레거시 코드베이스에서 CJS를 ESM으로 전환할 때 수작업은 꽤 고됩니다. cjstoesm이나 lebab 같은 도구로 약 90%까지 자동 변환하고 나머지를 수동으로 검토하는 방식이 현실적입니다. 단, 두 도구 모두 최근 업데이트가 뜸한 상태라 변환 결과는 반드시 직접 검토가 필요합니다.
# cjstoesm 설치 및 사용
npx cjstoesm src/**/*.js
# lebab으로 특정 변환 적용
npx lebab --transform commonjs src/utils.js -o src/utils.mjs변환 전후에는 @next/bundle-analyzer로 번들 구성을 확인하는 것을 권장합니다. 한 가지 주의할 점이 있는데, 프로젝트를 ESM으로 전환했다면 next.config.js에서도 require() 대신 import 방식을 사용하거나 next.config.mjs로 파일 확장자를 바꿔야 합니다.
// next.config.mjs (ESM 프로젝트)
import bundleAnalyzer from '@next/bundle-analyzer';
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
export default withBundleAnalyzer({
// ...
});ANALYZE=true pnpm build배럴 파일 import를 린트 수준에서 잡고 싶다면 eslint-plugin-import의 no-barrel-files 룰을 활용할 수 있습니다.
{
"plugins": ["import"],
"rules": {
"import/no-barrel-files": "warn"
}
}장단점 분석
아래 표는 ESM 배럴 파일 + sideEffects 설정 조합을 기준으로 정리한 내용입니다.
장점
| 항목 | 내용 |
|---|---|
| DX 향상 | 소비자 측 import 경로가 간결해지고, 내부 파일 구조 변경에도 외부 API가 안정적으로 유지됨 |
| API 표면 제어 | 배럴 파일로 공개할 항목만 골라 노출하고 내부 구현 경로를 숨길 수 있음 |
| ESM + sideEffects | 올바르게 설정된 ESM 배럴 파일은 tree shaking과 함께 잘 동작함 |
| Dual Package | CJS 하위 호환을 유지하면서 ESM 번들 최적화 혜택을 동시에 얻을 수 있음 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 콜드 스타트 오버헤드 | 배럴 파일 전체를 파싱해야 해서 dev 서버 첫 로딩에 프로젝트 규모에 따라 수백 ms~수 초 추가 | optimizePackageImports 적용 또는 직접 경로 import |
| CJS 혼재 시 tree shaking 실패 | 배럴 파일 내 CJS 모듈이 하나라도 있으면 해당 경계에서 tree shaking 중단 | 의존성 체인을 추적해 ESM으로 전환, Dual Package 사용 |
| 배럴→배럴 연쇄 구조 | 프로젝트 전체 모듈이 전이적으로 포함될 수 있음 | 배럴 파일 뎁스를 최소화, eslint-plugin-import의 no-barrel-files 룰 적용 고려 |
| Turbopack 알려진 버그 | enum 재내보내기 tree shaking 실패(#88009), 과도한 코드 제거로 런타임 오류(#85172) | bundle-analyzer로 주기적 확인, 문제 패키지는 직접 경로로 import |
| 로컬 워크스페이스 + 심링크 | optimizePackageImports 최적화가 동작하지 않는 이슈(#75148) |
해당 이슈 해결 전까지 직접 경로 import 사용 |
실무에서 가장 흔한 실수
-
tsconfig.json의"module": "CommonJS"설정을 유지한 채 ESM으로 전환했다고 착각한 경우 — 저도 이 함정에 빠진 적이 있습니다. TypeScript 컴파일러가 트랜스파일 단계에서 다시 CJS로 바꿔버려서, 번들러에 들어가는 코드는 사실상 CJS입니다."module": "ESNext"또는"module": "Preserve"로 바꾸는 게 필요합니다. -
"sideEffects": false를 전체 패키지에 선언했지만 CSS 모듈이나 전역 초기화 파일이 포함된 경우 — 팀에서 이 문제로 반나절을 쓴 적이 있습니다. 번들러가 스타일 파일을 제거해버려서 배포 후 화면이 깨졌는데, 원인을 찾는 데 시간이 꽤 걸렸습니다. 사이드 이펙트가 있는 파일은 반드시 배열로 명시해야 합니다. -
배럴 파일 내에서 외부 CJS 패키지를 re-export하는 경우 — ESM 파일이라도 CJS 모듈을 가져와 재내보내면 해당 모듈 경계에서 tree shaking이 멈춥니다. 외부 CJS 패키지는 Dual Package 지원 여부를 먼저 확인하는 게 좋습니다.
마치며
배럴 파일 자체가 문제가 아니라, 번들러가 정적으로 분석할 수 없는 구조와 조합될 때 문제가 됩니다. ESM 구조를 유지하고, sideEffects 필드를 올바르게 선언하고, TypeScript 컴파일 설정을 확인하는 것만으로도 체감할 수 있는 번들 크기 개선이 가능합니다.
지금 바로 시작해볼 수 있는 3단계입니다.
-
번들 현황부터 파악 —
ANALYZE=true pnpm build로@next/bundle-analyzer를 실행해봅니다. 어느 패키지가 예상보다 크게 포함됐는지 시각적으로 확인할 수 있고, 최적화 우선순위를 정하기 훨씬 수월해집니다. lucide-react 같은 아이콘 패키지가 400KB 이상을 차지하고 있다면, 다음 단계 하나만으로 즉시 줄일 수 있습니다. -
외부 패키지는
optimizePackageImports부터 —next.config.js에 lucide-react, @mui/material, lodash 같이 아이콘이나 유틸 패키지를 추가합니다. 별다른 코드 변경 없이 번들에서 수백 KB~수 MB를 즉시 제거할 수 있습니다. -
내부 패키지
package.json점검 — 모노레포나 내부 라이브러리라면"sideEffects": false와"exports"조건부 필드가 올바르게 설정됐는지 확인합니다.tsconfig.json의"module"설정도 함께 점검하면 됩니다. 이 두 가지가 맞물리면 번들 크기가 체감 수준으로 줄어드는 경우가 많습니다.
번들 분석기를 실행하고 어느 패키지에서 문제를 발견하셨는지 댓글로 공유해주시면 좋겠습니다. 다른 분들에게도 좋은 참고가 될 겁니다.
참고 자료
- Turbopack Dev is Now Stable | Next.js Blog
- Turbopack: What's New in Next.js 16.2 | Next.js Blog
- How we optimized package imports in Next.js - Vercel
- next.config.js: optimizePackageImports | Next.js Docs
- How CommonJS is making your bundles larger | web.dev
- Speeding up the JavaScript ecosystem - The barrel file debacle
- How We Achieved 75% Faster Builds by Removing Barrel Files - Atlassian
- Lazy barrel - Rspack
- Announcing Rspack 1.6 - Rspack Blog
- Tree Shaking | webpack
- Turbopack tree-shaking generates invalid output · Issue #85172
- Turbopack not tree-shaking unused enum exports · Issue #88009
- Publishing dual ESM+CJS packages - Mayank
- The Hidden Costs of Barrel Files - wesionary TEAM