You don't know JS module


작성자: 박정환 (FE개발랩)

서문

자바스크립트 개발을 하다 보면 다양한 모듈 정의 방법을 마주치게 된다. ES 모듈, CommonJS 모듈, AMD 모듈 등 자바스크립트에는 다양한 모듈 시스템이 공존하는데, 각 모듈은 모두 다른 방식으로 모듈을 정의하며 객체를 내보내고 가져오도록 설계되어있다. 그러나 React, Vue 등의 프레임워크로 애플리케이션을 개발해 본 독자라면, 모듈을 가져올 때 이러한 다양한 모듈 포멧을 신경쓰면서 사용해 본 기억이 없을 것이다. 그동안 번들러를 사용해서 ES6 환경으로 개발을 할 때 CommonJS 모듈을 ES6의 import 문법으로 가져오더라도 에러가 발생하지 않는 게 의심스럽지는 않았는가? ES 모듈의 import ~ from ~; 문법을 통해서 가져와도 잘 동작하니까 "그냥" 사용하지는 않았는가? 이제 슬슬 마음속에 "번들러나 타입스크립트 컴파일러 안에서 도대체 무슨 일이 일어나고 있는 걸까?"라는 질문이 떠오르길 바란다. 그렇다면 이 글을 통해 ES 모듈에만 있는 방법인 default import(이하 기본값 가져오기)를 이용해서 다른 방식의 모듈을 가져와 사용할 수 있는 이유, 그리고 번들러와 트랜스파일러 그리고 타입스크립트는 어떤 방법으로 서로 다른 모듈끼리 가져오기를 지원하는지 알아보도록 하자.

글을 쓰게 된 이유

이 글은 타입스크립트를 사용하는 사용자가 TOAST UI Image Editor를 사용하던 중, 타입스크립트 컴파일러에서 TS1192: Module "tui-image-editor" has no default export.와 같은 에러가 발생한다고 등록한 이슈로 인해 작성하게 되었다. 예제 코드를 보니 CommonJS 방식으로 내보낸 Image Editor 모듈을 ES 모듈 방식인 기본값 가져오기로 가져와서 생긴 이슈였다. 하지만 타입 정의 파일을 테스트에서도 동일하게 기본값 가져오기를 사용하고 있었지만 에러 없이 테스트가 성공하고 있었다.

왜 잘 작동하고 있었을까?

타입 정의 파일 테스트에서는 왜 잘 동작하는지 확인하기 위해 이유를 파악해보았다.

모듈 가져오고 내보내는 코드 확인

가장 먼저 확인할 부분은 모듈을 가져오고 내보내는 코드이기 때문에 새로운 타입스크립트 파일을 만들고 Image Editor 모듈을 가져오는 코드를 작성했다.

importTest.ts

import ImageEditor from 'tui-image-editor';

const instance = new ImageEditor(/* ... */);

타입스크립트 컴파일러로 컴파일해 보니 아무런 에러 없이 정상적으로 잘 동작한다. 그럼 실제로 Image Editor 모듈에서 ImageEditor 생성자 함수를 내보내는 코드는 어떻게 작성되어있을까?

imageEditor/src/js/index.js

//...

module.exports = ImageEditor;

확인해보니 ImageEditor 생성자 함수는 CommonJS 모듈로 정의되어 있었다. 분명 가져오는 코드는 ES 모듈의 기본값 가져오기이고, 내보내는 코드는 CommonJS 모듈 방식이다. 하지만 ES 모듈의 기본값 가져오기로 모듈을 가져올 때 에러와 경고 문구가 표시되지 않았다. 타입스크립트는 모듈을 정의한 타입을 확인하니까 타입 정의 파일이 잘못된 건 아닐까 생각이 들었다.

타입 정의 파일 살펴보기

이번에는 타입스크립트로 정의된 모듈의 타입이 ES 모듈인지 의심스러워서 타입스크립트 타입 정의 파일인 index.d.ts를 살펴봤다.

index.d.ts

//...

declare module 'tui-image-editor' {
  export = tui.imageEditor; // CommonJS 내보내기 방식 (실제 작성되어 있는 코드)
  // export default tui.imageEditor; // 이렇게 작성된 방식이 ES 모듈의 내보내기 방식이다. (기본값 내보내기) 
}

