Webpack Tree Shaking

Tree Shaking에 대해 알아봅시다

Tree Shaking 이란?

보통 tree shaking이라 하면 사용하지 않는 코드를 번들 과정에서 삭제하는것을 의미 합니다.

사용하지 않는 코드란?

이런식으로 re-export 하는데 c.jsexportimport하지 않은 경우, c.jsexport는 사용하지 않는 코드(모듈) 입니다.

module.js에서 cexport했는데, index.js에서 cimport하지 않았습니다. 이 경우 const c 는 사용하지 않는 코드입니다.

그럼 이런것도 사용하지 않는 코드인가?

function a() {
  console.log("a");	
}

function b() {
  console.log("b");	
}

function c() {
  console.log("c");	
}

a();

bc도 사용하지 않는 코드는 맞지만, tree shaking 되는 코드는 아닙니다.

importexport 하지 않았기 때문입니다.

Tree Shaking

그래서 제가 생각하는 tree shaking이란 단순히 사용하지 않는 코드를 삭제하는것이 아니라, 모듈 관점에서 import/export로 연결되지 않는 코드들을 번들에 포함하지 않는것과 또 그런 코드들을 삭제하는것을 의미합니다. 모듈이 핵심인 것이죠!

안쓰는 코드는 그냥 지워버리면 되는거 아닌가요?

저희가 만든 프로젝트 같은 경우라면 import하지 않은 코드들은 그냥 지워버리면 그만이지만, 라이브러리는 다릅니다. 저희가 작성한 코드가 아니기 때문에 쉽사리 지울수가 없습니다. 그래서 라이브러리로부터 import한 코드들만 번들에 포함하도록 webpack에게 일을 맡겨야 합니다.

Tree Shaking은 어떻게 하나요?

tree shaking하는 방법은 간단합니다. 최신 버전의 웹팩을 production mode로 빌드하면 웹팩이 알아서 다 해줍니다.

webpack이 알아서 해주는데 tree shaking을 왜 알아야 할까?

라이브러리

라이브러리를 만드는 경우에는 tree shaking 개념을 알고 있어야 소비자들의 자원이 낭비되지 않습니다.

commonjs

tree shaking의 개념을 알고 있으면 오래전에 commonjs방식으로 개발된 lodash같은 라이브러리를 사용할때 더 조심할 수 있게됩니다.

library를 만든다면, side effect는 알아야 한다!

저희가 라이브러리를 만든다고 가정해보겠습니다. 라이브러리에서 tree shaking을 잘 지원하기 위해서 side effect에 대한 개념을 이해해야 합니다.

side effect ?

side effect는 import하는 것 만으로도 실행되서 외부에 영향을 끼치는 코드를 의미합니다.

대표적으로 polyfill이 있죠.

// polyfills/is-number-polyfill.js

if (!Number.isInteger) {
  // If Number.isInteger is not defined
  Number.isInteger = function (n) {
    return n.toString().indexOf(".") == -1;
  };
}
// src/main.js

import "../polyfills/is-number-polyfill";

const a = 3;
const isAInteger = Number.isInteger(a);
console.log(isAInteger);

main.js에서 polyfill코드를 import했습니다.

tree shaking의 기본 개념대로라면

저렇게 export없이 import만 있는 코드들은 트리쉐이킹 할때 떨어져 나가야할 코드입니다만, 

보시다싶이 폴리필일수도 있고 저 코드가 다른 코드에 어떤 영향을 줄지 모르기 때문에  

웹팩은 기본적으로 모듈에 side effect가 있다고 전제 합니다.

그래서 웹팩은 development mode에서 저 코드를 삭제하지 않고 번들에 포함합니다.

하지만! polyfill 코드 하나 때문에 다른 모든 코드가 side effect가 있다고 치부되는것은 너무 아쉽습니다. tree shaking될 기회를 박.탈. 당했기 때문이죠.

sideEffects: [“..”]

// package.json

{
  "sideEffects": ["./src/is-number-polyfill.js"]
}

그래서 이런식으로 적어주면 is-number-polyfill.js는 side effect가 있는 코드가 되고, 나머지는 모두 side effect가 없는 코드가 되서 tree shaking이 잘 이뤄집니다.

sideEffects: false

false로 하면 모든 모듈에 side effect가 없다고 뜹니다. 그래서 side effect가 없는 경우에는 꼭 적어줘야 하며, 있는 파일들은 위의 예시처럼 파일명을 다 적어줘야합니다. 그래야 라이브러리 사용자의 번들러가 tree shaking을 잘 할 수 있습니다.

