CommonJS, esModule 번들링 결과 비교하기

차민철·2022년 5월 1일
0

타입스크립트와 웹팩을 이용하여 CommonJS와 esModule의 차이점을 비교하려고 합니다. 그전에 자바스크립트가 지원하는 모듈이 정확히 무엇인지 모르시다면 링크를 봐주시기 바랍니다. 이 포스트를 쓰게 된 이유는 문든 tsconfig.json에서 module을 수정하다가 CommonJS, ES2015, ... , ESNext 등 종류가 많은데 무엇을 사용해야 될지 몰랐습니다. 그리고 브라우저 환경에서는 CommonJS가 동작하지 않는 것으로 알고 있는데, tsconfig.json에서 module을 CommonJS로 설정하고 dev server를 실행하니 에러가 없다는 것도 의문이었습니다. 분명 어떤 이유로 인해 CommonJS가 다른 형태로 변환됐다는 뜻인데 이에 대한 해답을 알지 못했습니다. 그래서 module에서 여러 값들을 설정하면서 이를 비교하고, CommonJS로 dev server를 실행했을 때 왜 문제가 없는지 알아보려고 합니다.
이를 위해 tsconfig, webpack, react 환경은 아래와 같이 세팅하려고 합니다.

1.tsconfig

{
  "compilerOptions": {
    "target": "ES5" or "ES2015",
    "module": "CommonJS" or "ES2015" or "ESNext",
    "outDir": "dist",
    "jsx": "react",
    "esModuleInterop": true
  }
}

2.webapck.config.js

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
  mode: "development",
  entry: "./src/index",
  resolve: {
    extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
  },
  module: {
    rules: [
      {
        test: /\.(ts)x?$/,
        exclude: /node_modules/,
        loader: "ts-loader",
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({ template: "./public/index.html" })
  ],
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "[name].js",
  },
};

3.tsx

// App.tsx
import React from "react";
const App = () => {
  return <div>hello world</div>;
};
export default App;

// index.tsx
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
ReactDOM.render(<App />, document.getElementById("root"));

ES5 target, ES2015(ES6)

npx webpack

  1. commonJS module
    번들링 된 전체 코드는 너무 길기 때문에 핵심적인 부분만 따로 뽑아냈습니다. webpack_modules, webpack_module_cache라는 변수, webpack_require라는 함수가 가장 중요해 보였습니다. 구조 전체를 확인하니 webpack_modules 변수는 모듈이 있는 경로를 key로 가지고 있으며, 각 value는 함수로 구성되어 있고 각 로직을 eval()로 실행시키는 구조였습니다.
(() => {
  "use strict";
  var __webpack_modules__ = ({
     // ...생략...
    "./src/App.tsx": (function(__unused_webpack_module, exports, __webpack_require__) {
      eval("\r\nvar __importDefault = (this && this.__importDefault) || function (mod) {\r\n    return (mod && mod.__esModule) ? mod : { \"default\": mod };\r\n};\r\nObject.defineProperty(exports, \"__esModule\", ({ value: true }));\r\nvar react_1 = __importDefault(__webpack_require__(/*! react */ \"./node_modules/react/index.js\"));\r\nvar App = function () {\r\n    return react_1.default.createElement(\"div\", null, \"hello world\");\r\n};\r\nexports[\"default\"] = App;\r\n\n\n//# sourceURL=webpack://babel-typescript-sample/./src/App.tsx?");
    })
    "./src/index.tsx": (function(__unused_webpack_module, exports, __webpack_require__) {
      eval("\r\nvar __importDefault = (this && this.__importDefault) || function (mod) {\r\n    return (mod && mod.__esModule) ? mod : { \"default\": mod };\r\n};\r\nObject.defineProperty(exports, \"__esModule\", ({ value: true }));\r\nvar react_1 = __importDefault(__webpack_require__(/*! react */ \"./node_modules/react/index.js\"));\r\nvar react_dom_1 = __importDefault(__webpack_require__(/*! react-dom */ \"./node_modules/react-dom/index.js\"));\r\nvar App_1 = __importDefault(__webpack_require__(/*! ./App */ \"./src/App.tsx\"));\r\nreact_dom_1.default.render(react_1.default.createElement(App_1.default, null), document.getElementById(\"root\"));\r\n\n\n//# sourceURL=webpack://babel-typescript-sample/./src/index.tsx?");
    })
  })

  var __webpack_module_cache__ = {};
  function __webpack_require__(moduleId) {
    var cachedModule = __webpack_module_cache__[moduleId];
    if (cachedModule !== undefined) {
      return cachedModule.exports;
    }
    var module = __webpack_module_cache__[moduleId] = {
      exports: {}
    };
    __webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    return module.exports;
  }

  var __webpack_exports__ = __webpack_require__("./src/index.tsx");
})()