tui-image-editor 모듈은 CommonJS 모듈로 잘 정의되어있었다. 실제 내보내는 코드와 타입 정의 파일에 모듈이 CommonJS 방식으로 정의되어 있었는데 어떻게 ES 모듈의 가져오기 문법으로 잘 동작했던 걸까? 작성된 코드들에서는 테스트 코드가 잘 동작하는 이유를 찾을 수가 없었다.

타입스크립트는 개발 당시에는 타입스크립트 파일을 생성하여 타입스크립트 문법으로 개발하지만, 실제 코드를 실행하기 위해서는 타입스크립트 컴파일러를 사용해서 타입스크립트 코드를 자바스크립트로 컴파일을 한다. 그렇게 얻은 자바스크립트 코드를 실제 브라우저나 Node.js같은 실행 환경에서 실행하는 것이다. 그렇다면 타입스크립트 컴파일러가 하는 일에 실마리가 있을 거라 생각하고 타입스크립트 설정 파일을 확인했다.

타입스크립트 컴파일러 옵션 확인

타입스크립트 설정 파일인 tsconfig.json에는 compilerOption 으로 타입스크립트의 컴파일러에 옵션을 넘길 수 있다. 타입 정의 파일 테스트를 위해 추가한 tsconfig.json을 확인하니 기존 테스트가 왜 잘 동작했는지 알 수 있었다. compilerOptionsesModuleInterop 옵션이 켜져 있었기 때문이었다.

test/types/tsconfig.json

{
    "compilerOptions": {
        "esModuleInterop": true,
        "noEmit": true,
        "noImplicitAny": false
    }
}

esModuleInterop을 켜면 자바스크립트 코드로 컴파일 할 때 ESModule과 다른 모듈을 지원해주는 코드를 추가한다. esModuleInterop 옵션을 끄니 이슈를 등록한 사용자와 같은 에러를 확인할 수 있었다.

importTest.ts

import ImageEditor from 'tui-image-editor'; // TS1192: Module "tui-image-editor" has no default export.

const instance = new ImageEditor(/* ... */);

그럼 타입스크립트에서는 CommonJS 모듈을 어떻게 가져와야 할까?

타입스크립트에서 CommonJS 모듈을 가져오는 방법

타입스크립트에서 esModuleInterop 옵션을 사용하지 않고 CommonJS 모듈을 가져오려면 import ImageEditor = require('tui-image-editor'); 와 같은 독특한 문법을 사용해야 한다.

importTest.ts

import ImageEditor = require('tui-image-editor'); // CommonJS 모듈을 정상적으로 가져올 수 있다.

const instance = new ImageEditor(/* ... */);

위 예제 코드처럼 모듈을 가져오는 코드를 바꾸니 기본값 내보내기가 없다고 발생하던 에러가 사라졌다. 그리고 자바스크립트 개발 시에도 CommonJS로 정의된 모듈을 모두 import문으로 가져오고 있던 게 떠올랐다. 그래서 자바스크립트 트랜스파일러 Babel과 번들러인 Webpack, 그리고 타입스크립트 컴파일러는 이를 어떻게 처리하는지 궁금해졌다.

다른 모듈을 어떻게 동일하게 사용할 수 있는지 방법을 알아보기 전에 알아두면 좋을 내용이 있다. 왜 표준 문법인 ES 모듈 말고 다른 방식들을 사용하는지, 그리고 모듈 방식을 지원하는 도구들은 어떻게 발전해 왔는지 알게 된다면 Webpack과 타입스크립트가 하는 모듈 호환 처리 방법의 이유를 더 쉽게 이해할 수 있을 것이다.

자바스크립트의 모듈과 이를 지원하는 도구들

