Tree Shaking

Tree shaking은 사용되지 않는 코드를 제거하기 위해 JavaScript 컨텍스트에서 일반적으로 사용되는 용어입니다. ES2015 모듈 구문은 정적 구조에 의존합니다. 예를 들면, importexport가 있습니다. 이름과 개념은 ES2015 모듈 번들러의 rollup에 의해 대중화되었습니다.

webpack 2 릴리스에서는 ES2015 모듈(별칭 harmony 모듈)과 사용하지 않는 모듈의 export를 감지하는 기능을 제공합니다. 새로운 webpack 4의 릴리스는 package.json"sideEffects" 프로퍼티를 통해 컴파일러에 힌트를 제공하는 방식으로 기능을 확장합니다. 프로젝트의 어떤 파일이 "순수"한지 나타내며, 만약 사용하지 않는다면 제거해도 괜찮은지 표시합니다.

Add a Utility

다음 두 함수를 내보내는 새 유틸리티 파일인 src/math.js를 프로젝트에 추가해 보겠습니다.

project

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;
}

mode 옵션을 development로 설정하여 번들이 압축되지 않도록 합니다.

webpack.config.js

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
+ mode: 'development',
+ optimization: {
+   usedExports: true,
+ },
};

이를 통해 새로운 메소드 중 하나를 사용하도록 entry 스크립트를 업데이트하고, 스크립트를 간단하게 하기 위해 lodash를 삭제하겠습니다.

src/index.js

- import _ from 'lodash';
+ import { cube } from './math.js';

  function component() {
-   const element = document.createElement('div');
+   const element = document.createElement('pre');

-   // 이제 Lodash를 스크립트로 가져왔습니다.
-   element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+   element.innerHTML = [
+     'Hello webpack!',
+     '5 cubed is equal to ' + cube(5)
+   ].join('\n\n');

    return element;
  }

  document.body.appendChild(component());

우리는 src/math.js 모듈에서 square 메소드를 가져오지 않았습니다. 이 함수는 "사용하지 않는 코드"로 알려져 있고, 사용하지 않아 삭제되어야 하는 export를 의미합니다. 이제 npm 스크립트인 npm run build를 실행하여 출력된 번들을 살펴보겠습니다.

dist/bundle.js (around lines 90 - 100)

/* 1 */
/***/ (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;
  }
});

위의 unused harmony export square 주석을 참고하세요. 아래 코드를 보면 square를 가져오지 않지만, 여전히 번들에 포함되어 있습니다. 다음 섹션에서 수정해 보겠습니다.

Mark the file as side-effect-free

100% ESM 모듈에서는 사이드 이펙트를 쉽게 식별할 수 있습니다. 그러나 우리는 아직 거기까지는 도달하지 않았으므로, 도달하기 까지는 코드의 "순수성"에 대한 힌트를 webpack 컴파일러에 제공해야 합니다.

이를 수행하는 방법은 package.json의 "sideEffects" 속성입니다.

{
  "name": "your-project",
  "sideEffects": false
}

위에 언급한 코드는 사이드 이펙트를 포함하지 않으므로, 간단하게 false로 프로퍼티를 표시하여 사용하지 않는 export는 제거해도 괜찮다는 것을 webpack에 알릴 수 있습니다.

코드에 사이드 이펙트가 있다면 대신 배열을 사용할 수 있습니다.

{
  "name": "your-project",
  "sideEffects": ["./src/some-side-effectful-file.js"]
}

배열은 관련된 파일의 간단한 전역 패턴을 허용합니다. 내부적으로 glob-to-regexp을 사용합니다 (사용 가능: *, **, {a,b}, [a-z]). /을 포함하지 않는 *.css와 같은 패턴은 **/*.css처럼 취급합니다.

{
  "name": "your-project",
  "sideEffects": ["./src/some-side-effectful-file.js", "*.css"]
}

마지막으로 "sideEffects"module.rules 옵션으로도 설정할 수 있습니다.

Clarifying tree shaking and sideEffects

sideEffectsusedExports(트리 쉐이킹으로 알려져 있음)의 최적화는 두 가지 다른 점이 있습니다.

sideEffects 전체 모듈 및 파일, 전체 하위 트리를 건너뛸 수 있기 때문에 훨씬 더 효율적입니다.

usedExportsterser를 사용하여 문장에서 사이드 이펙트를 감지합니다. 이것은 JavaScript에서 어려운 작업이며 간단한 sideEffects 플래그만큼 효과적이지 않습니다. 또한 사이드 이펙트를 확인해야 하는 명세가 있기 때문에 하위트리 및 의존성을 무시할 수 없습니다. export 기능은 잘 동작하지만, React의 Higher Order Components(HOC)는 이와 관련된 문제가 있습니다.

예를 들어보겠습니다.

import { Button } from '@shopify/polaris';

미리 번들된 버전은 아래와 같습니다.

import hoistStatics from 'hoist-non-react-statics';

function Button(_ref) {
  // ...
}

function merge() {
  var _final = {};

  for (
    var _len = arguments.length, objs = new Array(_len), _key = 0;
    _key < _len;
    _key++
  ) {
    objs[_key] = arguments[_key];
  }

  for (var _i = 0, _objs = objs; _i < _objs.length; _i++) {
    var obj = _objs[_i];
    mergeRecursively(_final, obj);
  }

  return _final;
}

function withAppProvider() {
  return function addProvider(WrappedComponent) {
    var WithProvider =
      /*#__PURE__*/
      (function (_React$Component) {
        // ...
        return WithProvider;
      })(Component);

    WithProvider.contextTypes = WrappedComponent.contextTypes
      ? merge(WrappedComponent.contextTypes, polarisAppProviderContextTypes)
      : polarisAppProviderContextTypes;
    var FinalComponent = hoistStatics(WithProvider, WrappedComponent);
    return FinalComponent;
  };
}