sideEffects를 지정해준 라이브러리 : react-hook-form tanstack-query

Tree Shaking 실험

re-exports

실험 환경

webpack.config.js

mode: development

usedExports: true

usedExportstrue로 하면 사용한 export, 사용하지 않은 export를 조사합니다. 지금은 development모드라서 번들 결과에 comment를 다는 정도만 사용되지만, production 모드에서는 다른 옵션과 함께 최적화에 사용됩니다.

https://webpack.kr/configuration/optimization/#optimizationconcatenatemodules

https://webpack.kr/configuration/optimization/#optimizationusedexports

코드

// main.js

import { a, b } from "./utils";

a();
b();
// utils/index.js

export { a } from "./a";
export { b } from "./b";
export { c } from "./c";
// utils/a.js

export const a = () => {
  console.log("a");
  return 10;
};

// utils/b.js

export const b = () => {
  console.log("b");
  return 10;
};

// utils/c.js

export const c = () => {
  console.log("c");
  return 10;
};

sideEffects를 비워둔 경우

빌드 결과

/* harmony export */
const a = () => {
  console.log("a");
  return 10;
};

/* harmony export */
const b = () => {
  console.log("b");
  return 10;
};

/* unused harmony export c */
const c = () => {
  console.log("c");
  return 10;
};

의의

sideEffectsfalse로 안해줬기 때문에 c를 함부로 지우지 못합니다.

다만, usedExportstrue로 했기 때문에 unused harmoy export c라는 주석이 붙었습니다.

sideEffects: false

/* harmony export */
const a = () => {
  console.log("a");
  return 10;
};

/* harmony export */
const b = () => {
  console.log("b");
  return 10;
};

side effect가 없다고 선언했기 때문에 웹팩은 맘편히 c를 삭제했습니다

export in one file

실험 환경

webpack.config.js

mode: development

usedExports: true

코드

// utils.js

export const a = () => {
  console.log("a");
  return 10;
};

export const b = () => {
  console.log("b");
  return 10;
};

export const c = () => {
  console.log("c");
  return 10;
};
// main.js 

import { a, b } from "./utils.js";

a();
b();

sideEffects를 비워두었을때

빌드 결과

/* unused harmony export c */
const a = () => {
  console.log("a");
  return 10;
};

const b = () => {
  console.log("b");
  return 10;
};

const c = () => {
  console.log("c");
  return 10;
};

예상대로 c가 안지워졌습니다. 어떤 side effect가 있을지 모르기 때문이죠.

sideEffect: false

빌드 결과

/* unused harmony export c */
const a = () => {
  console.log("a");
  return 10;
};

const b = () => {
  console.log("b");
  return 10;
};

const c = () => {
  console.log("c");
  return 10;
};

import *

실험 환경

webpack.config.js

mode: development

usedExports: true

코드

import * as AllUtils from "./utils";

AllUtils.a();
AllUtils.b();
// utils/index.js

export { a } from "./a";
export { b } from "./b";
export { c } from "./c";
// utils/a.js

export const a = () => {
  console.log("a");
  return 10;
};

// utils/b.js

export const b = () => {
  console.log("b");
  return 10;
};

// utils/c.js

export const c = () => {
  console.log("c");
  return 10;
};

sideEffects를 비워두었을때

빌드 결과

/* unused harmony export c */
const a = () => {
  console.log("a");
  return 10;
};

const b = () => {
  console.log("b");
  return 10;
};

const c = () => {
  console.log("c");
  return 10;
};

sideEffects: false

빌드 결과

/* harmony export */
const a = () => {
  console.log("a");
  return 10;
};

/* harmony export */
const b = () => {
  console.log("b");
  return 10;
};

실험 환경

webpack.config.js

mode: development

usedExports: true

코드

import { a, b } from './utils.js'
// utils/a.js

export const a = () => {
  console.log("a");
  return 10;
};

// utils/b.js

export const b = () => {
  console.log("b");
  return 10;
};

// utils/c.js

export const c = () => {
  console.log("c");
  return 10;
};

sideEffects를 비워두었을때

빌드 결과

/* unused harmony export a */
const a = () => {
  console.log("a");
  return 10;
};

/* unused harmony export b */
const b = () => {
  console.log("b");
  return 10;
};

/* unused harmony export c */
const c = () => {
  console.log("c");
  return 10;
};

안에 어떤 side effect가 있을지 모르기 때문에 사용하지 않더라도 코드를 번들에 포함했습니다.