자바스크립트도 다른 언어들과 마찬가지로 파일을 여러 개로 나누고, 모듈 단위로 개발할 수 있다. 그런데 왜 자바스크립트에는 모듈 시스템이 한 개가 아닌 걸까? ECMAScript2015 즉 ES6가 배포되기 전에는 ECMAScript에 모듈이라는 개념이 존재하지 않았다. 규모가 큰 프로젝트일수록 모듈 개념이 없어서 의존성을 관리하기 매우 불편했다. 모듈 간 의존성을 관리하기 위해 표준은 아니지만, AMD나 CommonJS와 같은 여러 모듈 개념을 만들었고, 각 모듈 방식을 토대로 여러 파일을 한 개, 혹은 몇 개의 파일로 합쳐주는 requireJS, Browserify, Webpack등의 번들러가 개발되었다. 한동안 사람들은 번들러들을 통해 모듈 개발을 계속했고 모듈 개발 방식이 널리 퍼졌다. 하지만 2015년에 ES6 명세가 등장하면서 모듈 개념이 정식으로 ECMAScript 명세에 수록되었다. ES6를 지원하는 환경이 많지 않을 당시에 Babel이 등장하여 ES6 코드를 ES5 코드로, ES 모듈을 CommonJS로 트랜스파일 해주면서 표준인 ES 모듈 개발 환경또한 이미 나와 있는 모듈 방식들을 대체해 갔다. Webpack은 풍부한 plugin을 통해 번들러 기능 외에 Task Runner인Grunt, gulp 같은 다양한 기능들도 수행할 수 있어서 많이 사용되고 있었지만, ES6가 출시된 당시의 Webpack1은 ES6 문법을 처리하지 못했다. 하지만 Babel을 이용해 CommonJS 모듈 형태로 변환된 코드는 번들링 할 수 있었으므로 Babel loader + Webpack을 쓰는 패턴이 많이 사용되었다. Webpack1으로 개발된 라이브러리들이 지금까지도 잘 동작하고 있고, 현재 유명한 프런트 엔드 프로젝트들도 Babel이나 Webpack을 사용하고 있다. 최근에 개발된 프로젝트가 ES 모듈을 사용하더라도 필요에 따라 CommonJS 모듈 방식으로 내보내진 모듈을 사용하기도 있으므로, 여러 모듈 방식의 코드들이 웹 프런트 엔드 생태계에 공존할 수 있었다.

현재 활발히 사용되고 있는 모듈은 ES 모듈과 CommonJS 모듈 두 가지므로 이후의 설명은 ES 모듈과 CommonJS 모듈 방식을 같이 사용하는 방법으로 한정하겠다. 그렇다면 두 모듈 방식의 가져오기/내보내기 방법이 얼마나 다르길래 별도의 처리가 필요한지 모듈을 가져오고 내보내는 코드를 살펴보자.

ES 모듈과 CommonJS 모듈의 차이

모듈 개발에 친숙하지 않은 독자들을 위해 각 모듈의 사용법을 먼저 설명하겠다. 이미 알고 있는 독자들도 다시 한번 읽으면서 모듈 내보내기/가져오기 방법을 상기시켜보자. 모듈이 이미 내보내져 있어야 가져올 모듈이 생기니, 우선 모듈의 내보내기 방법 먼저 설명하겠다.

CommonJS 모듈의 내보내기 방법

CommonJS 모듈 방식에서 모듈을 내보내는 방법은 한가지다. module.exports 객체를 조작하는 방법이다. 해당 객체에 프로퍼티를 추가하거나, 다른 객체로 치환하면 다른 모듈에서 가져와서 사용할 수 있다.

  1. 프로퍼티로 추가하여 내보내기
module.exports.crop = function() {};
module.exports.rotate = function() {};
  1. module.export 객체를 치환하여 내보내기
module.exports = {
  crop: function() {},
  rotate: function() {}
};

CommonJS 모듈의 가져오기 방법

모듈을 가져오는 방법은 require()라는 함수의 반환 값을 사용하는 형태다.

var filter = require('filter');

filter.crop();
filter.rotate();

require() 함수 실행을 통해 가져온 모듈은 앞서 내보낸 module.export 객체를 받은 것처럼 동작한다.

ES 모듈의 내보내기 방법

ES 모듈은 CommonJS 모듈과 다르게 두 가지 방법으로 내보낼 수 있다.

  1. 이름 붙인 내보내기(Named export)
  2. 기본값 내보내기

이름 붙인 내보내기는 말 그대로 이름을 붙여서 내보내는 것이다.

// filter.js
export const crop = function() {};
export const rotate = function() {};
// graphics.js
import {crop, rotate} from './filter';

crop();

rotate();

위의 예제코드처럼 한 파일에서 여러 개의 객체를 내보낼 수 있고, import문으로 그 모듈을 가져올 때는 반드시 export문에서 정의한 모듈 이름으로 가져와야 한다. (물론 as키워드로 가져온 이름을 바꿀 수는 있다) 이번에는 기본값 내보내기 문법을 살펴보자.

// filters.js
const filterNames = ['retro', 'sharpen'];

export default filterNames;
// command
import filters from './filters'; 

filters[0]; // 'retro'
filters[1]; // 'sharpen'

기본값 내보내기는 이름 붙인 내보내기와는 다르게 내보내는 객체에 이름을 정의하지 않는다. 그리고 한 모듈 파일당 한 번만 사용할 수 있다.

