CommonJS가 번들을 더 크게 만드는 방법

CommonJS 모듈이 애플리케이션의 트리 쉐이킹에 미치는 영향을 알아봅니다.

이 게시물에서는 CommonJS가 무엇인지, CommonJS로 인해 JavaScript 번들이 필요 이상으로 커지는 이유를 살펴봅니다.

요약: 번들러가 애플리케이션을 최적화할 수 있도록 하려면 CommonJS 모듈에 종속되지 않도록 하고 전체 애플리케이션에서 ECMAScript 모듈 문법을 사용하세요.

CommonJS란 무엇인가요?

CommonJS는 JavaScript 모듈에 대한 규칙을 확립한 2009년 표준입니다. 처음에는 웹브라우저 외부에서 주로 서버 측 애플리케이션에 사용하기 위해 고안되었습니다.

CommonJS를 사용하면 모듈을 정의하고, 모듈에서 기능을 내보내고, 다른 모듈에서 가져올 수 있습니다. 예를 들어 아래 스니펫은 add, subtract, multiply, divide, max의 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]);

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

// index.js
const { add } = require('./utils.js');
console.log(add(1, 2));

node를 사용하여 index.js를 호출하면 콘솔에 3 숫자가 출력됩니다.

2010년대 초 브라우저에 표준화된 모듈 시스템이 없기 때문에 CommonJS는 JavaScript 클라이언트 측 라이브러리에서도 인기 있는 모듈 형식이 되었습니다.

CommonJS가 최종 번들 크기에 미치는 영향은 무엇인가요?

서버 측 JavaScript 애플리케이션의 크기는 브라우저만큼 중요하지 않으므로 CommonJS는 프로덕션 번들 크기를 줄이는 것을 염두에 두고 설계되지 않았습니다. 동시에 분석에 따르면 여전히 JavaScript 번들 크기가 브라우저 앱의 속도를 저하시키는 가장 큰 이유입니다.

webpackterser와 같은 JavaScript 번들러 및 축소기는 앱 크기를 줄이기 위해 다양한 최적화를 실행합니다. 빌드 시간에 애플리케이션을 분석하여 사용하지 않는 소스 코드에서 최대한 많이 삭제하려고 합니다.

예를 들어 위 스니펫에서 add 함수만 최종 번들에 포함되어야 합니다. index.js에서 가져오는 utils.js의 유일한 기호이기 때문입니다.

다음 webpack 구성을 사용하여 앱을 빌드해 보겠습니다.

const path = require('path');
module.exports = {
  entry: 'index.js',
  output: {
    filename: 'out.js',
    path: path.resolve(__dirname, 'dist'),
  },
  mode: 'production',
};

여기서는 프로덕션 모드 최적화를 사용하고 index.js를 진입점으로 사용하겠다고 지정합니다. webpack를 호출한 후 출력 크기를 살펴보면 다음과 같이 표시됩니다.

$ cd dist && ls -lah
625K Apr 13 13:04 out.js

번들 크기가 625KB임을 알 수 있습니다. 출력을 살펴보면 utils.js의 모든 함수와 lodash의 많은 모듈을 확인할 수 있습니다. index.js에서 lodash를 사용하지는 않지만 출력의 일부이므로 프로덕션 애셋에 많은 가중치가 추가됩니다.

이제 모듈 형식을 ECMAScript 모듈로 변경하고 다시 시도해 보겠습니다. 이번에는 utils.js가 다음과 같이 표시됩니다.

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;

import { maxBy } from 'lodash-es';

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

index.js는 ECMAScript 모듈 구문을 사용하여 utils.js에서 가져옵니다.

import { add } from './utils.js';

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

동일한 webpack 구성을 사용하여 애플리케이션을 빌드하고 출력 파일을 열 수 있습니다. 이제 출력40바이트입니다.

(()=>{"use strict";console.log(1+2)})();

최종 번들에는 사용하지 않는 utils.js의 함수가 포함되어 있지 않으며 lodash의 트레이스도 없습니다. 또한 terser (webpack에서 사용하는 JavaScript 축소기)는 console.logadd 함수를 인라인 처리했습니다.

CommonJS를 사용하면 출력 번들이 거의 16,000배 커지는 이유는 무엇인가요?라는 질문이 있을 수 있습니다. 물론 이는 장난감 예시일 뿐입니다. 실제로는 크기 차이가 그렇게 크지 않을 수 있지만 CommonJS가 프로덕션 빌드에 상당한 크기를 추가할 가능성이 있습니다.

CommonJS 모듈은 ES 모듈보다 훨씬 더 동적이므로 일반적으로 최적화하기가 더 어렵습니다. 번들러와 미니파이저가 애플리케이션을 최적화할 수 있도록 하려면 CommonJS 모듈에 종속되지 않도록 하고 전체 애플리케이션에서 ECMAScript 모듈 문법을 사용하세요.

index.js에서 ECMAScript 모듈을 사용하더라도 사용 중인 모듈이 CommonJS 모듈이면 앱의 번들 크기가 줄어듭니다.

CommonJS가 앱을 더 크게 만드는 이유는 무엇인가요?

