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

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

이 게시물에서는 CommonJS가 무엇인지, 그리고 CommonJS가 JavaScript 번들을 필요 이상으로 크게 만드는 이유를 살펴보겠습니다.

요약: 번들러가 애플리케이션을 성공적으로 최적화할 수 있도록 CommonJS 모듈에 의존하지 말고 전체 애플리케이션에서 ECMAScript 모듈 구문을 사용하세요.

CommonJS란 무엇인가요?

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

CommonJS를 사용하면 모듈을 정의하고, 모듈에서 기능을 내보내고, 다른 모듈에서 가져올 수 있습니다. 예를 들어 아래 스니펫은 add, subtract, multiply, divide, max의 다섯 가지 함수를 내보내는 모듈을 정의합니다.

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

nodeindex.js를 호출하면 콘솔에 숫자 3가 출력됩니다.

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

CommonJS는 최종 번들 크기에 어떤 영향을 미치나요?

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

JavaScript 번들러와 최소화기(예: webpackterser)는 앱 크기를 줄이기 위해 다양한 최적화를 실행합니다. 빌드 시간에 애플리케이션을 분석하면 개발자가 사용하지 않는 소스 코드에서 가능한 한 많이 제거하려고 합니다.

예를 들어 위 스니펫에서 최종 번들에는 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가 앱을 더 크게 만드는 이유는 무엇인가요?

이 질문에 답하기 위해 webpack에서 ModuleConcatenationPlugin의 동작을 살펴본 다음 정적 분석 가능성을 살펴보겠습니다. 이 플러그인은 모든 모듈의 범위를 하나의 클로저로 연결하고 브라우저에서 코드를 더 빠르게 실행할 수 있도록 합니다. 예를 살펴보겠습니다.

// 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를 사용하는 경우 트리 쉐이킹 작업이 불가능한 모듈에 의존하면 기본적으로 경고가 표시됩니다.