ES 모듈의 가져오기 방법

ES 모듈에서는 importfrom으로 모듈을 가져온다. 그리고 당연하겠지만 가져오는 방법도 가져오기와 기본값 가져오기 두 종류이다.

import {add, average} from 'mathmatics'; // 이름 붙인 가져오기
import TextBox from 'textBox';           // 기본값 가져오기
//...

이름 붙인 가져오기는 중괄호 안에 가져올 모듈 파일에 정의된 객체의 이름을 작성해서 가져온다. 반면에 기본값 가져오기는 중괄호 없이 그냥 이름을 사용하는 것을 볼 수 있다.

두 모듈을 같이 사용할 수 있는 방법

두 모듈의 가져오기/내보내기 방식이 달라서 서로 다른 타입의 모듈을 그대로 가져와서 사용하려면 추가적인 처리를 해야 하거나, 문제가 생기게 된다. 이제는 두 모듈을 모두 기본값 가져오기로 사용할 수 있었던 방법에 대해 알아볼 것이다. 현재 다양한 도구가 ES 모듈에서 CommonJS 모듈을 가져오고, CommonJS 모듈에서 ES 모듈을 가져와서 사용할 수 있는 방법을 지원하고 있다. 먼저, 가장 널리 사용되는 번들러인 Webpack이 어떻게 두 모듈을 함께 사용할 수 있도록 처리하는지 살펴보고, 그 과정을 우리가 직접 따라 해 보면서 원리를 알아보자.

Webpack은 어떻게 처리할까?

먼저 Webpack4를 이용해서 ES6 환경으로 개발한 자바스크립트 프로젝트를 번들링 할 것이다. 그리고 그 프로젝트에서 ES6 모듈과 CommonJS 모듈 방식으로 구현된 모듈들을 가져와서 사용해야 한다고 가정하자. 예제 코드는 다음과 같다.

원본 코드

index.js - 가져오는 모듈

import bold, {boldTagName} from './bold';
import italic, {italicTagName} from './italic';

let isBold = true;

export function sayHello(name) {
  const formatter = isBold ? bold : italic;

  isBold = !isBold;

  return `Hello! ${formatter(name)}!`;
}

console.log(sayHello('정환')); // 'Hello! <b>정환</b>!'
console.log(sayHello('정환')); // 'Hello! <i>정환</i>!'
console.log(boldTagName); // 'b'
console.log(italicTagName); // 'i'

bold.js - 내보내는 모듈 (CommonJS 모듈)

module.exports = function(name) {
  return '<b>' + name + '</b>';
};
module.exports.boldTagName = 'b'

italic.js - 내보내는 모듈 (ES 모듈)

export const italicTagName = 'i';
export default function(name) {
  return '<i>' + name + '</i>';
}

이 코드는 Hello! <b>정환</b>! 처럼 전달한 이름을 인사말과 각 태그로 감싸서 반환하는 간단한 예제다. sayHello() 함수를 호출할 때마다 메시지의 이름이 볼드, 이탤릭으로 바뀌며 반환된다. 그럼 이 코드를 Webpack을 통해 번들링 해보자. 번들링 된 코드는 실제로 더 길고 복잡하지만 우리는 각 모듈을 어떻게 가져오고 내보내는지만 확인하면 되므로, 다른 코드들은 생략하겠다.

번들된 코드

// ...
{
  "./src/index.js": (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    
    __webpack_require__.r(__webpack_exports__);
    /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "sayHello", function() { return sayHello; });
    /* harmony import */ var _bold__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./bold */ "./src/bold.js");
    /* harmony import */ var _bold__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_bold__WEBPACK_IMPORTED_MODULE_0__);
    /* harmony import */ var _italic__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./italic */ "./src/italic.js");
    
    let isBold = true;
    
    function sayHello(name) {
      const formatter = isBold ? _bold__WEBPACK_IMPORTED_MODULE_0___default.a : _italic__WEBPACK_IMPORTED_MODULE_1__["default"];
    
      isBold = !isBold;
    
      return `Hello! ${formatter(name)}!`;
    }
    
    console.log(sayHello('정환'));
    console.log(sayHello('정환'));
    console.log(_bold__WEBPACK_IMPORTED_MODULE_0__["boldTagNmae"]);
    console.log(_italic__WEBPACK_IMPORTED_MODULE_1__["italicTagName"]);
  }),
  
  "./src/bold.js": (function(module, exports) {
    module.exports = function(name) {
      return '<b>' + name + '</b>';
    };
    module.exports.boldTagNmae = 'b';
  }),
  
  "./src/italic.js": (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    __webpack_require__.r(__webpack_exports__);
    /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "italicTagName", function() { return italicTagName; });
    const italicTagName = 'i';
    /* harmony default export */ __webpack_exports__["default"] = (function(name) {
      return '<i>' + name + '</i>';
    });
  }),
  
  0: (function(module, exports, __webpack_require__) {
    module.exports = __webpack_require__(/*! ./src/index.js */"./src/index.js");
  })
}
// ...

