Tree Shaking 관점에서의 CommonJS와 ES Module

기운찬곰·2023년 5월 19일
4

프론트개발이모저모

목록 보기
10/20
post-thumbnail
post-custom-banner

Overview

요즘에 제가 부족하다고 생각되는 부분을 찾아서 공부하고 있는데 그 중 한가지가 바로 모듈 시스템입니다. CommonJS와 ES Module(ESM)에 대한 대략적인 부분은 알고 있지만 이들의 실질적인 차이, 그리고 왜 ESM을 권장하는지를 말해보라고 하면 글쎄요... 참 어려운 주제인거 같습니다. 또한, 이런 파편화된 모듈 시스템이 조금은 JavaScript 를 시작할 때 진입장벽이 되는거 같습니다.

이번 시간에는 그래서 CommonJS와 ES Module(ESM)을 Tree Shaking 관점에서 살펴보려고 합니다.


JavaScript 모듈 시스템 역사

모듈 시스템이 없었던 시절

JavaScript는 그 이전에 모듈에 대한 개념이 존재하지 않았습니다. 각각의 script 파일을 전역 스코프처럼 사용했습니다. 하지만 이는 "전역 오염"이 발생하기 쉬운 구조가 되었습니다. 그래서 전역 변수 사용을 지양하고, 즉시 실행함수를 사용하는 등 방법이 제시되었지만 근본적인 해결책은 될 수 없었습니다.

근본적인 해결책은 모듈을 사용하는 것입니다. 모듈을 사용하면 모듈 스코프안에 관련이 있는 변수와 함수를 한데 모을 수 있습니다. 또한, 모듈 간에 내보내고(export) 가져오기(import)가 가능해지기 때문에 독립적으로 작동할 수 있는 단위가 생기는 것입니다.

이렇듯 JavaScript에 모듈 시스템을 도입하려는 움직임이 시작되면서 클라이언트 사이드와 서버 사이드로 나누어져 모듈 시스템 도입이 고려되었고, 먼저 등장한게 서버 사이드에서의 CommonJS입니다.

CommonJS의 등장

2009년, CommonJS는 Node.js 진영에서 서버 측 애플리케이션에서 사용하기 위해 만들어졌습니다. 덕분에 수많은 서버 사이드 애플리케이션용 자바스크립트 라이브러리가 탄생하게 되었습니다.

CommonJS에서는 require()는 동기로 이뤄진다는 중요한 특징이 있습니다. 아래는 사용 예시입니다.

// @filename: util.cjs
module.exports = (x, y) => x + y

// @filename: main.cjs
const whateverWeWant = require('./util.cjs')
console.log(whateverWeWant(2, 4))

CommonJS는 모든 파일이 로컬에 있어 필요할 때 바로 불러올 수 있는 상황을 전제로 합니다. 즉, 동기적인 동작이 가능한 서버사이드 자바스크립트 환경을 전제로 합니다. 이런 방식은 브라우저에서 사용할 때 문제가 됩니다. 브라우저에서는 CommonJS 방식으로 모듈을 로드할 경우, 메인스레드가 모듈을 모두 불러올 때까지 아무것도 할 수 없는 상태(blocking)이 될 것입니다.

ESM의 등장

ESM은 ECMAScript에서 지원하는 JavaScript공식 모듈 시스템입니다. ES6에서 도입되었습니다. Node.js에서는 12버전부터 CommonJS와 ESM을 동시에 지원하기 시작했습니다. (Node.js ESM 참고)

ESM은 모듈 로더를 비동기 환경에서 실행합니다. 아래는 사용 예시입니다.

// @filename: util.mjs
export default (x, y) => x + y

// @filename: main.mjs
import whateverWeWant from './util.mjs'
console.log(whateverWeWant(2, 4))

ESM 모듈의 경우 동작 방식을 3단계로 나눠서 볼 수 있습니다. "구성, 인스턴스화, 평가" 입니다.

이를 간단하게 설명하자면, ESM은 먼저 가져온 스크립트를 바로 실행하지 않고 import 문을 따라가면서 종속성 트리를 생성합니다. 이 과정에서 ESM 모듈 로더는 스크립트를 비동기로 다운로드하여 파싱합니다. 이런식으로 종속성 트리를 만들고 나면 인스턴스화 과정과 평가 과정을 거칩니다. 즉, 메모리를 할당하고 실제 값을 메모리에 채우는 과정입니다.

상세 참고 : https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/

결론적으로 ESM 모듈 내의 모든 자식 스크립트들은 병렬로 다운로드 되지만, 실행은 순차적으로 진행됩니다.


CommonJS와 ESM의 대표적인 차이

1. 동적 구조와 정적 구조

