[JavaScript] ESM(ECMAScript Modules) 이란? 자바스크립트 공식 모듈 시스템 공부하기
Module?
자바스크립트에서 Module은 프로그램을 기능별로 나눈 독립적인 코드 단위를 의미한다.
자바스크립트가 발전함에 따라, 스크립트의 규모가 커지기 시작했고 이후 복잡한 프로그램을 작은 단위로 나누어 관리하기 위해 모듈이 도입되었다.
현재 자바스크립트에선 ESM(ECMAScript Modules)와 CommonJS라는 두 가지 모듈 시스템이 사용 중이다.
ESM은 ES6(2015) 이후의 표준 모듈 시스템으로 최신 브라우저와 Node.js 에서 지원되고,
CommonJS는 Node.js에서 많이 사용되는 모듈 시스템이다.
Module 장점
Module은 기능별로 분리되어 있기 때문에 코드의 가독성와 유지 보수성이 기존 코드보다 높다.
또한 작성한 코드를 여러 곳에서 재사용 할 수 있어 코드 중복이 적고, 필요한 모듈만 가져와 사용할 수 있기 때문에 의존 관계가 명확화여 개발 생산성이 높다.
- 재사용성 : 여러 곳에서 동일한 코드 사용 가능
- 캡슐화 : 코드 일부를 외부에 노출하지 않고 숨길 수 있음
- 유지보수성 : 기능별로 분리되어 있어 수정 및 관리 용이
현재 자바스크립트에서 사용하는 모듈 시스템 ESM(ECMAScript Modules) 와 CommonJS 을 설명해보겠다.
1. ESM ( ECMAScript Modules )
1- 1) 개념
ESM은 ECMAScript Modules의 약자로, 자바스크립트의 공식 모듈 시스템을 의미한다.
ES6(2015)부터 표준으로 도입되었으며, 현재는 최신 브라우저와 Node.js 모두 기본적으로 모듈 기능을 지원하고 있다.
컴파일 시 분석하는 정적 로딩 방식으로 동작하며, 트리 세킹 등을 지원해 최적화 가능성이 높다.
최신브라우저와 Node.js을 지원하는 것은 맞으나 Node.js에서 혼용은 불가능하다.(??)
1- 2) 문법
모듈은 export 문을 통해 함수, 객체 원시 값을 내보내고 import 문을 통해 가져와야 사용할 수 있다.
모듈은 "use strict"의 존재 유무와 상관 없이 무조건 엄격 모드이며, export문과 import문은 HTML 안에 작성한 스크립트에서는 사용할 수 없다.
브라우저에서 export한 모듈 파일은 <script type="module"> 태그를 사용해 불러오는데, 이때 import 할 파일보다 상위에 작성 혹은 먼저 렌더링 되어야한다.
<script type="module" src="/test.js"></script>
Node.js 에서는 pakage.json에 "type": "module"을 설정하거나 .mjs 확장자를 사용해 불러온다.
1- 3) Export
Export문 기본형
// 하나씩 내보내기
export let name1, name2, …, nameN; // var, const도 동일
export let name1 = …, name2 = …, …, nameN; // var, const도 동일
export function functionName(){...}
export class ClassName {...}
// 목록으로 내보내기
export { name1, name2, …, nameN };
// 내보내면서 이름 바꾸기
export { variable1 as name1, variable2 as name2, …, nameN };
// 비구조화로 내보내기
export const { name1, name2: bar } = o;
// 기본 내보내기
export default expression;
export default function (…) { … } // also class, function*
export default function name1(…) { … } // also class, function*
export { name1 as default, … };
// 모듈 조합
export * from …; // does not set the default export
export * as name1 from …;
export { name1, name2, …, nameN } from …;
export { import1 as name1, import2 as name2, …, nameN } from …;
export { default } from …;
Named Export / Default Export
export문은 하나의 모듈에서 여러개를 내보낼 수 있는 Named Export와 한 개의 항목만 내보낼 수 있는 Default Export 라는 두 가지 방식이 있다.
1) Named Export
named export로 내보내진 모듈은 import 할 때 내보낸 이름과 동일한 이름을 사용해야한다.
// 여러 값을 묶어서 내보낼 경우 export { name, greet }; const name = 'module'; function greet() { console.log('Hello!'); } // 한번에 한개씩 내보낼 경우 export const name = 'module'; export function greet() { console.log('Hello!'); }
2) Default Export
반면, default export로 내보내진 모듈은 원하는 이름으로 변경하여 import 할 수 있다.// 한번에 한개의 값만 내보낼 수 있다. export { greet as default }; function greet() { console.log('Hello!'); } export default function greet() { console.log('Hello!'); }
1- 4) Import
import문 기본형
// 기본형
import 가져올모듈이름 form 모듈 경로
import {가져올모듈이름} form 모듈 경로
// 1) default export로 내보낸 모듈 가져오기
import defaultExport from "/module";
// 2) 모듈 전체를 가져와 name에 바인딩하기
import * as name from "/module";
// 3) named export로 내보낸 모듈 가져오기
import { export1 } from "/module";
// 4) 모듈에서 여러 멤버를 가져오기.
import { export1 , export2 } from "/module";
// 5) 가져온 멤버(export)에 원하는 새로운 이름(alias) 짓기.
import { export1 as alias1 } from "/module";
import { export1 , export2 as alias2 , [...] } from "/module";
// 6) 기본값으로 가져올 부분을 먼저 선언 후 원하는 부분 선언
import defaultExport, * as name from "/module";
import defaultExport, { export1 [ , [...] ] } from "/module";
import defaultExport, { foo , bar } from "/module";
// 7) 어떠한 바인딩 없이 모듈 전체의 사이드 이펙트만 가져오기
import "module-name";
// 8) 변수에 담아 import
var promise = import("/module");
import 문은 다른 모듈에서 export 된 데이터를 가져올 때 사용한다.
위의 예시와 같이 export된 멤버를 원하는 방식으로 import 하여 사용할 수 있다.
1) 과 같은 형태는 가장 기본적으로 import 하는 방법이며, 모듈에서 default export로 내보냈을 경우 사용한다.
오직 하나의 기본 내보내기만 가져올 수 있다.
// 모듈.js 에서 내보낼 때 export default function sayHello() { console.log("Hello!"); } // 사용할.js 에서 가져올 때 import sayHello from "./module.js"; // 사용 sayHello(); // 출력 : "Hello!"
2) 와 같은 형태에서는 named export된 모듈을 name 이라는 하나의 객체로 바인딩한 것이다.
때문에 .속성의 형태로 접근이 가능하다. 아래에선 utils 라는 이름에 모든 모듈을 바인딩 하였기 때문에 utils.greeting, utilsadd() 로 접근하였다.
// 모듈.js 에서 내보낼 때 export const greeting = "Hi"; export function add(a, b) { return a + b; } // 사용할.js 에서 가져올 때 import * as utils from "./module.js"; // 사용 console.log(utils.greeting); // "Hi" console.log(utils.add(2, 3)); // 5
3) 과 같은 형태는 특정 named export만 선택적으로 가져올 때 사용한다.
모듈에 greeting과 version이 있어도 greeting만 가져와 사용할 수 있다.
// 모듈.js 에서 내보낼 때 export const greeting = "Hello, World!"; export const version = "1.0.0"; // 사용할.js 에서 가져올 때 import { greeting } from "./module.js"; // 사용 console.log(greeting); // "Hello, World!"
4)와 같은 형태는 여러개의 named export를 한번에 가져올 때 사용한다.
// 모듈.js 에서 내보낼 때 export const name = "Alice"; export function greet() { console.log("Hi!"); } // 사용할.js 에서 가져올 때 import { name, greet } from "./module.js"; // 사용 console.log(name); // "Alice" greet(); // "Hi!"
5) as 키워드를 통해 가져온 멤버에 새로운 이름을 지어 사용할 수 있다.// 모듈.js 에서 내보낼 때 export const fullName = "John Doe"; export function sayHi() { console.log("Hi there!"); } // 사용할.js 에서 가져올 때 import { fullName as userName, sayHi as greet } from "./module.js"; // 사용 console.log(userName); // "John Doe" greet(); // "Hi there!"
6) default export된 sayHello라는 모듈을 가져오고, named export로 내보낸 greeting, add는 utils에 바인딩한다.// 모듈.js 에서 내보낼 때 export default function sayHello() { console.log("Hello!"); } export const greeting = "Hi!"; export function add(a, b) { return a + b; } // 사용할.js 에서 가져올 때 import sayHello, * as utils from "./module.js"; // 사용 sayHello(); // "Hello!" console.log(utils.greeting); // "Hi!" console.log(utils.add(2, 3)); // 5
7) default export된 코드와 named export된 코드 중 필요한 모듈만 가져와 사용한다.
// 모듈.js 에서 내보낼 때 export default function sayHello() { console.log("Hello!"); } export const foo = "Foo value"; export const bar = "Bar value"; export const baz = "Baz value"; // 사용할.js 에서 가져올 때 import sayHello, { foo, bar } from "./module.js"; // 사용 sayHello(); // "Hello!" console.log(foo); // "Foo value" console.log(bar); // "Bar value"
8) 은 모듈을 가져오지만 아무것도 바인딩하지 않는다. 단순 모듈의 실행코드가 필요할 때 사용한다.// 모듈.js console.log("Module loaded!"); // 사용할.js 에서 가져올 때 import "./module.js"; // "Module loaded!"가 자동으로 출력됨
9) 은 아래에서 설명할 동적 임포트(dynamic import)로 모듈을 가져오는 방법이다.
import()는 Promise를 반환하여, then() 또는 async / await를 사용하여 모듈을 사용할 수 있다.// 모듈.js 에서 내보낼 때 export function greet() { console.log("Hello, dynamically imported module!"); } // 사용할.js 에서 가져올 때 import("./module.js").then((module) => { module.greet(); // "Hello, dynamically imported module!" }); // async / await 를 사용하면 async function loadModule() { const module = await import("./module.js"); module.greet(); } loadModule();
Dynamic Import (동적 임포트)
import()는 모듈을 비동기적으로 가져오는 import의 함수형이다.
즉, import 키워드는 정적 임포트(컴파일 시점에 미리 로딩)되는 반면 import() 함수는 동적 인포트(런타임에서 필요할 때만 로딩)되는 방식이다.
// 정적 임포트 (import 키워드) -> 모듈을 항상 로딩 import { greet } from "./module.js"; greet(); // 동적 임포트 (import() 함수) -> 필요할 때만 로딩 async function loadModule() { const module = await import("./module.js"); module.greet(); } loadModule();
import()는 비동기로 Promise 반환하기 때문에, await를 사용할 수 있으며, 이와 같이 사용할 경우 초기 로딩을 줄이고 사용자의 동작에 필요한 모듈만 가져올 수 있다는 장점이 있다.
또한 import()는 <script type="module">없이도 동작한다. 정적 import는 반드시 <script type="module"> 를 사용해야 했지만 import()는 함수이므로 <script> 내에서 바로 사용할 수 있다.
import('./module.js').then((module) => { module.greet(); });
nomodule 속성
nomodule 속성이 붙은 <script>는 ESM(모듈)을 지원하지 않는 브라우저에서만 실행된다.
즉,최신 브라우저는 modern.js(ESM)를 실행하고, legacy.js(구버전)를 무시한다.
구형 브라우저(IE 같은 ESM 미지원 브라우저)는 legacy.js(CommonJS 또는 전통적인 JS)를 실행한다.
<script type="module" src="modern.js"></script> <script nomodule src="legacy.js"></script>
예제,<!-- ESM을 지원하는 브라우저에서 실행됨 --> <script type="module"> import { greet } from "./modern.js"; greet(); </script> <!-- ESM을 지원하지 않는 브라우저에서 실행됨 --> <script nomodule> function greet() { console.log("Hello from legacy.js"); } greet(); </script>
즉, nomodule을 사용하면 최신 브라우저는 ESM을 실행하고, 구형 브라우저는 기존 코드(ES5)를 실행하여 하위 호환성을 유지할 수 있다.
1-5) Tree Shaking
사용되지 않는 코드를 제거하는 최적화 기법이다.
ESM의 정적 구조 덕분에 빌드 도구(Webpack, Rollup 등)가 이 기능을 제공할 수 있다.
2. CommonJS
CommonJS는 Node.js에서 전통적으로 사용하던 모듈 시스템으로 주로 Node.js에서 사용한다.
런타임에 분석하는 동적 로딩 방식으로 동작하며, 최적화 가능성이 낮다.
CommonJS내에서만 동작하기 때문에 혼용이 불가능..?