먼저 시작점은 맨 하단에 var __webpack_exports__ = __webpack_require__("./src/index.tsx")입니다. 이 함수가 호출되면 먼저 webpack_module_cache에 "./src/index.tsx"가 캐싱 되었는지 체크합니다. 캐싱이 되었다면 결과값을 바로 반환하지만, 캐싱되지 않았으면 {moduleId:exports:{}}을 생성하고 webpack_module_cache 객체에다가 추가합니다. 그리고 webpack_modules 객체에서 "./src/index.tsx"를 moduleId로 갖는 모듈 함수를 call() 메서드로 호출합니다. call()의 첫 번째 매개변수는 함수에게 전달할 this의 값이 되고, 그 이후에 매개변수는 함수의 인자값이 됩니다. moduleId 값이 "./src/index.tsx"이기 때문에, webpack_modules 변수에서 "./src/index.tsx" key에 접근하여 value를 호출하게 됩니다. 즉, "./src/index.tsx"의 모듈 함수를 호출합니다.
"./src/index.tsx"의 모듈 함수의 동작과정을 알아보기 위해, eval()에 있는 문자열에서 이스케이프 시퀀스를 적용하고 주석들을 제거 해보겠습니다.

var __webpack_modules__ = ({
  "./src/App.tsx": (function(__unused_webpack_module, exports, __webpack_require__) {
    var __importDefault = (this && this.__importDefault) || function (mod) {
      return (mod && mod.__esModule) ? mod : { \"default\": mod };
    };
    Object.defineProperty(exports, \"__esModule\", ({ value: true }));
    var react_1 = __importDefault(__webpack_require__(\"./node_modules/react/index.js\"));
    var App = function () {
      return react_1.default.createElement(\"div\", null, \"hello world\");
    };
    exports[\"default\"] = App;
  }),
  "./src/index.tsx": (function(__unused_webpack_module, exports, __webpack_require__) {
    var __importDefault = (this && this.__importDefault) || function (mod) {
      return (mod && mod.__esModule) ? mod : { \"default\": mod };
    };
    Object.defineProperty(exports, \"__esModule\", ({ value: true }));
    var react_1 = __importDefault(__webpack_require__( \"./node_modules/react/index.js\"));
    var react_dom_1 = __importDefault(__webpack_require__(\"./node_modules/react-dom/index.js\"));
    var App_1 = __importDefault(__webpack_require__(\"./src/App.tsx\"));
    react_dom_1.default.render(react_1.default.createElement(App_1.default, null), document.getElementById(\"root\"));
  })
})

가장 먼저 importDefault 변수가 눈에 띄입니다. 인자값이 esModule이라면 그대로 반환하지만, esModule이 아닐 경우에는 객체를 만들고 default라는 key로 감싸서 반환합니다. exports는 webpack_require 함수 내에서 생성했던 변수입니다. 이 변수에다가 Object.defineProperty()을 통해 {esModule:true}라는 객체를 추가 시킵니다. 이는 importDefault 변수에서 mod.esModule을 체크하기 위해 사용됩니다. 사용자가 작성한 모듈이라면 exports에 esModule이 추가되지만, 라이브러리를 불러온 모듈이라면
{esModule:true}가 추가되지 않습니다. 그래서 이를 통해 사용자가 정의한 모듈인지 아닌지 알 수 있게 됩니다. 예시로 react는 사용자가 만든 모듈이 아니고 라이브러리로 가지고 온 모듈입니다. react 코드를 가지고 오기 위해 webpack_require()에서 react의 경로를 전달하였고, react 모듈에 담긴 eval을 해석했을 때 if (false) {} else { module.exports = __webpack_require__(\"./node_modules/react/cjs/react.development.js\"); }가 실행 됩니다. 사용자가 정의한 모듈이기 아니기 때문에Object.defineProperty(exports, \"__esModule\", ({ value: true }));가 존재하지 않고, importDefault()로 react를 호출하게 되면 esModule가 없기 때문에 default로 감싸집게 됩니다. 그래서 react_dom_1이나 react_1 다음에 .default가 붙은 이유도 이 때문인 것으로 생각합니다. 그 이후에는 App_1가 실행되면서 "./src/App.tsx" 모듈을 호출하게 됩니다. "./src/index.tsx"와 동일하게 동작하지만 마지막 부분에 exports[\"default\"] = App;처럼 모듈을 내보내는 코드가 존재합니다. 이 때에는 exports 객체에다가 default라는 key로 App 함수가 value로 들어가게 됩니다.

  1. ES2015(ES6) module, ESNext module
    이번에는 tsconfig.json에서 module을 ES2015나 ESNext로 설정 하였습니다. 그러면 중요한 부분만 따로 뽑아내서 확인해보겠습니다.
(() => {
  "use strict";
  var __webpack_modules__ = ({
     // ...생략...
    "./src/App.tsx": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
      eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   \"default\": () => (__WEBPACK_DEFAULT_EXPORT__)\n/* harmony export */ });\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! react */ \"./node_modules/react/index.js\");\n\r\nvar App = function () {\r\n    return react__WEBPACK_IMPORTED_MODULE_0__.createElement(\"div\", null, \"hello world\");\r\n};\r\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (App);\r\n\n\n//# sourceURL=webpack://babel-typescript-sample/./src/App.tsx?");
    }),
    "./src/index.tsx": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
      eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! react */ \"./node_modules/react/index.js\");\n/* harmony import */ var react_dom__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! react-dom */ \"./node_modules/react-dom/index.js\");\n/* harmony import */ var _App__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./App */ \"./src/App.tsx\");\n\r\n\r\n\r\nreact_dom__WEBPACK_IMPORTED_MODULE_1__.render(react__WEBPACK_IMPORTED_MODULE_0__.createElement(_App__WEBPACK_IMPORTED_MODULE_2__[\"default\"], null), document.getElementById(\"root\"));\r\n\n\n//# sourceURL=webpack://babel-typescript-sample/./src/index.tsx?");
    })
  });

  var __webpack_module_cache__ = {};
  function __webpack_require__(moduleId) {
    var cachedModule = __webpack_module_cache__[moduleId];
 	if (cachedModule !== undefined) {
      return cachedModule.exports;
 	}
    var module = __webpack_module_cache__[moduleId] = {
      exports: {}
    };
    __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
    return module.exports;
  }

  (() => {
    __webpack_require__.d = (exports, definition) => {
      for(var key in definition) {
        if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
          Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
        }
      }
    };
  })();

  (() => {
    __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
  })();

  (() => {
    __webpack_require__.r = (exports) => {
      if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
        Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
      }
      Object.defineProperty(exports, '__esModule', { value: true });
    };
  })();

  var __webpack_exports__ = __webpack_require__("./src/index.tsx");
})()

commonJS module과 동일하게 webpack_modules 객체가 생성되었습니다. 이 때에도 key는 경로값을 가지고 있고, value는 모듈 함수로 구성되어 있습니다. _또한_webpack_require 함수를 호출하여 모듈을 실행시키는 것도 동일합니다. 그런데 webpack_require 함수에 webpack_require.d, webpack_require.o, webpack_require.r이라는 함수가 추가적으로 생성되었습니다. 코드의 흐름과 주석들을 확인했을 때 각각 다음과 같은 역할을 할 것으로 생각합니다.
webpack_require.d: exports에 definition으로 정의 된 객체를 추가시킴. 즉, definition이 {default:() => {}}라면, exports 객체에 {default:() => {}}가 추가 됨
webpack_require.o: obj 객체 변수에 prop이라는 속성이 존재하는지 체크
webpack_require__.r: exports에 Module 혹은 esModule 속성을 추가

var __webpack_modules__ = ({
  "./src/App.tsx:" (__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
    __webpack_require__.r(__webpack_exports__);
    __webpack_require__.d(__webpack_exports__, {
      \"default\": () => (__WEBPACK_DEFAULT_EXPORT__)
    });
    var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(\"./node_modules/react/index.js\");
    var App = function () {
      return react__WEBPACK_IMPORTED_MODULE_0__.createElement(\"div\", null, \"hello world\");
    };
    const __WEBPACK_DEFAULT_EXPORT__ = (App);
  },
  "./src/index.tsx": (__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
    __webpack_require__.r(__webpack_exports__);
    var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(\"./node_modules/react/index.js\");
    var react_dom__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(\"./node_modules/react-dom/index.js\");
    var _App__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__( \"./src/App.tsx\");
    react_dom__WEBPACK_IMPORTED_MODULE_1__.render(react__WEBPACK_IMPORTED_MODULE_0__.createElement(_App__WEBPACK_IMPORTED_MODULE_2__[\"default\"], null), document.getElementById(\"root\"));
  }
});

이번에도 동일하게 eval() 함수에 문자열 매개변수를 풀어봤습니다. "./src/index.tsx" 모듈이 실행되면서 webpack_require.r()이 호출 되었고, webpack_exports 객체에 "esModule" 속성이 추가되었습니다. 그리고 CommonJS와는 달리 이번에는 importDefault 함수가 존재하지 않습니다. 브라우저 환경에서 모듈 방식이 기본적으로 esModule이기 때문에, CommonJS으로 변환 할 과정이 필요없기 때문에 없는게 아닐까 생각합니다. react 라이브러리의 모듈들을 호출한 후 "./src/App.tsx"의 모듈을 이어서 호출합니다. 이 모듈은 사용자가 작성한 모듈이기 때문에, webpack_require.d()가 존재하는 것으로 보입니다. 이를 통해 webpack_exports 객체에 default라는 key로 App 함수가 value로 추가 되었습니다. 모듈 가져오기를 완료하면 다시 "./src/index.tsx"로 돌아옵니다. 자세히 보면 react나 react_dom을 호출할 때는 default가 없지만, App 모듈을 가져올 때에는 ["default"]가 추가된 것으로 보입니다.

어떤 방식으로 모듈을 내보내고 가지고 오는가?

Commonjs
내보내기: exports[\"default\"] = 내보낼 함수
가져오기: importDefault(webpack_require("가지고 올 모듈 경로"));
esModuel
내보내기:
webpack_require.d(webpack_exports, { \"default\": () => (WEBPACK_DEFAULT_EXPORT) })
가져오기:
webpack_require__("가지고 올 모듈 경로");

결국 어떤 module을 쓸 것인가?

esModule 방식으로 선택하려고 합니다. 즉, ES6 방식을 사용하려고 합니다. 하지만 직접 tsconfig.json에서 module: ES6를 추가하지 않아도 됩니다. 공식문서를 확인했을 때, target === "ES3" or "ES5" ? "CommonJS" : "ES6"이기 때문에 자동으로 module 방식을 정하게 됩니다. 저는 ESNext를 방식으로 target을 지정하고 있기 때문에 module은 자동으로 ES6으로 설정됩니다. 즉, target은 ESNext이고 module은 ES6으로 구성하려고 합니다.

결론

처음에는 tsconfig에서 commonJS와 esModule로 지정했을 때 차이점이 뭘까?라는 질문으로 시작했는데 너무 멀리 간 기분이 듭니다. 하지만 commonJS와 esModule간의 차이점을 알았고, 번들링을 통해 어떠한 결과를 가지게 되는지 알게 되었습니다. 그리고 번들링을 통해 하나의 파일로 합쳐졌기 때문에 내보내고 가지고 오는 문법은 번들러 함수로 대체되고, script 태그에는 type="module"가 불필요하기 때문에 사라지는 것도 알게 되었습니다.

profile
넥슨 -> 플라스크 -> 크래프톤

0개의 댓글