CommonJS는 동적 구조를 가지고 있습니다. 일례로, CommonJS에서의 아래 구문은 유효합니다. 이는 CommonJS가 동기적으로 동작하기 때문에 가능하며, 전부 실행을 해봐야 알 수 있습니다. 이것은 밑에 나오겠지만 Tree Shaking에도 영향을 미치게 됩니다.

module.exports[localStorage.getItem(Math.random())] = () => {};

if (condition) {
  lodash = require('lodash');
}

require(condition ? 'foo' : 'bar');

require(`${path}/counter.js`).count;

function foo () {
  lodash = require('lodash');
}

반면에 ESM은 저런 구문을 허용하지 않습니다. CommonJS 와 달리 export문은 ESM의 최상위 레벨에만 위치할 수 있습니다. 이런 동작은 Tree Shaking도 가능하게 하는 밑바탕이 됩니다. 또한, 위에서 살펴봤듯이 전부 실행하지 않고도 미리 종속성 트리를 생성할 수 있게 됩니다.

import React from 'react';

export default function foo() {
	...
}

import { count } from `${path}/counter.js`; // 불가능

결론적으로 CommonJS 모듈은 ES 모듈보다 훨씬 동적이기 때문에 일반적인 경우에 최적화하기가 더 어렵습니다. ES 모듈은 CommonJS와 비교하여 정적으로 더 분석 가능하기 때문에 ES 모듈에 대해 tree-shaking이 기본적으로 활성화되어 있습니다.

2. Bindings, Not Values

CommonJS는 require로 로드한 모듈의 값을 사용한다고 합니다. 같은 메모리를 바라보고 있지 않기 때문에, export된쪽에서 값을 변경해도 require한 쪽에서는 변경된 값으로 사용할 수 없습니다.

반면, ESM에서는 import와 export 모두 같은 메모리 주소를 바라봅니다. export한 곳에서 값을 변경하면 해당 변경사항이 import한 곳에서도 변경한 값이 반영됩니다.


Tree Shaking 관점에서 CommonJS와 ESM

Tree Shaking 이란?

Tree shaking is a term commonly used within a JavaScript context to describe the removal of dead code. - MDN 참고

Tree Shaking이란 용어는 rollup 에서 처음 나온 것으로 알고 있습니다. 하지만 그 이전부터 사용되었던 개념이긴 합니다. Tree Shaking의 용어적 의미를 봤을때 나무를 흔든다는 뜻을 가지고 있는데, 이를 모듈 관점에서 보면 모듈 중에서 사용하지 않는 기능의 코드들은 빌드 시에 제거하여 번들 사이즈를 줄인다는 것을 의미합니다.

CommonJS는 Tree Shaking을 제대로 지원하지 못하며, ESM을 사용하면 Tree Shaking을 제대로 지원한다고 알려져있습니다. 이런 차이를 실습을 통해 알아보도록 하겠습니다.

참고 : https://web.dev/commonjs-larger-bundles/
실습 코드 : https://github.com/ckstn0777/commonjs-esm-example

commonjs 방식 살펴보기

예를 들어, 간단한 utils 함수를 만들어 commonjs 방식으로 아래의 5개 모듈을 내보내고 있습니다.

// utils.js
const { maxBy } = require("lodash-es");

const fns = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
  multiply: (a, b) => a * b,
  divide: (a, b) => a / b,
  max: (arr) => maxBy(arr),
};

Object.keys(fns).forEach((fnName) => (module.exports[fnName] = fns[fnName]));

이를 다른 모듈에서 utils 함수 중 일부 또는 전체를 가져와 사용할 수 있습니다.

// index.js
import { add } from "./utils"; 

const subtract = (a, b) => a - b;

console.log(add(1, 2));

그리고 나서 webpack.config.js를 간단하게 만들어줍니다.

// webpack.config.js
const path = require("path");

module.exports = {
  entry: path.resolve(__dirname, "index.js"),
  output: {
    filename: "out.js",
    path: path.resolve(__dirname, "dist"),
  },
  optimization: {
    minimize: false,
  },
  mode: "production",
};

빌드를 시켜보면 out.js가 생성됩니다. 결과를 보면 다소 충격적이게도 625KB라는 크기를 가지고 있습니다. 우리가 사용한건 add 함수뿐인데… 불필요한 것까지 다 긁어왔나봅니다.

참고로 optimization minimize를 true로 하면 88KB가 되긴 하는군요.

❯ cd dist && ls -lah
total 1256
drwxr-xr-x  3 ckstn0777  staff    96B  5 18 11:25 .
drwxr-xr-x  6 ckstn0777  staff   192B  5 18 11:25 ..
-rw-r--r--  1 ckstn0777  staff   625K  5 18 11:25 out.js

out.js 내용을 확인해보면 모든 내용이 다 포함되어있습니다. 특히 lodash-es에서 maxBy을 사용한 max함수가 문제인 것을 알 수 있습니다.