var Button$1 = withAppProvider()(Button);

export {
  // ...,
  Button$1,
};

Button이 사용되지 않으면 export { Button$1 };을 효과적으로 제거하고 나머지 코드를 모두 남길 수 있습니다. "이 코드가 사이드 이펙트가 없거나 안전하게 삭제할 수 있을까요?"라는 질문을 할 수 있습니다. withAppProvider()(Button) 라인 때문에 말하기 어렵습니다. withAppProvider가 호출되고 리턴 값도 호출됩니다. merge 또는 hoistStatics를 호출할 때 사이드 이펙트가 있나요? WrappedComponent.contextTypes (Getter?)를 읽거나 WithProvider.contextTypes (Setter?)를 할당할 때 사이드 이펙트가 있나요?

Terser는 알아내려고 노력하지만 여러 상황에서 장담할 수는 없습니다. 이것은 terser가 알아낼 수 없기 때문에, terser가 역할을 잘 수행하지 못한다는 것이 아닙니다. JavaScript 같은 동적 언어에서 확실하게 판단하는 것은 매우 어렵습니다.

그러나 /*#__PURE__*/ 어노테이션을 이용하여 terser를 도와줄 수 있습니다. 그 구문은 사이드 이펙트가 없는 것으로 표시합니다. 그래서 간단한 변경만으로 코드를 tree-shake 할 수 있습니다.

var Button$1 = /*#__PURE__*/ withAppProvider()(Button);

이렇게 하면 이 코드를 제거 할 수 있습니다. 그러나 포함되어야 하거나 평가가 필요한 import는 사이드 이펙트가 있을 수 있기 때문에 여전히 이에 대한 문제가 남아 있습니다.

이 문제를 해결하기 위해 package.json"sideEffects" 프로퍼티를 사용합니다.

이것은 /*#__PURE__*/와 비슷하지만, 구문 레벨이 아닌 모듈 레벨에서 사용합니다. "sideEffects" 프로퍼티에 대해 "sideEffect가 없다고 플래그된 모듈에서 직접적인 export가 없는 경우 번들러는 사이드 이펙트에 대한 평가를 건너 뛸 수 있다."라고 설명하고 있습니다.

Shopify's 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"
],
// ...

import { Button } from "@shopify/polaris";는 다음과 같이 동작합니다.

  • 포함: 모듈을 포함하고 평가하며 계속 의존성을 분석합니다.
  • 건너뛰기: 포함하지 않으며 평가하지 않으나 계속 의존성을 평가합니다.
  • 제외: 포함하지 않으며 평가하지 않고 의존성도 분석하지 않습니다.

매칭되는 리소스별로 자세히 보겠습니다.

  • 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

이 최적화 후, 다른 최적화도 적용할 수 있습니다. 예를 들면, buttonFromButton.js에서 export하는 buttonsFrom은 사용되지 않습니다. usedExports 최적화는 이를 알아채고 terser는 모듈에서 일부 명령문을 삭제할 수 있습니다.

모듈의 연결에도 적용됩니다. 따라서 이 4개의 모듈과 엔트리 모듈(그리고 아마도 좀 더 많은 의존성)을 연결할 수 있습니다. 결국 index.js에는 생성되는 코드가 없습니다.

Full Example: Understanding Side Effects with CSS Files

sideEffects 플래그의 영향을 더 잘 이해하기 위해 CSS 에셋이 포함된 npm 패키지의 전체 예제와 트리 셰이킹 중에 이러한 에셋이 어떻게 영향을 받을 수 있는지 살펴보겠습니다. "awesome-ui"라는 가상의 UI 컴포넌트 라이브러리를 만들어 보겠습니다.