다만, unused harmony export 주석이 모두 붙어있습니다.

sideEffects: false

빌드 결과

import해놓고 사용하지 않았는데 side effect도 없다고 했으니 지워졌습니다.

production mode

위의 실험들은 development mode로 진행한 것입니다. production mode로 하면 sideEffects의 여부와 관계 없이 알아서 안쓰는것들을 다 찾아 삭제합니다.

다만! sideEffectsfalse로 하면, 모듈 자체를 번들 과정에서 skip해 버리기 때문에 더 빠르게 빌드 할 수 있습니다. 라이브러리를 만든다면 side effect를 잊지 마세요!.

tree shaking 과 commonjs

CommonJS는 2009년에 세상에 나왔습니다. CommonJS 덕분에 자바스크립트 코드를 더 쉽게 모듈화 할 수 있게 되었습니다.

CommonJS방식에서는 모듈을 다음과 같은 방식으로 불러옵니다.

const thePackage = require(“package”);

그런데 이 require는 함수이기 때문에, 이런식으로도 작성할 수 있습니다.

let myModule = null;
if (Maht.random()) {
  myModule = require("./my-module");
} else {
  myModule = require("./other-module");
}

myModule.fn();

이렇게 동적으로 import를 하는것이 도움될때도 있지만, 이런 작동 방식은 tree shaking을 어렵게 만듭니다.

어떤것을 import하고 export하는지 컴파일 타임에 알수가 없기 때문입니다. 그래서 함부로 삭제할수 없습니다. 따라서 commonjs 방식으로 작성된 라이브러리는 기본적으로 웹팩에서 tree shaking을 지원하지 않습니다. 예시를 살펴보겠습니다.

예시

환경

sideEffects: false

커스텀 패키지(라이브러리)

// index.js

const isNull = require("./isNull");
const eq = require("./eq");

module.exports = {
  isNull,
  eq
}
// isNull.js
function isNull(value) {
  return value === null;
}

module.exports = isNull;
function eq(value, other) {
  return value === other || (value !== value && other !== other);
}

module.exports = eq;
{
  "name": "test-lodash",
  "version": "0.0.1",
  "sideEffects": false
}

내 코드

// main.js

import { isNull } from "_testLodash";

const a = window.localStorage("a");
if (isNull(a)) {
  console.log("a is null!");
}

빌드 결과

function eq(value, other) {
  return value === other || (value !== value && other !== other);
}

module.exports = eq;

function isNull(value) {
  return value === null;
}

module.exports = isNull;

사용하지 않은 eq 함수도 포함 되어있습니다.

webpack5 에서 조금 지원

하지만 webpack5에 오면서 상황이 개선되었습니다. 몇몇 상황에서는 tree shaking을 지원해줍니다.

예를들어, moduleexport하는 방식을 아래와 같이 변경해 주면

module.exports.isNull = require("./isNull");
module.exports.eq = require("./eq");

빌드 결과

/***/ "./node_modules/_testLodash/isNull.js":
/*!********************************************!*\
  !*** ./node_modules/_testLodash/isNull.js ***!
  \********************************************/
/***/ ((module) => {

function isNull(value) {
  return value === null;
}

module.exports = isNull;

eq함수는 사라지고 isNull만 남아있습니다.

결론

그래서 CommonJS 방식의 라이브러리를 쓸때는 조심 해야합니다. 자칫하다가는 사용하지 않는 코드까지 전부 번들에 포함될 수 있기 때문입니다.

tree shaking과 ESModule

지금까지의 테스트는 대부분 ESM으로 진행되었습니다. importexport 구문을 지원하는 ESM은 정적인 특성 덕분에 tree shaking과 잘 어울립니다.

결론!

라이브러리 프로젝트라면 side effect꼭 신경쓰고, package.json에 적어줍니다. 그렇지 않으면 라이브러리 소비자들의 번들 사이즈가 커지고 빌드 속도도 느려질것입니다.

일반 프로젝트라면? commonjs방식으로 작성된 라이브러리를 사용할때 딱 필요한 파일만 명시해서 import 합니다. 예를들어

import isNull from 'lodash/isNull' 이런식으로!

import { isNull } from 'lodash' 이런식으로 하면 안쓰는 모듈까지 전부 번들에 포함됩니다. production mode를 킨다 하더라도 말이죠!

긴 글 읽어주셔서 감사합니다.

(정리가 잘 안되어 있지만) 테스트 코드 확인 가능합니다.

Leave a Reply

Your email address will not be published.