ESM: ECMAScript 모듈
ECMAScript 2015 명세의 일부분으로 자바스크립트에 서로 다른 환경(브라우저, node등)에서도 적합한 공식 모듈 시스템을 부여하기 위해 도입되었습니다.
특징으로는 간편한 문법, 순환 종속성에 대한 지원과 비동기적으로 모듈을 로드할 수 있게 되었습니다.
CommonJS와 큰 차이는 ES 모듈은 static 특성입니다. 즉, 임포트가 모든 모듈의 가장 상위 레벨과 제어 흐름 구문의 바깥쪽에 기술됩니다. 아래 코드를 확인해 봅시다.
아래 코드는 EMS에서는 사용할 수 없습니다. 이유는 import문은 가장 상위에 작성해야 되기 때문입니다.
// ❌ An import declaration can only be used at the top level of a module.
if (true) {
import module1 from './module1.js';
} else {
import module2 from './module2.js';
}
반면 CommonJS에서는 다음과 같이 작성 가능합니다.
let module = null;
if (true) {
module = require('./module1');
} else {
module = require('./module2');
}
기본적인 ESM 사용법
ESM 모듈은 가장 기본으로 배우기 때문에 간략하게 넘어가겠습니다.
주의사항은 ESM은 파일 확장자 명을 명시해야 합니다.
// module1.js
export default class {}
export const a = 3;
// 2-6-2.js
import { a } from './module1.js';
import defaultClass from './module1.js';
import * as namespace from './module1.js';
비동기 임포트
import 구문은 정적이기 때문에 두 가지 제약이 존재합니다.
- 모듈 식별자는 실행 중에 생성될 수 없습니다.
- 모듈의 임포트는 모든 파일의 최상위에 선언되며, 제어 구문 내에 포함될 수 없습니다.
ESM은 이런 제약을 해결할 비동기 임포트를 제공합니다. import()를 사용하면 되며, 모듈 식별자를 인자로 취하고 모듈 객체를 프라미스로 반환합니다.
지금부터 간단한 비동기 임포트 예제를 확인해 보겠습니다.
각 나라의 언어를 동적으로 임포트 하는 예제입니다.
// strings-el.js
export const HELLO = 'el-hello';
// strings-en.js
export const HELLO = 'en-hello';
// strings-es.js
export const HELLO = 'es-hello';
// main.js
const SUPPORTED_LANGUAGES = ['el', 'en', 'es']; // (1)
const selectedIndex = 2; // (2)
if (!SUPPORTED_LANGUAGES.includes(SUPPORTED_LANGUAGES[selectedIndex])) {
// (3)
console.error('언어가 지원되지 않습니다....');
process.exit(1);
}
const translationModule = `./strings-${SUPPORTED_LANGUAGES[selectedIndex]}.js`; // (4)
import(translationModule) // (5)
.then((module) => {
// (6)
console.log(module.HELLO);
});
코드를 분석해 보겠습니다.
첫 번째 1,2,3 부분은 간단합니다.
- 지원되는 언어의 리스트 정의
- 선택할 언어의 인덱스를 정의
- 지원되지 않는 언어가 선택된 경우 처리
두 번째 4,5,6 부분은 동적 임포트를 사용합니다.
- 선택된 언어의 js 파일을 .js로 문자열을 만들어줍니다.
- 동적 임포트를 위해 import 연산자를 사용합니다.
- 동적 임포트는 비동기적으로 되며, then 사용하여 내용을 가져오고 hello를 호출합니다.
모듈 적재 이해하기
ESM이 어떻게 동작하고 어떻게 순환 종속성을 다루는지 이해하기 위해, ESM을 사용할 때 코드가 어떻게 파싱 되고 평가되는지 알아보겠습니다.
인터프리터의 목표는 필요한 모든 모듈의 그래프를 만들어 내는 것입니다.
인터프리터가 실행되면, JavaScript 파일 형식으로 실행할 코드가 전달됩니다. 파일은 종속성 확인을 위한 진입점입니다.
인터프리터는 진입점에서부터 필요한 모든 코드가 탐색되고 평가될 때까지 import 구문을 재귀적인 DFS으로 찾습니다. 좀 더 구체적으로, 3단계에 걸쳐 작업이 진행됩니다.
- 1단계 - 생성(파싱): 모든 Import 구문을 찾고 재귀적으로 각 파일로부터 모든 모듈의 내용을 적재합니다.
- 2단계 - 인스턴스화: 익스포트 된 모든 개체들에 대해 명명된 참조를 메모리에 유지합니다. 또한 모든 import 및 export문에 대한 참조가 생성되어 이들 간의 종속성 관계를 추적합니다. 이 단계에서는 어떠한 JavaScript 코드도 실행되지 않습니다.
- 3단계 - 평가: Node.js는 마지막으로 코드를 실행하여 이전에 인스턴스화된 모든 개체가 실제 값을 얻을 수 있도록 합니다. 이제 모든 준비가 되었기 때문에 진입점에서 코드를 실행할 수 있습니다.
쉽게 표현하면 1단계는 모든 점들을 찾고, 2단계에서 각 점들을 연결하고, 3단계에서는 올바른 순서로 실행하는 것입니다.
이 부분에서 CommonJS와 차이가 있습니다. 이전 CommonJS에서는 종속성 그래프가 탐색되기 전에 파일을 시키기 때문에 불완전한 상태로 모듈이 적재되어 실행되었습니다.
하지만 ESM에서는 종속성 그래프가 완전해지기 전까지는 어떠한 코드도 실행하지 않습니다.
순환 종속성 분석
순환 종속성을 분석하기 위해 이전 CommonJS에서 썼던 예제를 ESM으로 바꾸어 사용해 보겠습니다.
순환 종속성 예제 코드
// main.js
import * as a from './a.js';
import * as b from './b.js';
console.log('a ->', a);
console.log('b ->', b);
// a.js
import * as bModule from './b.js';
export let loaded = false;
export const b = bModule;
loaded = true;
// b.js
import * as aModule from './a.js';
export let loaded = false;
export const a = aModule;
loaded = true;
순환 종속성 예제 결과
CommonJS와 다르게 완전한 모듈 내용을 임포트 하였습니다. 그리고 모든 loaded 값이 true로 설정되었습니다.
단계별 해석
이제 3단계로 나누어 분석해 보겠습니다.
1 단계: 파싱
파싱 단계에서는 진입점에서부터 코드를 탐색하며, 모든 모듈의 import구문을 찾습니다. DFS로 탐색되며 모든 모듈은 한 번씩만 방문되며, 두 번 이상 방문하지 않습니다.
순환 종속성 예제 코드를 그림으로 살펴보겠습니다.
위의 그림을 보며 분석해 보겠습니다.
- main.js에서 처음으로 발견된 import문이 a.js로 곧장 향하게 합니다.
- a.js에서 b.js로 향하는 impor문을 발견합니다.
- b.js에서 a.js로 다시 향하는 import문이 있습니다. 하지만 a.js는 이미 방문했기 때문에 다시 탐색하지 않습니다.
- 이제 b.js가 다른 import문을 가지고 있지 않기 때문에 a.js로 되돌아가고, a.js에서 main.js로 되돌아갑니다. 하지만 main.js에서는 b.js의 import문을 발견하지만 해당 모듈은 이미 탐색했으니 무시됩니다.
순환 종속성 그래프 탐색을 마치면 아래와 같이 선형적인 모습을 갖추게 됩니다.
2 단계: 인스턴스화
인스턴스화 단계에서는 이전 단계에서 얻어진 트리 구조를 따라 아래에서 위로 움직입니다.
인터프리터는 모든 모듈에서 익스포트 된 속성을 먼저 찾고 나서 메모리에 익스포트된 이름의 맵을 만듭니다.
위의 그림(인스턴스화 시작 단계)을 분석하겠습니다.
- 인터프리터는 b.js에서 시작하며 모듈이 loaded와 a를 익스포트 하는 것을 포착합니다.
- 인터프리터는 loaded와 b를 익스포트 하는 a.js로 이동합니다.
- 마지막으로 main.js로 이동하며, 더 이상의 기능에 대한 익스포트가 없습니다.
- 마지막 단계에서 익스포트 맵은 익스포트 된 이름의 추적만을 유지합니다. 연관된 값은 현재로는 인스턴스화 되지 않은 것으로 간주됩니다.
인터프리터는 이 단계들을 거치고 나서 아래 그림과 같이 임포트를 하는 모듈에게 익스포트 된 이름의 링크를 전달합니다.
위의 그림을 분석하겠습니다.
- 모듈 b.js는 aModule라는 이름으로 a.js에서의 익스포트를 연결합니다.
- 모듈 a.js는 bModule라는 이름으로 b.js에서의 익스포트를 연결합니다.
- 마지막으로 main.js는 b라는 이름으로 b.js에서의 모든 익스포트를 임포트 합니다. a도 마찬가지입니다.
- 다시 말하지만 모든 값이 아직 인스턴스화되지 않았습니다. 이 단계는 다음 단계의 마지막에 사용 가능한 값에 대한 참조만을 연결합니다.
3 단계: 평가
마지막 평가 단계입니다. 모든 파일의 모든 코드가 실행됩니다. 실행 순서는 인스턴스화 단계처럼 그래프의 가장 아래에서부터 위로 올라갑니다.
가장 마지막에 실행되는 파일은 main.js입니다. 이 방식이 우리가 메인 비즈니스 로직을 수행하기 전에 익스포트 된 모든 값이 초기화되는 것을 보장해 줍니다.
아래 그림은 평가 단계의 시각화입니다.
평가 단계의 시간화 그림을 분석해 보겠습니다.
- b.js부터 수행되며, 첫 번째 라인은 모듈에서 익스포트 되는 loaded 값이 false로 평가됩니다.
- 마찬가지로, const a가 평가되며 모듈 a.js를 나타내는 모듈 객체에 대한 참조로 평가됩니다.
- loaded 속성의 값이 true로 바뀝니다. 이 시점에서 모듈 b.js의 익스포트 상태가 완전히 평가되었습니다.
- 이제 a.js로 수행이 이동되고, 다시 loaded를 false로 설정하는 것으로 시작합니다.
- 이때, export b가 익스포트 맵에서 모듈 b.js에 대한 참조로 평가합니다.
- 마지막으로 loaded 속성을 true로 바뀝니다. 이제 우리는 모듈 a.js에서도 완전히 평가된 모든 익스포트를 갖게 됩니다.
모든 단계를 거친 후 main.js에서는 완전히 export 된 값들을 사용할 수 있습니다.
이러한 단계들 덕분에 CommonJS에서의 순환 종속성 문제를 해결하였습니다.
모듈의 수정
모듈은 읽기 전용 라이브 바인딩이 됩니다. 이는 불러온 모듈을 변경할 수 없다는 것을 뜻합니다.
불러온 모듈의 값들이 객체라면 이는 속성으로 변경할 수 있습니다. 예제로 살펴보겠습니다.
아래 코드들은 fs모듈의 readFile을 모킹 하는 예제입니다. main.js와 mock-read-file.js로 구성되어 있습니다.
// mock-read-file.js
import fs from 'fs'; // (1)
const originalReadFile = fs.readFile; // (2)
let mockedResponse = null;
function mockedReadFile(path, cb) {
// (3)
setImmediate(() => {
cb(null, mockedResponse);
});
}
export function mockEnable(respondWith) {
// (4)
mockedResponse = respondWith;
fs.readFile = mockedReadFile;
}
export function mockDisable() {
// (5)
fs.readFile = originalReadFile;
}
mock-read-file.js 파일부터 분석해 보겠습니다.
- fs 모듈의 default expot를 임포트 한 것입니다. 이 코드는 다시 짚어볼 것이며, 지금은 fs모듈의 default export가 파일 시스템과 상호작용하게끔 해주는 기능들의 집합을 갖고 있는 객체라는 것입니다.
- 우리는 모의 구현으로 readFile() 함수를 대체하길 원합니다. 이 작업을 하기 전에 원래의 참조 값을 저장합니다.
- mockedReadFile() 함수는 우리가 원래의 구현을 대체하기 위해서 사용하고자 하는 실질적인 모의 구현입니다. 이 함수는 mockedRespocse의 현재 값과 함께 콜백을 호출합니다. 이 구현은 간략환 된 것입니다.
- 익스포트된 mockEnable() 함수는 모의 기능을 활성화하기 위해 사용될 수 있습니다. 원래의 구현을 모의 구현으로 바꿉니다. 모의 구현은 responseWith 인자를 통해 전달된 값과 같은 것을 리턴합니다.
- 마지막으로 익스포트된 mockDisable() 함수는 fs.readFile() 함수의 원래의 구현으로 복구시키기 위해서 사용될 수 있습니다.
mock-read-file.js를 사용하는 main.js 파일입니다.
// main.js
import fs from 'fs'; // (1)
import { mockDisable, mockEnable } from './mock-read-file.js';
mockEnable(Buffer.from('Hello World')); // (2)
fs.readFile('fake-path', (err, data) => {
// (3)
if (err) {
console.error(err);
process.exit(1);
}
console.log(data.toString()); // Hello World
});
mockDisable();
main.js 파일을 분석해 보겠습니다.
- 우리가 처음 한 것은 fs 모듈의 default export를 임포트 한 것입니다.
- 모킹 한 fs.readFile에서 얻기 위한 원하는 데이터를 인자로 넣어줍니다.
- 가짜 경로와 콜백함수를 전달하여 실행시키니다.
이러한 예제처럼 기존 기능을 다른 것으로 바꾸어 사용하는 것을 몽키 패치라고 부릅니다. 이런 몽키 패치는 테스트 코드를 작성할 때 유용하게 사용됩니다.
하지만 이렇게 직접적으로 사용하면 mockDisable 함수처럼 원래 함수로 되돌려 놓거나 sysncBuiltinESMExports(module 패키지에 존재) 함수를 사용하여 named export 된 기능도 일치하도록 해야 합니다.
하지만 Jest 같은 테스트 프레임워크들이 이러한 일들을 손쉽게 해 줄 수 있으며, 몽키 패치를 사용할 때는 신중히 사용해야 됩니다.
ESM과 CommonJS의 차이점과 상호 운용
ESM과 CommonJS의 대표적인 차이점은 ESM은 파일 확장자 명을 필수로 명시해야 하지만, CommonJS는 선택적으로 명시할 수 있습니다. 이어서 다른 차이점도 알아보겠습니다.
strict 모드
ESM은 암시적으로 strict mode에서 실행됩니다.
ESM에서의 참조 유실
ESM에서는 CommonJS에서 사용하던 변수를 사용할 수 없습니다. 아래처럼요
console.log(exports); // ReferenceEror...
console.log(module); // ReferenceEror...
console.log(__filename); // ReferenceEror...
console.log(__dirname); // ReferenceEror...
하지만 유틸함수를 만들어 사용할 수 있습니다.
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log(__filename); // /node-design-pattern/module-system/esm/2-7-2.js
console.log(__dirname); // /node-design-pattern/module-system/esm
또한 require를 재구성하여 ESM에서 CommonJS 파일을 import 하여 사용할 수 있습니다.
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// console.log(__filename);
// console.log(__dirname);
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
console.log(require('../commonjs/main')); // { a: 3 }
상호 운용
위의 참조 유실 부분에서 require를 재구성하여 CommonJS 파일을 사용하였지만, 기본적으로 ESM에서 CommonJS를 사용할 수 있습니다. 하지만 반대는 안됩니다.
// /commonjs/main.js
exports.b = 3;
// esm-main.js
import common from '../commonjs/main.js';
import { b } from '../commonjs/main.js';
console.log(common); // { b: 3 }
console.log(b); // 3
마무리
ESM 특징과 CommonJS의 차이점을 알아보았습니다. CommonJS와 ESM의 특성은 프론트엔드 개발자도 필수로 알아야 된다고 생각합니다.
'백엔드 > [NodeJS] 디자인 패턴' 카테고리의 다른 글
CommonJS (exports module.exports) (0) | 2024.01.01 |
---|