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

CommonJS 모듈이 애플리케이션의 트리 쉐이킹에 미치는 영향 알아보기

이 게시물에서는 CommonJS란 무엇이고 왜 JavaScript 번들을 필요 이상으로 만드는지 살펴보겠습니다.

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

CommonJS란?

CommonJS는 자바스크립트 모듈의 규칙을 확립한 2009년부터의 표준입니다. 이 API는 원래 웹브라우저 외부에서 사용하기 위해 고안되었으며 주로 서버 측 애플리케이션을 위해 만들어졌습니다.

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

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

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

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

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

JavaScript 번들러 및 축소기(예: webpack, terser)는 다양한 최적화를 실행하여 앱의 크기를 줄입니다. 빌드 시 애플리케이션을 분석하여 사용하지 않는 소스 코드에서 최대한 많은 부분을 삭제하려고 합니다.

예를 들어 위 스니펫에서 최종 번들에는 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에서 가져오는 기호와 내보내는 기호를 이해할 수 있었기 때문에 가능했습니다.

ES 모듈은 CommonJS보다 정적 분석이 더 용이하기 때문에 이 동작이 기본적으로 사용 설정됩니다.

정확히 동일한 예를 살펴보겠습니다. 이번에는 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 'runtime'(번들 모듈에서 기능을 가져오기/내보내기를 담당하는 코드가 삽입된 코드)이 포함되어 있습니다. 이번에는 utils.jsindex.js의 모든 기호를 동일한 네임스페이스에 배치하는 대신 런타임 시 __webpack_require__를 사용하는 add 함수를 동적으로 요구합니다.

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

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

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

이렇게 하면 축소기는 index.js가 종속 항목에서 정확히 무엇을 사용하는지 파악할 수 없으므로 트리 쉐이킹 작업을 할 수 없습니다. 서드 파티 모듈에서도 똑같은 동작을 관찰할 것입니다. node_modules에서 CommonJS 모듈을 가져오면 빌드 도구 모음에서 이 모듈을 제대로 최적화할 수 없습니다.

CommonJS를 통한 트리 쉐이킹

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

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

결론

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

다음은 최적 경로에 있는지 확인하기 위한 몇 가지 실용적인 팁입니다.

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