Intro
웹팩 Tree shaking을 정리한 글입니다. 이 글의 최종 결론은 다음과 같습니다.
- sideEffects설정은 package경계에서만 영향을 받습니다.
- 컨슈머 애플리케이션의 sideEffects설정이 서드 파티 패키지의 트리쉐이킹에 영향을 주지 않습니다.
- package.json에 있는 sideEffects설정은 해당 패키지의 소스 코드(퍼스트 파티)에만 영향을 끼친다.
- 서드 파티의 트리 쉐이킹은 서트 파티의 package.json의 sideEffects설정에 의존한다.
sideEffects란?
Side Effect(사이드 이펙트)는 모듈을 import할 때 export 제공 외에 다른 동작을 수행하는 코드를 의미합니다.
// ❌ 사이드 이펙트가 있는 코드
window.config = { theme: 'dark' }; // 전역 객체 수정
document.addEventListener('click', ...); // 전역 이벤트 리스너 등록
import './styles.css'; // 스타일 적용
// ✅ 사이드 이펙트가 없는 순수 코드
export const add = (a, b) => a + b;
export const multiply = (a, b) => a * b;
sideEffects 플래그의 의미
package.json
{
"sideEffects": false
}
이것은 다음을 의미합니다 "이 패키지의 모듈이 export됐으나 사용되지 않았다면 번들링시 제거해도 괜찮습니다"
여기서 sideEffects의 적용 범위는 해당 프로젝트의 소스코드, 즉 퍼스트 파티 코드입니다.
서드파티에 대한 sideEffects여부는 서드파티의 package.json에 선언된 sideEffects를 참고합니다.
예시를 통해 설명해보겠습니다. 프로젝트의 구조는 다음과 같습니다.
소스코드(src)에 있는 코드의 빌드 결과가 dist에 떨어집니다.
webpack-demo
|- package.json
|- package-lock.json
|- webpack.config.js
|- /dist
|- bundle.js
|- index.html
|- /src
|- index.js
+ |- math.js
|- /node_modules
src/math.js
/**
* 제곱
*/
export function square(x) {
return x * x;
}
/**
* 세제곱
*/
export function cube(x) {
return x * x * x;
}
// ✅ suare을 import하지 않음
import { cube } from './math.js';
function component() {
const element = document.createElement('pre');
element.innerHTML = [
'Hello webpack!',
'5 cubed is equal to ' + cube(5)
].join('\n\n');
return element;
}
document.body.appendChild(component());
dist/bundle.js
// 🔴 사용하지 않은 square가 번들에 포함됐습니다.
(function (module, __webpack_exports__, __webpack_require__) {
'use strict';
/* unused harmony export square */
/* harmony export (immutable) */ __webpack_exports__['a'] = cube;
function square(x) {
return x * x;
}
function cube(x) {
return x * x * x;
}
});
프로젝트 내부에서 선언된 math 모듈의 메소드중 미사용 메소드인 squre가 번들 파일(bundle.js)에 포함된 것을 확인할 수 있습니다.
트리쉐이킹이 동작하지 않은 결과입니다. 기본 설정으로 웹팩은 모듈의 사이드 이팩트 여부를 판단하지 않습니다.
이런 상황에서 번들러에게 "이 프로젝트에는 sideEffect가 없으니 사용되지 않은 모듈을 제거해도 안전합니다"라는 의미 sideEffects필드를 통해 전달합니다.
package.json
{
"name": "my-project",
"sideEffects": false
}
서드 파티 시나리오(Shopify )
앞 선 예제에서 퍼스트파티 모듈(math)의 예시를 확인했습니다.
이번엔 @shopify/polaris라는 UI라이브러리를 예시로 설명해보겠습니다. 다음은 @shopify/polaris의 프로젝트 구조입니다.
index.js
import './configure';
export * from './types';
export * from './components';
components/index.js
export { default as Breadcrumbs } from './Breadcrumbs';
export { default as Button, buttonFrom, buttonsFrom } from './Button';
export { default as ButtonGroup } from './ButtonGroup';
package.json
"sideEffects": [
"**/*.css",
"**/*.scss",
"./esnext/index.js",
"./esnext/configure.js"
],
이 패키지를 컨슈머 애플리케이션에서 다음과 같이 Button 컴포넌트를 사용했습니다.
import { Button } from "@shopify/polaris";
이 시나리오에서 컨슈머 애플리케이션이 빌드될 때 @shopify/polaris에 포함된 각 모듈은 @shopify/polaris에 정의된 sideEffects필드에 의해 다음 세가지 상황중 한 가지로 처리됩니다.
- 포함(include it): 모듈을 포함하고 평가하며 의존성을 분석합니다.
- 건너 뛰기(skip over): 모듈을 포함하지 않으며 평가하지 않지만 하위 의존성을 계속 평가합니다.
- 제외(exclude it): 모듈을 포함하지 않으며 평가하지 않고 의존성도 분석하지 않습니다.
리소스별로 어떻게 처리되는지 확인해보겠습니다.
- index.js: 직접 export하여 사용하진 않지만 sideEffect의 플래그는 사용 -> 포함
- configure.js: export하여 사용되지 않지만 sideEffect의 플래그는 사용 -> 포함
- types/index.js: export하여 사용되지 않고 sideEffect로 플래그도 사용하지 않음 -> 제외
- components/index.js: 직접 export하여 사용하지 않고 sideEffect로 플래그도 사용하지 않음, 그러나 다시 export한 export는 사용됨 -> 건너 뜀
- components/Breadcrumbs.js: export하여 사용되지 않고 sideEffect로 플래그도 사용하지 않음 -> 제외 sideEffect 플래그가 있더라도 components/Breadcrumbs.css와 같은 모든 의존성은 제외됩니다.
- components/Button.js: 직접 export를 사용하고 sideEffect 플래그는 사용하지 않음 -> 포함
- components/Button.css: 직접 export를 사용하지 않지만 sideEffect 플래그는 사용함 ->포함
위의 경우에 4개 모듈만 번들에 포함됩니다.
- index.js
- configure.js
- components/Button.js
- components/Button.css
컨슈머 애플리케이션을 번들할 때, 서드파티 package.json에 명시된 sideEffects필드를 참고하여 모듈을 번들에 포함시킬지 여부를 판단했습니다. 즉 sideEffects필드의 영향범위는 해당 프로젝트의 소스코드(퍼스트파티)이며 컨슈머 애플리케이션의 설정과 상관없이 서드파티의 package.json설정에 의해 트리 쉐이킹됩니다.
css파일과 SideEffects
sideEffects 플래그의 영향을 더 잘 이해하기 위해 CSS 에셋이 포함된 npm 패키지의 전체 예제와 트리 셰이킹 중에 이러한 에셋이 어떻게 영향을 받을 수 있는지 살펴보겠습니다.
이번엔 "awesome-ui"라는 가상의 UI 컴포넌트 라이브러리를 만들어 보겠습니다.
프로젝트 구조
awesome-ui/
├── package.json
├── dist/
│ ├── index.js
│ ├── components/
│ │ ├── index.js
│ │ ├── Button/
│ │ │ ├── index.js
│ │ │ └── Button.css
│ │ ├── Card/
│ │ │ ├── index.js
│ │ │ └── Card.css
│ │ └── Modal/
│ │ ├── index.js
│ │ └── Modal.css
│ └── theme/
│ ├── index.js
│ └── defaultTheme.css
package.json
{
"name": "awesome-ui",
"version": "1.0.0",
"main": "dist/index.js",
"sideEffects": false
}
dist/index.js
export * from './components';
export * from './theme';
dist/components/index.js
export { default as Button } from './Button';
export { default as Card } from './Card';
export { default as Modal } from './Modal';
dist/components/Button/index.js
import './Button.css'; // 이것은 사이드 이펙트가 있습니다. 가져올 때 스타일이 적용됩니다!
export default function Button(props) {
// Button component implementation
return {
type: 'button',
...props,
};
}
dist/components/Button/Button.css
.awesome-ui-button {
background-color: #0078d7;
color: white;
padding: 8px 16px;
border-radius: 4px;
border: none;
cursor: pointer;
}
dist/theme/index.js
import './defaultTheme.css'; // 이것은 사이드 이펙트가 있습니다!
export const themeColors = {
primary: '#0078d7',
secondary: '#f3f2f1',
danger: '#d13438',
};
이런 구조에서 버튼 컴포넌트만 사용하는 컨슈머 애플리케이션을 가정해보겠습니다.
import { Button } from 'awesome-ui';
sideEffects: false인 경우
웹팩은 다음 흐름으로 트리쉐이킹을 처리하니다.
1. 패키지에서 Button이라는 모듈 하나만 import하고 있습니다.
2. awesom-ui의 package.json에 sideEffects: falas를 확인합니다.
3. import에 대한 부수 효과가 없다고 판단하여 Button 컴포넌트만 가져오면 된다고 판단합니다.
4. 모든 파일에 사이드 이팩트가 없다고 판단하여 Button 컴포넌트만 애플리케이션 번들에 포함됩니다.
5. css파일을 번들에 포함시키지 않습니다. Button컴포넌트에서 import './Button.css' 선언이 있지만 사용하는 곳이 명시적이지 않아서 포함되지 않았습니다.
이 결과 Button컴포넌트가 렌더링되지만, Button.css가 번들링에서 누락되어 스타일이 빠지게 됩니다.
이 문제를 해결하기 위해선 모든 css유형의 파일의 import는 직접적 사용이 없더라도 영향을 미친다는 사실을 알리기 위해서 다음과 같이 표현해야 합니다.
{
"name": "awesome-ui",
"version": "1.0.0",
"main": "dist/index.js",
"sideEffects": ["**/*.css"]
}
배럴 파일에서 일부 모듈만 사용한 시나리오
코드 구조
// lib/utils/index.ts (배럴 파일)
export * from './string';
export * from './number';
export * from './date';
export * from './array';
export * from './object';
// ... 20개 카테고리
// lib/utils/string.ts
export const capitalize = (str: string) => { /* ... */ };
export const truncate = (str: string, len: number) => { /* ... */ };
export const slugify = (str: string) => { /* ... */ };
// ... 50개 함수
사용 코드
// app/blog/[slug]/page.tsx
import { slugify } from '@/lib/utils';
export default function BlogPost({ params }) {
const slug = slugify(params.slug);
// ...
}
설정 없음 (sideEffects:true)
번들 결과
lib/utils/index.ts: 2KB
lib/utils/string.ts: 15KB (전체 포함)
lib/utils/number.ts: 12KB (전체 포함)
lib/utils/date.ts: 18KB (전체 포함)
lib/utils/array.ts: 14KB (전체 포함)
lib/utils/object.ts: 11KB (전체 포함)
... (나머지 카테고리들)
Total: ~200KB
sideEffects: false 설정
{
"sideEffects": false
}
번들 결과
lib/utils/string.ts: 0.5KB (slugify 함수만)
Total: ~0.5KB
절감: 199.5KB (99.75%)
번들링 과정 상세
1. app/blog/[slug]/page.tsx 분석
└─ import { slugify } from '@/lib/utils'
2. lib/utils/index.ts (배럴 파일)
├─ 직접 사용? No
├─ sideEffects? No
├─ re-export 중 사용? Yes (slugify)
└─ 판단: Skip Over
└─ lib/utils/string.ts로 계속 진행
3. lib/utils/string.ts
├─ slugify 사용? Yes
└─ 판단: 포함(Include)
├─ slugify 함수 포함
├─ capitalize 제거 (미사용)
├─ truncate 제거 (미사용)
└─ 기타 47개 함수 제거
4. lib/utils/number.ts, date.ts, array.ts, object.ts
├─ 사용? No
├─ sideEffects? No
└─ 판단: Exclude (분석조차 안 함)
모듈 초기화 로직이 있는 경우 트리쉐이킹에서 초기화 로직이 포함될까?
아래와 같은 상황을 생각해보겠습니다.
모듈 초기화 시점에 실행되는 함수가 존재합니다. 이 경우 initialize함수가 번들에 포함될까요?
// component/chart.jsx
const initialize = () => { //... };
initialize()
/** 라인 차트 */
export const LineChart = () => { //... }
/** 바 차트 */
expor const BarChart = () => { // ... }
// list-page.jsx
import { LineChart } from '@component'
const Page = ()=> {
return(
<LineChart />
)
}
sideEffects필드의 상황에 따라 비교해보겠습니다.
sideEffect:false
chart.jsx에서 export된 모듈이 사용된다면 initialize가 포함되고 실행됩니다. import를 했으나 사용된 모듈이 없는 경우에 chart.jsx 모듈이 포함되지 않으며 initialize도 포함되지 않습니다.
sideEffect:true
chart.jsx에서 export한 모듈이 import되기만 하면 initialize가 실행됩니다.
웹팩의 트리쉐이킹 결정
1. 이 모듈의 내보내기 기능이 직접 사용되는가? 혹은 간접적으로 사용되는가?
- 예: 모듈을 포함합니다.
- 아니요: 2단계로 진행합니다.
2.모듈에 사이드 이팩트가 표기되어 있나요?
- 예: 모듈을 포함합니다.
- 아니요: 모듈과 해당 종속성을 제외합니다.
적절한 sideEffects 구성을 갖춘 라이브러리 파일의 경우
- dist/index.js: 직접 내보내기가 사용되지 않음, 사이드 이펙트 없음 -> 건너뛰기
- dist/components/index.js: 직접 내보내기가 사용되지 않음, 사이드 이펙트 없음 -> 건너뛰기
- dist/components/Button/index.js: 직접 내보내기 사용 -> 포함
- dist/components/Button/Button.css: 내보내기가 불가능하고 사이드 이펙트 있음 -> 포함
- dist/components/Card/*: 내보내기가 사용되지 않음, 사이드 이펙트 없음 -> 제외
- dist/components/Modal/*: 내보내기가 사용되지 않음, 사이드 이펙트 없음 -> 제외
- dist/theme/*: 내보내기가 사용되지 않음, 사이드 이펙트 없음 -> 제외
마무리
webpack 번들러의 sideEffects필드에 대해 알아봤습니다.
개인적으로 영향 범위에 대해서 많이 헷깔렸던 기능입니다. 이 글을 통해 여러분의 이해에 도움이 됐으면 하는 바람입니다.
참고
'javascript' 카테고리의 다른 글
| 자바스크립트와 동시성 (1) | 2024.06.25 |
|---|---|
| 분할 작업을 통한 대량 데이터 처리 (0) | 2024.06.21 |