JavaScript | Modules

Updated:
9 minute read

ES6 이전의 JavaScript에선 자체적인 모듈 시스템이 존재하지 않았습니다. 때문에 C의 #include나 Java의 import처럼 코드를 모듈화하여 보내거나 받는 것이 순수 JavaScript에선 불가능했습니다.

이에 대한 대안으로 사람들은 직접 JavaScript를 위한 모듈 로더 라이브러리를 만들었으며 대표적인 예로 commonJSAMD(Asynchronous Module Definition)가 있습니다.

JavaScript 표준을 위한 움직임: CommonJS와 AMD

그러나 ES6에 들어서면서 JavaScript modules라는 이름으로 JavaScript에서 직접 모듈 시스템을 지원하기 시작했습니다.

이번 글에서는 ES6부터 표준으로 제공되는 모듈 시스템을 알아보겠습니다.

들어가기 전에

JavaScript modules는 JS modules, ES modules, ECMAScript modules 등의 이름으로 부르며 이하 본 글에서는 ESM이란 명칭을 사용하겠습니다.

ESM은 exportimport 두 키워드를 사용합니다.

export는 다른 JS 파일로 내보내는 기능을, import는 내보낸 것을 받아오는 기능을 수행한다는 것은 다들 예측할 수 있을 것입니다.

그런 의미를 담아 아래의 모든 예에서 모듈이 되는 파일은 import.js로, 모듈에서 받아와 사용하는 파일은 export.js로 이름을 붙여 사용하겠습니다.

사실 모듈로 사용할 JS 파일의 확장자를 .js로 하는 것은 논란의 소지가 있으며 확장자로 .mjs를 사용하는 편이 더 권장되긴 합니다.

그 이유는 아래와 같습니다.

You may have noticed we’re using the .mjs file extension for modules. On the Web, the file extension doesn’t really matter, as long as the file is served with the JavaScript MIME type text/javascript. The browser knows it’s a module because of the type attribute on the script element.

Still, we recommend using the .mjs extension for modules, for two reasons:

  1. During development, the .mjs extension makes it crystal clear to you and anyone else looking at your project that the file is a module as opposed to a classic script. (It’s not always possible to tell just by looking at the code.) As mentioned before, modules are treated differently than classic scripts, so the difference is hugely important!
  2. It ensures that your file is parsed as a module by runtimes such as Node.js and d8, and build tools such as Babel. While these environments and tools each have proprietary ways via configuration to interpret files with other extensions as modules, the .mjs extension is the cross-compatible way to ensure that files are treated as modules.

JavaScript modules · V8

그리고 연습을 위해 예시 코드를 직접 작성하고 실행하는 것이 생각보다 까다로운 일이었습니다.

ESM을 사용하기 위해선 둘 이상의 JS 파일이 필요했기에 코드를 콘솔에서 바로 시험해 볼 수 없었기 때문입니다.

결국 JavaScript runtime environment인 Node.js나 브라우저를 통해 작성된 스크립트를 실행하는 방식으로 예시 코드를 만들 수 밖에 없었습니다.

그런데 Node.js는 commonJS 기반이라 12.16.x 버전 기준으로 export, import를 인식하지 못한다는 문제가 있습니다.

이 문제는 --experimental-modules 옵션을 추가해서 node의 실험적 기능을 활성화하여 실행하는 것으로 해결할 수 있습니다.

12.17.0 버전부터는 --experimental-modules 옵션 없이도 ESM을 사용할 수 있습니다. 하지만 단지 --experimental-modules 플래그 없이도 모듈을 사용할 수 있을 뿐이며 Node.js에서의 ESM 구현이 실험적 기능으로 남아있는 것은 마찬가지입니다.

$ tree .
.
├── export.mjs
└── import.mjs

## node 12.16.x
$ node --experimental-modules import.mjs 

## node 12.17.x
$ node import.mjs