Package Structure

예제 패키지는 다음과 같습니다.

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 Files Content

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/components/Card/index.js and dist/components/Modal/index.js would have similar structure.

dist/theme/index.js

import './defaultTheme.css'; // 이것은 사이드 이펙트가 있습니다!

export const themeColors = {
  primary: '#0078d7',
  secondary: '#f3f2f1',
  danger: '#d13438',
};

What Happens When Consuming This Package?

이제 Button 구성 요소만 사용하려는 컨수머 애플리케이션을 상상해 보세요.

import { Button } from 'awesome-ui';

// 버튼 컴포넌트를 사용하세요

With sideEffects: false in package.json

트리 쉐이킹이 활성화된 상태에서 webpack이 이 가져오기를 처리하는 경우.

  1. 버튼에 대한 가져오기만 확인됩니다.
  2. package.json을 보면 sideEffects: false가 보입니다.
  3. Button 구성 요소 코드만 포함하면 된다고 판단합니다.
  4. 모든 파일에 사이드 이펙트가 없는 것으로 표시되어 있으므로 단지 버튼에 대한 JavaScript 코드만 포함됩니다.
  5. CSS 파일 가져오기가 중단됩니다! Button.css가 Button/index.js로 가져와졌더라도 webpack은 이 가져오기에 사이드 이펙트가 없다고 가정합니다.

결과: Button 구성 요소는 렌더링되지만, 트리 셰이킹 중에 Button.css가 제거되었기 때문에 스타일이 적용되지 않습니다.

The Correct Configuration for This Package

이 문제를 해결하려면 package.json을 업데이트하여 CSS 파일에 사이드 이펙트가 있음을 올바르게 표시해야 합니다.

{
  "name": "awesome-ui",
  "version": "1.0.0",
  "main": "dist/index.js",
  "sideEffects": ["**/*.css"]
}

이 설정을 사용하면,

  1. Webpack은 여전히 Button 구성 요소만 필요하다고 식별합니다.
  2. 하지만 이제 CSS 파일에는 사이드 이펙트가 있다는 것을 인식합니다.
  3. 따라서 Button/index.js를 처리할 때 Button.css가 포함됩니다.

The Decision Tree for Side Effects

트리 쉐이킹 중에 WebPack이 모듈을 평가하는 방식은 다음과 같습니다.

  1. 이 모듈의 내보내기 기능은 직접 사용되나요, 아니면 간접적으로 사용되나요?

    • 예: 모듈을 포함합니다.
    • 아니요: 2단계로 계속 진행하세요.
  2. 모듈에 사이드 이펙트가 표시되어 있나요?

    • 예(sideEffects가 이 파일을 포함하거나 true인 경우): 모듈을 포함합니다.
    • 아니요(sideEffectsfalse이거나 이 파일을 포함하지 않는 경우): 모듈과 해당 종속성을 제외합니다.

적절한 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/*: 내보내기가 사용되지 않음, 사이드 이펙트 없음 -> 제외

Real-World Impact

잘못된 사이드 이펙트 설정은 심각한 영향을 미칠 수 있습니다.

  1. CSS 미포함: 컴포넌트가 스타일 없이 렌더링됨
  2. 전역 JavaScript 미실행: 폴리필 또는 전역 구성이 실행되지 않음
  3. 초기화 코드 생략: 컴포넌트를 등록하거나 이벤트 리스너를 설정하는 함수가 실행되지 않음

이러한 문제는 트리 쉐이킹이 활성화된 프로덕션 빌드에서만 발생하는 경우가 많기 때문에 디버깅하기가 특히 어려울 수 있습니다.

Testing Side Effects Configuration

사이드 이펙트 설정이 올바른지 테스트하는 좋은 방법은 다음과 같습니다.

  1. 하나의 컴포넌트만 가져오는 최소 애플리케이션을 만듭니다.
  2. 프로덕션 설정(트리 쉐이킹 활성화)으로 빌드합니다.
  3. 모든 필수 스타일과 동작이 제대로 작동하는지 확인합니다.
  4. 생성된 번들을 보고 올바른 파일이 포함되어 있는지 확인합니다.

Mark a function call as side-effect-free

/*#__PURE__*/어노테이션을 사용하여 해당 함수 호출이 사이드 이펙트가 없다(side-effect-free)(순수하다)는 것을 webpack에 알릴 수 있습니다. 함수 호출 앞에 추가하여 사이드 이펙트가 없는 것으로 표시할 수 있습니다. 함수에 전달된 인수는 어노테이션으로 표시되지 않고 개별적으로 표시해야 할 수 있습니다. 사용하지 않는 변수의 초기값이 사이드 이펙트가 없다(순수하다)면, 사용하지 않는 코드로 표시되고 실행되지 않으며 최소화할 때 삭제됩니다. 이런 동작은 optimization.innerGraphtrue 일 때 활성화됩니다.