지금은 모듈을 가져오고 사용하는 API에만 집중하자. 코드를 보면 먼저 이름이 길고 복잡한 코드들이 눈에 띈다. 그 중 __webpack_require____webpack__exports__는 이름만 봐도 모듈을 가져오고 내보내는 코드라는 느낌이 든다. "./src/index.js" 키에 할당된 함수 내부를 보면, import문 대신 __webpack_require__() 라는 함수를 통해 모듈을 가져오도록 바뀌었다. 그리고 __webpack_require__.d() 함수에 __webpack_exports__를 넘기면서 "sayHello" 텍스트와 sayHello() 참조를 반환하는 함수도 같이 넘기면서 모듈을 정의하고 있다. 이 두 함수와 객체는 Webpack이 ES 모듈을 CommonJS와 같은 API 형상으로 사용하기 위해 Webpack이 독자 구현한 함수다. Webpack은 CommonJS로 정의된 모듈을 가져올 때 ES 모듈의 기본값 가져오기 코드를 만나면 별도의 함수를 통해서 다시 한번 감싼 객체를 사용한다. 그래서 CommonJS 모듈을 사용한 코드에선 _bold__WEBPACK_IMPORTED_MODULE_0__대신 __webpack_require__.n()를 사용해서 반환한 _bold__WEBPACK_IMPORTED_MODULE_0___default를 사용하며, _bold__WEBPACK_IMPORTED_MODULE_0___default.a로 실제 모듈을 사용하는 것을 볼 수 있다. ES 모듈의 문법으로 내보낸 객체는 별도로 default 처리된 객체를 사용하지 않고 _italic__WEBPACK_IMPORTED_MODULE_1__["default"]와 같이 가져온 모듈의 프로퍼티에 직접 접근해서 사용한다.

Webpack이 번들한 ES6 코드를 보니, 어떤 방법으로 두 모듈을 함께 사용하는지 어느 정도 감이 오기 시작할 것이다.

근데 Webpack은 왜 이렇게 처리하고 있을까? (feat.Babel의 영향력)

Webpack이 이 방법으로 두 모듈을 처리하는 데는 이유가 있다. 현재 가장 널리 쓰이고 있는 자바스크립트 트랜스파일러, 그리고 ES 모듈을 CommonJS 모듈과 함께 사용할 수 있게 코드 변환을 지원하는 도구는 바로 Babel이다. Babel은 ES6 문법의 코드를 대부분의 환경에서 지원 가능한 ES3나 ES5 코드로 바꿔주는 역할을 한다. Babel이 ES 모듈의 코드를 CommonJS 모듈의 문법으로 바꿔주면서 이 방법이 이후에도 널리 사용되게 된다. 동일한 코드를 Babel의 preset-es2015를 사용해서 바꾼 결과를 보면 이해가 갈 것이다.

"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.sayHello = sayHello;