이 질문에 답하려면 webpackModuleConcatenationPlugin 동작을 살펴본 후 정적 분석 가능성을 논의하겠습니다. 이 플러그인은 모든 모듈의 범위를 하나의 폐쇄 표현식으로 연결하고 코드가 브라우저에서 더 빠르게 실행되도록 합니다. 예를 살펴보겠습니다.

// utils.js
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
// index.js
import { add } from './utils.js';
const subtract = (a, b) => a - b;

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

위에는 index.js에서 가져오는 ECMAScript 모듈이 있습니다. subtract 함수도 정의합니다. 위와 동일한 webpack 구성을 사용하여 프로젝트를 빌드할 수 있지만, 이번에는 최소화를 사용 중지합니다.

const path = require('path');

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

생성된 출력을 살펴보겠습니다.

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

// CONCATENATED MODULE: ./utils.js**
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;

// CONCATENATED MODULE: ./index.js**
const index_subtract = (a, b) => a - b;**
console.log(add(1, 2));**

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

위 출력에서 모든 함수는 동일한 네임스페이스 내에 있습니다. 충돌을 방지하기 위해 webpack은 index.jssubtract 함수 이름을 index_subtract로 변경했습니다.

최소화 도구가 위의 소스 코드를 처리하면 다음을 실행합니다.

  • 사용하지 않는 함수 subtractindex_subtract 삭제
  • 모든 주석과 중복 공백 삭제
  • console.log 호출에서 add 함수의 본문을 인라인 처리합니다.

개발자는 종종 이 사용되지 않는 가져오기 삭제를 트리 셰이킹이라고 부릅니다. 트리 쉐이킹은 webpack이 빌드 시 utils.js에서 가져오는 기호와 내보내는 기호를 정적으로 파악할 수 있었기 때문에 가능했습니다.

이 동작은 CommonJS에 비해 더 정적으로 분석할 수 있으므로 ES 모듈에 기본적으로 사용 설정됩니다.

동일한 예시를 살펴보겠습니다. 이번에는 ES 모듈 대신 CommonJS를 사용하도록 utils.js를 변경합니다.

// 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]);

이 작은 업데이트로 출력이 크게 달라집니다. 이 페이지에 삽입하기에는 너무 길기 때문에 일부만 공유합니다.

...
(() => {

"use strict";
/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(288);
const subtract = (a, b) => a - b;
console.log((0,_utils__WEBPACK_IMPORTED_MODULE_0__/* .add */ .IH)(1, 2));

})();

최종 번들에는 번들된 모듈에서 기능을 가져오고 내보내는 역할을 하는 삽입된 코드인 webpack '런타임'이 포함되어 있습니다. 이번에는 utils.jsindex.js의 모든 기호를 동일한 네임스페이스 아래에 배치하는 대신 런타임에 __webpack_require__를 사용하는 add 함수가 동적으로 필요합니다.

CommonJS를 사용하면 임의의 표현식에서 내보내기 이름을 가져올 수 있으므로 이 작업이 필요합니다. 예를 들어 아래 코드는 완전히 유효한 구성입니다.

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

내보낸 기호의 이름은 런타임 시 사용자 브라우저의 컨텍스트에서만 사용할 수 있는 정보가 필요하므로 빌드 시 번들러가 알 수 있는 방법이 없습니다.

이렇게 하면 최소화 도구가 index.js가 종속 항목에서 정확히 무엇을 사용하는지 이해할 수 없으므로 트리 셰이크를 수행할 수 없습니다. 서드 파티 모듈에서도 동일한 동작이 관찰됩니다. node_modules에서 CommonJS 모듈을 가져오면 빌드 도구 모음에서 이를 올바르게 최적화할 수 없습니다.

CommonJS를 사용한 트리 쉐이킹

CommonJS 모듈은 정의상 동적이므로 분석하기가 훨씬 더 어렵습니다. 예를 들어 ES 모듈의 가져오기 위치는 표현식인 CommonJS에 비해 항상 문자열 리터럴입니다.

사용하는 라이브러리가 CommonJS를 사용하는 방법에 관한 특정 규칙을 따르는 경우 서드 파티 webpack 플러그인을 사용하여 빌드 시간에 사용되지 않는 내보내기를 삭제할 수 있습니다. 이 플러그인은 트리 쉐이킹 지원을 추가하지만 종속 항목에서 CommonJS를 사용할 수 있는 다양한 방법을 모두 다루지는 않습니다. 즉, ES 모듈과 동일한 보장이 제공되지 않습니다. 또한 기본 webpack 동작에 더해 빌드 프로세스의 일부로 추가 비용이 발생합니다.

결론

번들러가 애플리케이션을 최적화할 수 있도록 하려면 CommonJS 모듈에 종속되지 않도록 하고 전체 애플리케이션에서 ECMAScript 모듈 문법을 사용하세요.

다음은 최적의 경로를 따르고 있는지 확인할 수 있는 몇 가지 활용 가능한 팁입니다.

  • Rollup.js의 node-resolve 플러그인을 사용하고 modulesOnly 플래그를 설정하여 ECMAScript 모듈에만 종속되도록 지정합니다.
  • is-esm 패키지를 사용하여 npm 패키지가 ECMAScript 모듈을 사용하는지 확인합니다.
  • Angular를 사용하는 경우 트리 쉐이킹 작업이 불가능한 모듈에 의존하면 기본적으로 경고가 표시됩니다.