// out.js

// ... 생략...

var __webpack_exports__ = {};
// This entry need to be wrapped in an IIFE because it need to be in strict mode.
(() => {
"use strict";
/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(288);
/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_utils__WEBPACK_IMPORTED_MODULE_0__);


const subtract = (a, b) => a - b;

console.log((0,_utils__WEBPACK_IMPORTED_MODULE_0__.add)(1, 2));

})();

결국 저는 add 만을 import 해서 사용하려고 했지만, 결론적으로 tree shaking이 되지 않아 불필요한 모든 것까지 다 불러와서 번들 크기가 커진 것을 알 수 있었습니다.

esm 방식 살펴보기

이번에는 esm 방식으로 내보내기를 사용해보겠습니다.

// utils.js
import { maxBy } from "lodash-es"; // 15.92KB (6.09KB zipped)

export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export const multiply = (a, b) => a * b;
export const divide = (a, b) => a / b;

export const max = (arr) => maxBy(arr);

마찬가지로 다른 모듈에서 utils 함수 중 일부 또는 전체를 가져와 사용할 수 있습니다.

// index.js
import { add } from "./utils";

const subtract = (a, b) => a - b;

console.log(add(1, 2));

webpack.config.js는 동일하게 해주고 실행시켜봅니다.

❯ yarn run build:esm
yarn run v1.22.19
$ webpack build --config ./esm/webpack.config.js
asset out.js 38 bytes [emitted] [minimized] (name: main)
orphan modules 613 KiB [orphan] 641 modules
./esm/index.js + 1 modules 325 bytes [built] [code generated]
webpack 5.83.1 compiled successfully in 459 ms
✨  Done in 1.25s.

out.js 결과를 보면 420B 라는 엄청난 차이를 보여줍니다. 내용도 단순합니다.

/******/ (() => { // webpackBootstrap
/******/ 	"use strict";
var __webpack_exports__ = {};

;// CONCATENATED MODULE: ./esm/utils.js


const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
const multiply = (a, b) => a * b;
const divide = (a, b) => a / b;

const max = (arr) => maxBy(arr);

;// CONCATENATED MODULE: ./esm/index.js


const esm_subtract = (a, b) => a - b;

console.log(add(1, 2));

/******/ })()
;

실제 optimization minimize: true 까지 했을때는 더 단순해집니다. 최종 번들에는 사용하지 않는 utils.js의 함수가 포함되어 있지 않으며 lodash의 흔적도 없습니다!


Lodash와 Lodash-es

참고 : https://yrnana.dev/post/2021-11-28-lodash-lodash-es/
참고 : https://velog.io/@sangbooom/lodash-vs-lodash-es

이런 차이를 대표적으로 볼 수 있는 라이브러리가 Lodash랑 Moment.js라고 합니다. 그 중 Lodash를 살펴보겠습니다. Lodash에는 lodash와 lodash-es라는 라이브러리가 있습니다. 둘의 차이는 다음과 같습니다.

  • lodash: node.js 모듈 기반으로 내보낸 라이브러리
  • lodash-es: es-module 기반으로 내보낸 라이브러리

lodash 전체를 가져왔을 때와 lodash 일부를 가져왔을 때 결과는 동일합니다. 그 이유는 lodash는 commonjs 로 되어있기 때문에 tree shaking이 안되기 때문입니다.

import _ from 'lodash' // 71.72KB (25.91KB zipped)
import { filter } from 'lodash' // 71.72KB (25.91KB zipped)

하지만, 그렇다고 하더라도 방법은 없는건 아닙니다. cherry-picking 방식을 사용하면 용량이 확 줄어듭니다.

import filter from 'lodash/filter' // 21.08KB (8.29KB zipped)

아니면, lodash-es를 사용하는게 가장 좋은 방법인 듯 합니다. esm 기반으로 되어있어 tree shaking이 가능하기 때문입니다.

import { filter } from "lodash-es"; // 16.22KB (6.22KB zipped)

마치면서

이번 시간을 통해 commonjs보다는 이제는 esm을 사용하는걸 권장하는 이유에 대해 조금이나마 알 수 있었던 거 같습니다. 하지만 그렇다고 해도 여전히 commonjs를 버릴 수는 없는 상황이니 이런 차이를 명확히 알고 필요에 따라 유도리 있게 사용하는게 좋을 거 같습니다.

예를 들면, commonjs와 esm을 동시에 지원한다는 라이브러리를 만들어야 할 수 있을테니까요. 나중에 후속편으로 이런 내용에 대해서도 다뤄보겠습니다.


참고 자료

profile
배움을 좋아합니다. 새로운 것을 좋아합니다.
post-custom-banner

0개의 댓글