var _bold = _interopRequireDefault(require("../bold"));
var _italic = _interopRequireDefault(require("./italic"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

var isBold = true;

function sayHello(name) {
  var formatter = isBold ? _bold.default : _italic.default;
  isBold = !isBold;
  return "Hello! ".concat(formatter(name), "!");
}

예제 코드를 간단하게 설명해보겠다. Babel은 ES 모듈의 내보내기 문법을 사용한 모듈에는 module.exprots.__esModule 플래그를 true로 설정한다. 그리고 ES 모듈로 기본값 가져오기를 통해 가져오는 코드를 만나면 _interopRequireDefault() 함수를 생성하고, 가져온 객체를 그대로 사용하지 않고 객체의 defalut 프로퍼티에 접근하여 사용하도록 코드를 바꾼다. 이렇게 바꿔주면 CommonJS 모듈은 module.exports에 할당한 객체가 새로운 객체의 default 프로퍼티에 할당되어 감싸진 상태로 반환된다. 이런 방법으로 ES 모듈에서 기본값 내보내기한 객체와 같은 방법으로 사용할 수 있다.

자바스크립트 모듈에서 말한 것처럼 Webpack1은 CommonJS 문법으로 정의된 모듈을 번들링 해주는 도구였다. 다시 앞 절의 Webpack4가 번들한 코드를 보면 "./src/bold.js" 함수 내부에 CommonJS 모듈 문법으로 된 코드는 바뀌지 않았다.

  //...
  "./src/bold.js": (function(module, exports) {
    module.exports = function(name) {
      return '<b>' + name + '</b>';
    };
    module.exports.boldTagNmae = 'b';
  }),
  //...

반면에 ES 모듈로 정의된 italic.js 함수는 두 번째 인자가 __webpack_exports__로 바뀌었다. 그리고 그 객체를 사용하는 코드인 __webpack_exports__["default"] = (function(name) { /* ... */ });는 CommonJS 코드와 비슷한 형상을 하는 것을 확인할 수 있다.

  //...
  "./src/italic.js": (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    __webpack_require__.r(__webpack_exports__);
    /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "italicTagName", function() { return italicTagName; });
    const italicTagName = 'i';
    /* harmony default export */ __webpack_exports__["default"] = (function(name) {
      return '<i>' + name + '</i>';
    });
  }),
  //...

Webpack2 부터는 ES 모듈을 지원하여 ES 모듈도 번들링 할 수 있도록 바뀌었다. 위의 코드들을 보고 짐작해 봤을 때 ES 모듈을 지원하는 과정에서 Webpack은 이미 널리 사용되고 있던 Babel이 생성한 코드들도 사용해야 했으므로 Babel의 변환 방식을 차용했다고 생각이 된다.

지금까지 Webpack이 어떻게 두 모듈을 지원할 수 있도록 처리하는지 알아보았다. 그렇다면 더 자세한 과정을 알기 위해 우리가 트랜스파일러가 되었다고 가정하고, 두 가지 모듈을 함께 사용할 수 있도록 ES6 코드를 직접 바꿔보면서 과정을 천천히 재현해보자.

ES 모듈 내보내기를 CommonJS 모듈과 유사한 방식으로 바꾸기

이제부터 우리는 트랜스파일러다. ES6 문법의 코드를 ES5 문법으로 바꾸면서, 두 모듈 방식을 하나로 통일시켜서 두 모듈을 지원하는 코드로 바꿔볼 것이다. 지금부터 설명하는 예제 코드는 실제 Webpack이 처리한 코드와는 다르다. 두 모듈방식을 공통으로 처리할 수 있는 원리를 설명하기 위해 비교적 간단하게 코드를 바꿔서 설명할 것이다.

이름 붙인 내보내기

먼저 이름 붙인 내보내기를 바꿔보자.

ES6 문법 / ES 모듈

export const add = function() {};

다른 모듈에서 이 모듈이 ES 모듈임을 판별할 수 있게 module.exports 객체에 __esModule 프로퍼티를 추가하자. 그리고 module.export 객체에 add 프로퍼티로 내보낼 함수를 추가하면 된다.

ES5 문법 / CommonJS 모듈

var add = function() { /* ... */ };

Object.defineProperty(module.exports, '__esModule', true);
module.exports.add = add;

이름 붙인 내보내기를 CommonJS 모듈 형태로 바꿔보았다. 그럼 기본값 내보내기도 바꿔보자.

기본값 내보내기

CommonJS 모듈은 module.exports 객체가 내보내지는 형태인데, 이름 붙인 내보내기에서 이미 프로퍼티에 추가해서 내보내는 방법을 사용했다. 그럼 기본값 내보내기는 어떻게 표현할 수 있을까?

ES6 문법 / ES 모듈

const add = function() { /* ... */ };

export default add;

기본값 내보내기 한 모듈은 module.exports.default라는 프로퍼티에 해당 객체를 할당한다는 규칙을 만들자. 그리고 앞서 했던 것과 동일하게 __esModule 프로퍼티를 설정한다.

ES5 문법 / CommonJS 모듈

var add = function() { /* ... */ };

Object.defineProperty(module.exports, '__esModule', true);
module.exports.default = add;