ESM을 사용하는 JS 파일의 확장자는 .mjs로 해줘야 합니다. 단, package.json 파일에 다음 옵션을 추가한다면 .js, .mjs 둘 다 모듈 파일로 인식합니다.

{
  "type": "module"
}

또다른 방법인 브라우저에서 스크립트를 실행하는 것 또한 녹록치 않습니다.

<!DOCTYPE html>
<html>
  <body>
    <script type="module" src='export.mjs'></script>
    <script type="module" src='import.mjs'></script>
  </body>
</html>

HTML 파일에서 모듈을 가져오려면 <script> 태그의 type attribute를 "module"로 지정해줘야 합니다. (그리고 이때 <script>defer attribute는 자동으로 설정됩니다.)

위와 같은 식으로 HTML 파일을 만들고 브라우저에서 파일 경로(file://home/.../index.html)를 로드해 그 파일을 로컬에서 실행하면 JavaScript 보안 요구 사항으로 인해 CORS 오류가 발생해 버립니다.

CORS 오류가 발생하는 문제는 HTML을 띄워주는 서버를 구축하여 벗어날 수 있으며, 저는 아예 React 프로젝트를 따로 만들고 컴포넌트 파일끼리 모듈을 주고 받는 식으로 연습했습니다.

서론이 길었는데 그럼 본격적으로 exportimport를 사용하여 어떻게 모듈화를 하는지 알아보겠습니다.

Named Exports

우선 기본적인 export 방식을 알아보겠습니다.

// export.js
export let num = 10;
export var add = (a, b) => a + b;

export class Person {
  constructor(name) {
    this.name = name;
  }
}

export 뒤에 모듈 밖으로 내보낼 것들을 기입하는 이 방법은 named exports라고 합니다.

exportvar, let, const, function, class 를 내보낼 수 있으며 exportimport는 모두 최상위 레벨에서 작성해야 합니다. 함수 내부나 반복문 등과 같이 어느 블록에 들어가지 않게끔 말이죠.

이렇게 내보내진 모듈은 자동으로 strict mode가 됩니다.

또한 각각의 선언문마다 export를 붙이지 않는 대신 중괄호로 묶어 객체로 한번에 내보낼 수도 있습니다.

// export.js
let num = ...
var add = ...
class Person {...}
export { num, add, Person };
// import.js
import { num, add, Person } from './export';

console.log(num);         // 10
console.log(add(1, 2));   // 3
console.log(new Person("Park"));    // Person {name: "Park"}

export한 모듈은 객체로 전달됩니다. 그래서 import를 할 때 object destructuring을 하여 받아오면 됩니다.

import statement는 import { features } from 'url'의 형태로 작성하며 가져올 기능들은 export에서의 이름과 동일한 이름으로 가져와야 합니다.

위와 같이 성공적으로 import가 되었다면 가져온 이름으로 모듈의 기능들을 사용할 수 있습니다.

Default Exports

named exports를 사용하면 한 모듈에서 여러 항목들을 내보낼 수 있었습니다.

반면에 default exports는 한 모듈에서 단 하나의 값 혹은 기능만을 내보내는 또다른 export 방식입니다.

// export.js
let num = 10;
export default num;

export 뒤에 default 키워드를 붙여서 default exports를 사용합니다.

단, export default 뒤엔 expression, function, class 만이 올 수 있으며 var, let, const와 같은 키워드들을 사용하면 parsing error가 발생합니다.

// import.js
import ten from './export';
console.log(ten);     // 10

default exports는 어차피 단 하나만을 내보내기 때문에 export할 때 사용한 이름이 그대로 import에서 일치할 필요가 없습니다. import 뒤에 오는 identifier가 곧 모듈에서 가져온 기능을 import.js에서 부르는 이름이 됩니다.

Renaming Imports and Exports

named exports로 기능들을 주고받을 때 as 키워드를 사용하여 지정된 이름을 변경할 수 있습니다.

renaming으로 인해 길고 불편한 이름을 짧고 간단하게 사용할 수 있으며 서로 다른 모듈의 동일한 identifier가 충돌하는 것을 방지할 수 있습니다.

이름 변경은 export / import 어느 때이든 가능합니다.

// export.js
let num1 = 10;
let num2 = 20;
let num3 = 30;
export { num1 as ten, num2 as twenty, num3 as thirty };

// import.js
import { ten, twenty, thirty } from './export';
// export.js
let num1 = 10;
let num2 = 20;
let num3 = 30;
export { num1, num2, num3 };

// import.js
import { num1 as ten,
         num2 as twenty,
         num3 as thirty } from './export';

as 키워드를 사용하여 모듈 전체를 하나의 객체로 import 할 수도 있습니다.

// import.js
import * as numbers from './export';

console.log(numbers.num1);    // 10
console.log(numbers.num2);    // 20
console.log(numbers.num2);    // 30

default export도 엄밀히 말하자면 default란 이름으로 내보내지는 모듈의 이름을 자동적으로 변경하여 쓰는 것과 같습니다. 아래의 예처럼 말입니다.

// export.js
export default () => {
  console.log(10);
}
// import.js
// [1]
import printTen from './export';

// [2]
import { default as printTen } from './export';

// [1]과 [2]는 같습니다.

Aggregating Modules

여러 모듈들을 종합하여 하나의 모듈로 합치는 방법도 있습니다.

기존의 import 하는 방식이

import { exportedFeature } from './export';

였다면 하나의 모듈로 합치는 방법은

export { exportedFeature } from './export';     // import -> export

입니다.

이는 하위 모듈을 상위 모듈 파일에 import 한 뒤 바로 export 하는 것과 같습니다.

// sub1.js
export let num = 10;

// sub2.js
export let num = 20;

// sub3.js
export let num = 30;
// export.js
export { num as ten } from './sub1';
export { num as twenty } from './sub2';
export { num as thirty } from './sub3';
// import.js
import { ten, twenty, thirty } from './export';

console.log(ten, twenty, thirty);   // 10 20 30

덧붙여 *로 하위 모듈의 이름을 그대로 전달하는 방법도 있으며 default export는 rename을 하여 전달합니다.

// sub1.js
export let ten = 10;

// sub2.js
export let twenty = 20;

// sub3.js
export defualt 30;
// export.js
export * from './sub1';
export * from './sub2';
export { default as thirty } from './sub3';

Dynamic Module Loading

ESM은 동적으로 모듈을 가져오는 기능도 지원합니다.

동적 로딩은 import()을 통해 이뤄집니다.

괄호 안에 모듈을 집어넣으면 import()는 요청한 모듈을 갖는 promise 객체를 반환합니다.

// export.mjs
let num1 = 10;
let num2 = 20;
let num3 = 30;
export { num1, num2, num3 };
// import.mjs
import('./export.mjs')
  .then(console.log);     // { num1: 10, num2: 20, num3: 30 }

이렇게 보면 import()는 함수라고 오해할 수 있겠지만 사실 import()함수나 객체가 아닙니다. 그저 괄호 안에 있는 모듈을 포함한 프로미스 객체를 제공하는 syntax일 뿐입니다.

Function.prototype을 상속받지 않아 call 이나 apply를 사용할 수도 없으며 const importAlias = import와 같은 형태로 다른 곳에 할당할 수도 없습니다.

한편, import()는 promise 객체를 반환하므로 당연히 async/await 와도 함께 쓸 수 있습니다.

// import.mjs
(async () => {
  const numbers = await import('./export.mjs');
  console.log(numbers);     // { num1: 10, num2: 20, num3: 30 }
})();

References

JavaScript modules - JavaScript | MDN

import - JavaScript | MDN

export - JavaScript | MDN

Module | PoiemaWeb

JavaScript modules · V8

Dynamic import() · V8

Node v12.17.0 (LTS) | Node.js

Back to top ↑

Leave a comment