file.js

/*#__PURE__*/ double(55);

Minify the Output

importexport 구문을 통해 "사용하지 않는 코드"를 삭제했습니다. 하지만 번들에서도 삭제해야 합니다. 이렇게 하려면 mode 옵션을 production으로 설정해야 합니다.

webpack.config.js

const path = require('path');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
- mode: 'development',
- optimization: {
-   usedExports: true,
- }
+ mode: 'production',
};

즉, 다른 npm run build를 실행하고 변경된 사항이 있는지 볼 수 있습니다.

dist/bundle.js에서 다른 점을 찾았나요? 정확하게 전체 번들이 최소화되고 난독화 되었지만, 자세히 보면 포함되어 있던 square 함수가 없으며 난독화 된 cube 함수 (function r(e){return e*e*e}n.a=r)를 볼 수 있습니다. 최소화와 tree shaking으로 번들은 이제 몇 바이트 더 작아졌습니다! 위의 임의로 만든 예제에서는 큰 변화를 느끼지 못하겠지만 tree shaking은 복잡한 의존성 트리가 있는 커다란 애플리케이션에서 작업할 때 번들의 크기를 많이 줄일 수 있습니다.

Common Pitfalls with Side Effects

트리 쉐이킹과 sideEffects 플래그를 사용할 때 피해야 할 몇 가지 일반적인 함정이 있습니다.

1. Over-optimistic sideEffects: false

package.json 파일에서 sideEffects: false를 설정하면 최적의 트리 셰이킹 효과를 얻을 수 있지만, 코드에 실제로 부작용이 있는 경우 문제가 발생할 수 있습니다. 숨겨진 부작용의 예는 다음과 같습니다.

  • CSS 가져오기(위에서 설명한 대로)
  • 전역 객체를 수정하는 폴리필
  • 글로벌 이벤트 리스너를 등록하는 라이브러리
  • 프로토타입 체인을 수정하는 코드

2. Re-exports with Side Effects

다음 패턴을 고려해 보세요.

// 이 파일에는 건너뛸 수 있는 부작용이 있습니다
import './polyfill';

// 컴포넌트 다시 내보내기
export * from './components';

소비자가 특정 구성 요소만 가져오는 경우, 부작용에 대한 적절한 표시가 없으면 폴리필 가져오기가 완전히 건너뛰어질 수 있습니다.

3. Forgetting about Nested Dependencies

패키지가 부작용을 올바르게 표시하더라도, 부작용을 잘못 표시하는 타사 패키지에 의존하는 경우 여전히 문제가 발생할 수 있습니다.

4. Testing Only in Development Mode

트리 쉐이킹은 일반적으로 프로덕션 모드에서만 완전히 활성화됩니다. 개발 모드에서만 테스트하면 배포될 때까지 트리 쉐이킹 문제를 숨길 수 있습니다.

Conclusion

그래서 tree shaking의 이점을 살리기 위하여

  • ES2015 모듈 구문을 사용해야 하는 것을 배웠습니다. (예: importexport)
  • 컴파일러가 ES2015 모듈 구문을 CommonJS 모듈로 변환하지 않도록 해야 합니다. (이것은 인기 있는 Babel preset @babel/preset-env의 기본 동작입니다. 자세한 내용은 documentation를 참고하세요.)
  • package.json 파일에 "sideEffects" 속성을 추가하세요.
  • 특히 CSS 가져오기의 경우 사이드 이펙트가 있는 파일을 올바르게 표시하는 데 주의하세요.
  • 최소화와 tree shaking을 포함한 다양한 최적화를 사용하려면 production mode 설정 옵션을 사용하세요. (플래그 값을 사용하여 개발 모드에서 사이드 이펙트 최적화가 활성화됩니다)
  • production 모드에서는 일부 기능을 사용할 수 없으므로 devtool에 올바른 값을 설정했는지 확인하세요.

애플리케이션을 나무와 같이 생각할 수 있습니다. 실제로 사용되는 소스 코드와 라이브러리는 나무의 살아있는 잎과 같은 녹색을 나타냅니다. 사용하지 않는 코드는 가을에 바싹 마른 나무의 죽은 잎사귀처럼 갈색입니다. 낙엽을 없애기 위해서 나무를 흔들어서 낙엽을 떨어 뜨려야 합니다.

산출물에 대한 최적화에 더 관심이 있다면 production을 빌드하기 위한 상세 가이드로 이동하세요.

17 Contributors

simon04zacangeralexjovermavant1dmitriidprobablyupgishlumo10byzykpnevaresEugeneHlushkoAnayaDesigntorifatrahul3vsnitin315vansh5632

Translators