이렇게 ES 모듈이 CommonJS 모듈로 바꾸는 경우, default 프로퍼티는 기본값 내보내기를 위해 예약된 프로퍼티임을 알아두자. 자 그럼 여기까지 모듈에서 객체를 내보내는 코드를 바꿔보았다. 내보낸 코드를 가져오는 부분도 바꿔보자.

ES 모듈의 기본값 가져오기 코드를 CommonJS 모듈 방식으로 바꾸기

모듈을 가져오는 import문을 바꿔볼 것이다. 다음 코드는 위에서 기본값 내보내기로 정의한 add 모듈을 가져오는 코드이다.

ES6 문법 / ES 모듈

import add from './add';

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

우선 import 문을 require()현재는 Webpack이 생성한 코드가 아니므로 간결한 코드를 위해 require를 사용할 것이다.를 사용하는 코드로 바꾸고, add라는 모듈로 기본값 가져오기를 하고 있으므로 가져온 모듈의 default 프로퍼티를 사용하는 코드로 바꿔보자.

ES5 문법 / CommonJS 모듈

var add = require('./add');
var add_default = add.default;

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

ES 모듈끼리는 서로 default 프로퍼티에 기본값 내보내기 객체가 들어있는 것이 약속되어 있으므로 잘 동작할 것이다. 그렇다면 이 방법을 그대로 사용할 때, 기본값 가져오기를 통해서 CommonJS 모듈을 가져온다면 어떻게 될까?

CommonJS 모듈을 기본값 가져오기 코드로 가져올 때

우리가 기본값 가져오기로 어떤 모듈을 가져올 때를 생각해보자. ES 모듈로 정의된 경우 위처럼 default 프로퍼티에 접근하면 되지만, CommonJS 모듈로 정의된 경우는 default 프로퍼티에 접근하면 module.exportsdefault 프로퍼티에 접근하게 될 것이다. 실제로 키값이 default인 프로퍼티가 있다면 엉뚱한 객체에 접근하게 될 것이고, 해당 키값에 아무것도 정의하지 않은 경우 undefined를 받을 것이다. 만약 가져온 것이 함수나 객체라면 예상하지 않은 것을 실행하거나 접근하게 되어 에러가 발생하기에 십상이다. 그렇다면 ES 모듈에서 CommonJS 모듈을 기본값 가져오기로 가져오더라도 잘 동작하도록 바꿔보자.

ES6 문법 / CommonJS 모듈

const add = function() { /* ... */ };

module.exports = add;

앞서 ES 모듈에 추가해 두었던 __esModule 프로퍼티가 기억나는가? 그 프로퍼티를 이용해서 모듈을 기본값 가져오기 할 때 ES 모듈이 아니라면 새 객체를 생성해서 default 프로퍼티로 가져온 모듈을 넣어주는 함수를 만들어보자.

ES5 문법 / CommonJS 모듈

function esModuleInterop(module) {
  return module.__esModule ? module : {default: module};
}

var add = require('./add');
var _add = esModuleInterop(add);

console.log(_add.default(1, 2));

우선 require() 함수를 사용해서 한번 가져오고, 가져온 객체를 esModuleInterop() 함수를 통해서 같은 API로 사용할 수 있게 바꾼다. 예제의 상황에서는 객체가 ES 모듈로 정의되지 않았으므로 default 프로퍼티에 해당 객체가 들어있는 새로운 객체가 준비될 것이다. 마지막으로 가져온 객체를 사용하는 코드에서 default 프로퍼티에 접근해서 사용하도록 바꿔주면 같은 API로 기본값 가져오기를 사용할 수 있게 된다.

자, 이렇게 기본값 가져오기시 두 모듈 방식을 섞어서 사용할 수 있는 환경을 스텝별로 재현해봤다. 자바스크립트 환경에서는 번들러를 바로 사용하면 되지만, 타입스크립트는 컴파일러가 타입스크립트를 자바스크립트로 변환시켜준다. 그렇다면 타입스크립트는 ES 모듈 코드를 어떻게 처리할까?

타입스크립트가 default import를 처리하는 방법

궁금증의 기원으로 돌아가서, 다시 처음에 얘기한 Image Editor 이슈로 돌아가보자. 이제 타입스크립트가 두 모듈을 함께 사용할 수 있도록 컴파일하는 과정을 살펴볼 것이다. 우선, 타입스크립트 프로젝트에서 컴파일러의 esModuleInterop 옵션을 켠다. 그리고 이미지 에디터를 기본값 가져오기 한 타입스크립트 코드를 컴파일해 보면, 앞서 우리가 만들어본 내보내기 코드와 비슷한 코드의 자바스크립트 함수가 추가된다. 다음은 Image Editor의 실제 타입 정의 파일과 글 초반에 등장했던 타입 정의 테스트 코드다.

