컨텐츠를 불러오는 중...
type="module"
이 미치는 영향에 대해서 이야기하기전에 CommonJS에 대해서 간단하게 어떠한 특징이 있는지에 대해서 이야기하고 넘어가겠습니다. CommonJS는 비록 오늘 날의 트렌드와 어울리지는 않지만 많은 legacy 코드가 이미 많이 작성되어 있기 때문에 간단히 어떠한 특징을 갖고 있는지에 대해서 이야기 하겠습니다.(function (exports, require, module, __filename, __dirname) {
// 내부 모듈 코드는 실제로 여기에 들어갑니다.
});
module wrapper
라는 함수를 사용하고 있습니다. 위의 작성한 코드처럼 require 한 모듈들을 개별 함수 클로저에 의해 래핑되어서 실행된다는 특징을 갖고 있습니다. 이러한 점이 문제가되는 예시는 다음과 같습니다.// test.js
module.exports = {
[globalThis.hello]: 'world',
};
// index.js
const hello = 'hello';
globalThis[hello] = hello;
const test = require('./test.js');
console.log(test[hello]);
console.log
는 어떤 값이 출력이 될까요? world
가 출력 됩니다. CommonJS는 동적으로 실행이 되고 같은 개별 클로저에서 실행이 되기 떄문입니다.// message.js
const greeting = 'Welcome!';
module.exports = { content: greeting };
// main.js
const msg = require('./message');
console.log(msg.content); // 출력: Welcome!
module.exports
에 할당되는 변수, 값들은 하나의 객체로써 다루어집니다. 앞서 설명한 방법들이 CommonJS에서 모듈을 다루는 방법들이었습니다.module wrapper
로 인해 불러온 모듈에 대한 클로저가 매번 생성 참조된다는 것은 성능상의 문제를 만들었습니다.// CommonJS
const fs = require("fs);
module.export = {...};
// ES Module
import fs from "fs";
export { ... };
// module1.cjs
console.log('모듈1 로드');
setTimeout(() => console.log('모듈1 시작'), 2000);
console.log('모듈1 종료');
console.log('시작');
const module1 = require('./module1.js');
console.log('모듈들 실행 중');
const module2 = require('./module2.js');
console.log('모듈들 모두 실행 완료');
시작
모듈1 로드
모듈1 종료
모듈들 실행 중
모듈2 로드
모듈2 종료
모듈들 모두 실행 완료
모듈1 시작
모듈2 시작
// esmodule1.js
console.log('모듈1 로드');
setTimeout(() => console.log('모듈1 시작'), 2000);
console.log('모듈1 종료');
console.log('시작');
import './esmodule.js';
console.log('Hello World');
import './esmodule2.js';
모듈1 로드
모듈1 종료
module2 로드
module2 종료
시작
Hello World
모듈1 시작
module2 시작
require
문을 사용했을 경우에는 작동하고 import
문을 사용했을 경우에는 작동하지 않을까요? ES Module은 정적 분석을 기본적인 속성으로 갖기 때문입니다.import
와 export
는 반드시 모듈의 최상위 레벨에 작성되어야 하며, 동적인 조건문이나 함수 호출 내에서 사용할 수 없습니다.module.exports
의 객체라는 특성 때문에, 빌드 타임에서는 모듈에서 어떠한 값을 불러와 어떻게 사용될 수 있을지를 가늠할 수 없습니다. 동적으로 실행이 되며 객체로 불러온 값이 언제 사용할 것인지 알 수 없기 때문입니다.import
선언과 해당 모듈의 export
선언을 매칭시켜 필요한 부분만 연결(linking) 한다는 것이 가장 중요한 부분입니다. 이러한 것이 가능한 것은 모듈의 일반적인 생명 주기와도 연관이 있습니다.import
및 export
구문을 식별합니다.import
및 export
바인딩을 해석하고 연결합니다.export
된 값을 최종적으로 바인딩 합니다.import
구문을 해당 export
구문과 연결하는 것입니다.Module Environment Record
를 생성하고 초기화 합니다. 이 환경 레코드는 모듈 내의 모든 변수, 함수, 클래스 선언 및 import 된 바인딩을 관리하는 역할을 합니다.[[Status]]
필드는 unlinked
에서 linking
으로, 그리고 성공적으로 완료되면 linked
로 전환됩니다.unlinked
상태는 모듈의 import
구문이 해당 export
구문과 성공적으로 연결되지 못했음을 의미합니다. 이러한 모듈은 모듈 그래프의 일부로 완전히 통합되지 못했기 때문에, 자바스크립트 엔진은 이 모듈이 애플리케이션의 최종 번들에 필요하지 않다고 판단하여 Tree Shaking 의 대상이 되어 최종 번들에서 제외되게 됩니다.module.exports
방식과 명확히 차별화됩니다. 코드를 실행하기 전에 필요한 부분만을 정확히 식별하고 연결하는 이 능력은 번들러가 불필요한 코드를 효과적으로 제거(Tree Shaking)할 수 있도록 하며, 이는 곧 애플리케이션의 번들 크기 감소와 로딩 성능 향상으로 이어집니다. 따라서 ESM의 import
/export
구문은 단순히 모듈을 가져오는 문법을 넘어, 웹 애플리케이션의 효율성을 극대화하는 핵심적인 기반을 제공한다고 할 수 있습니다.export
하고자 합니다. 예를 들어 가장 유명한 API인 JSONPlaceholder에서 todo 데이터를 읽어오고 해당 todolist를 export
하려고 한다고 가정 해봅시다. 물론 Top-Level-Await 가 도입되기 이전의 상황에서 생각을 해보겠습니다.todolist
를 export 하기 위해서는 위와 같이 IIFE
를 활용하여 값을 todolist가 호출되는 즉시 API를 호출하고 값을 업데이트하는 방법이 있을 것입니다. 하지만 이 코드를 import
하여 결과를 출력하면 어떤 값이 출력이 될까요?todolist
의 출력값은 undefined
가 출력이 됩니다. 이러한 이유는 무엇일까요? 순차적으로 자세하게 알아 보겠습니다.import { todolist } from "./todolist"
구문이 가장 먼저 실행이 됩니다. 구문이 실행이 되면 JavaScript 엔진은 파일을 즉시 평가하기 시작합니다. 이 초기 평가 단계에서 todolist
변수는 선언되었지만 어떠한 값도 할당 되지 않기 때문에 이 시점에서는undefined
로 평가가 이루어집니다.export { todolist }
는 현재의 바인딩을 export
합니다. 즉 undefined
를 내보내게 됩니다. 아무리 IIFE
로 비동기 작업을 통해서 API 의 결과 값을 todolist 의 값으로 업데이트하더라도 평가 단계에서 이 값은 반영이 되지 않습니다.Job Queue
에서 스케줄링 처리가 되기 때문에 타이밍 문제가 발생하여 위와 같은 방법을 사용한다면 undefined
가 아닌 todolist
값은 import
할 수 없습니다.Top-Level-Await
을 사용하는 것입니다!export
가 될지 예상할 수 있게끔 코드가 변경 되었습니다. 이제 왜 Top-Level-Await
가 앞선 문제의 해결책이 될 수 있는지에 대해서 이야기 해보겠습니다.await
은 반드시 async
함수 내부에서만 유용하다는 엄격한 규칙이 있었습니다. 하지만 ECMAScript 2022부터는 await
키워드가 Module의 최상위 레벨에서도 파싱될 수 있도록 허용되었습니다. 이는 모듈 코드 자체가 비동기 작업을 직접 기다릴수 있음을 의미합니다.Module Record
를 정의하는데 그 중에서도 Source Text Module Record
는 JavaScript 코드로부터 파싱된 모듈을 나타냅니다.Source Text Module Record
에 Top-Level-await을 위한 내부 슬롯인 [[HasTLA]]
추가가 되었고 Top-Level-await 표현식이 존재할 경우 이 필드 값이 true
로 설정이 됩니다.[[HasTLA]]
가 설정이 된 모듈은 비동기적으로 평가됩니다. 이 후 비동기 평가를 위한 추상 연산이 수행되어 모듈의 코드 실행과 Promise의 resolve/reject를 연동시킵니다. 이로 인해 Top-Level await
이 Promise의 결과를 기다리는 동안 모듈의 실행이 일시 중지될 수 있습니다.import
할 경우 가져오는(importing
) 모듈은 가져오는(imported
) 모듈의 비동기 평가가 완료될때까지 자동으로 기다립니다.await
가 코드 실행을 일시 중지할 때, 이는 메인 스레드를 차단하지 않습니다. 대신, Promise가 해결될 때까지 기다리는 동안 해당 작업의 나머지 부분은 Job Queue로 스케줄링되어 이벤트 루프를 통해 비동기적으로 처리됩니다. 이로써 Top-Level await
은 전체 애플리케이션의 시작을 멈추지 않고 비동기 작업을 수행할 수 있습니다.// my-commonjs-module.cjs (CommonJS)
module.exports = {
valueA: 10,
valueB: 'hello',
myFunction: () => console.log('From CommonJS'),
};
// main.mjs (ES Module)
import { valueA } from './my-commonjs-module.cjs';
console.log(valueA);
// moduleA.mjs (ESM)
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
// main.mjs (ESM)
import { PI, add } from './moduleA.mjs';
console.log(PI);
console.log(add(5, 2));
let todolist;
(async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/todos/1');
const json = await response.json();
todolist = json;
})();
export { todolist };
import { todolist } from './todolist';
console.log(todolist); //undefined
import { todolist } from "./todolist';
setTimeout(() => {
console.log(todolist);
}, 1000);
const response = await fetch('https://jsonplaceholder.typicode.com/todos/1');
const todoList = await response.json();
export { todoList };