index.d.ts - 타입 정의 파일의 tui-image-editor 모듈의 타입을 정의한 부분. CommonJS 타입으로 정의되어 있다.

//...
declare module 'tui-image-editor' {
  export = tui.ImageEditor;
}

importTest.ts - 타입스크립트 코드

import ImageEditor from 'tui-image-editor';

const imageEditor = new ImageEditor('#container'/* ... */);
// ...

importTest.js - 컴파일 된 자바스크립트 코드

"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
exports.__esModule = true;
var tui_image_editor_1 = __importDefault(require("tui-image-editor"));

var imageEditor = new tui_image_editor_1["default"]('#container'/* ... */);
// ...

컴파일된 코드에서 이제는 제법 익숙한 형태의 코드들이 보인다. __importDefault() 라는 함수가 추가되고 tui_image_editor_1을 그대로 사용하지 않고 ["default"]로 접근하여 인스턴스를 생성하는 모습이 보인다. 타입스크립트 컴파일러는 tui-image-editor 모듈의 타입 정의 부분을 보고 __importDefault() 함수를 추가했다. 만약 해당 객체의 타입이 CommonJS가 아니라면, 컴파일러는 해당 함수를 추가하지 않고 require()로 가져온 객체를 그대로 사용한다.

index.d.ts - 모듈의 타입이 기본값 내보내기로 정의 되었을 경우

//...
declare module 'tui-image-editor' {
  export default tui.ImageEditor;
}

importTest2.ts - 타입스크립트 코드

import ImageEditor from 'tui-image-editor';

const imageEditor = new ImageEditor('#container'/* ... */);
// ...

importTest2.js - 컴파일 된 자바스크립트 코드

"use strict";
exports.__esModule = true;
var tui_image_editor_1 = require("tui-image-editor");

var imageEditor = new tui_image_editor_1["default"]('#container'/* ... */);
// ...

타입스크립트는 가져올 모듈의 내보내기 타입을 보고 기본값 가져오기 코드를 바꿔준다. 아무리 자바스크립트 파일이 ES 모듈로 작성되어 있어도 모듈의 타입을 CommonJS 모듈로 정의했다면 모듈을 가져와서 default 프로퍼티에 넣어서 새 객체로 감싸도록 코드를 추가할 것이다. 이렇게 내보낸 모듈의 타입이 실제와 다르게 정의되어있다면, 모듈에서 객체를 가져오는 코드가 정상적으로 연결되지 않을 것이다. 게다가 타입스크립트 설정 파일에서 compilerOptions.esModuleInterop 옵션의 기본값은 false다. 따라서 내보내기 타입이 잘못 정의된 모듈을 기본값 가져오기로 사용하려 한다면 undefined를 받아오거나 에러를 만날 가능성이 높다. 그러므로 자바스크립트로 개발된 프로젝트에서 타입 정의 파일에 별도로 타입을 정의하는 경우 주의해야 한다.

결론

이 글을 읽은 여러분은 이제 개발 도중 타 모듈에서 가져온 객체가 undefined 거나 {default: /* ... */}와 같은 형태의 객체가 되어 에러가 발생할 때, 당황하지 않고 사용 중인 번들러나 컴파일러의 옵션을 확인할 수 있게 되었다. 프런트 엔드 개발을 하다 보면 이미 CommonJS로 개발된 모듈을 가져다 사용해야 할 때가 있을 것이다. 그때마다 모듈이 CommonJS 인지 ES 모듈인지 확인하고 가져오는 코드를 구분해야 한다면, 지금보다 매우 번거로웠으리라 예상한다. 그리고 항상 깔끔하고 정리된 상태인 개발 코드만 보다가 실제로 번들 된 코드를 열어보니, 간단한 import 문의 이면에 이런 많은 처리가 숨어있어 놀라기도 했다. ES 모듈을 CommonJS 방식으로 바꿔주는 Babel과 번들링도 해주고 새로운 모듈 방식을 사용하는 Webpack의 지원이 새삼 고맙게 느껴졌다. 이 글을 읽고 알고 있는 사실과 다른 부분이 있거나 궁금한 점이 있다면 FE개발랩 E-Mail로 자유롭게 연락해주길 바란다. 그럼 모두 해피 자바스크립팅!

reference