Node.js의 CommonJS, ESM 모듈 호환성
Node.js의 CommonJS, ESM 모듈 호환성
Node.js 문서를 참조하여 필요한 부분만 정리했습니다
Modules: CommonJS modules | Node.js v21.7.1 Documentation
Modules: ECMAScript modules | Node.js v21.7.1 Documentation
우선 Node.js 에서는 각 파일을 별개의 모듈로 간주한다는 점을 깔고 갑니다.
CommonJS 모듈
module.exports 객체에 키값을 지정하여 내보내기
CommonJS 방식에서 모듈 내부의 변수를 export하려면 module.exports
object 또는 exports
object 에 할당하면 됩니다.
Example
모두 commonjs 타입 모듈이라고 가정합니다.
src/to-string.js
const { toFixed, toExponential } = Number;
module.exports.toFixedString = (n) => toFixed(n);
// or exports.toFixedString = (n) => toFixed(n);
module.exports.toExponentialString = (n) => toExponential(n);
// or exports.toExponentialString = (n) => toExponential(n);
src/main.js
const ToString = require('./to-string');
console.log(ToString);
node src/main
의 출력 :
{
toFixedString: [Function (anonymous)],
toExponentialString: [Function (anonymous)]
}
module.exports 자체에 값을 할당하여 내보내기
exports
객체 자체에 새로운 값을 할당할 수도 있습니다. (꼭 객체가 아니더라도)
하지만 외부에서 모듈을 가져올 때는 module
객체를 가져와서 module.exports
에 접근합니다.
export하는 모듈에서 사용하는 exports
는 module.exports
레퍼런스의 복사본이기 때문에 exports
가 아닌 module.exports
에 할당해주어야 합니다.
Example
모두 commonjs 타입 모듈이라고 가정합니다.
src/module.js
module.exports = 'something exported';
src/main.js
const imported = require('./module');
console.log(imported);
node src/main
의 출력
'something exported'
오브젝트 destructuring을 통해 모듈 변수들을 가져올 때 주의할 점
CommonJS에서 exports
객체를 분해 할당하는 경우 조심해야 할 부분이 있습니다.
다음과 같이 구조 분해 할당을 통해 가져오는 경우 새 변수를 선언하고 값을 복사하게 됩니다.
만약 가져온 변수가 모듈 내부에서 변화할 수 있는 primitive 값이라면 가져온 이후 변화한 내용을 보지 못할 수 있습니다.
Example
src/module.js
exports.counter = 0;
exports.increment = () => exports.counter++;
src/main.js
const cjs = require('./module.js');
const { counter, increment } = require('./module.js');
console.log(`cjs.counter : ${cjs.counter}, counter: ${counter}`);
cjs.increment();
console.log(`cjs.counter : ${cjs.counter}, counter: ${counter}`);
increment();
console.log(`cjs.counter : ${cjs.counter}, counter: ${counter}`);
node src/main
의 출력 :
cjs.counter : 0, counter: 0
cjs.counter : 1, counter: 0
cjs.counter : 2, counter: 0
이 예제에서 볼 수 있듯이, 모듈에서 가져온 변수의 값이 후에 변경 되더라도 구조 분해 할당을 통해 가져온 변수의 값은 그대로 유지됩니다.
cf)
ES 모듈에서는 live binding 이므로 import한 변수의 변화가 visible하게 됩니다. ([import
mdn 문서](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import))
src/module.js
export let counter = 0;
export const increment = () => counter++;
src/main.js
import { counter, increment } from './module.js';
console.log(`counter: ${counter}`);
increment();
console.log(`counter: ${counter}`);
increment();
console.log(`counter: ${counter}`);
node src/main
의 출력 :
counter: 0
counter: 1
counter: 2
이 예제에서는 ES 모듈을 사용하여 import한 변수의 값이 실시간으로 업데이트되는 것을 확인할 수 있습니다.
ES 모듈에서 CommonJS 모듈 가져오기
Node.js의 ES 모듈 지원은 기본적으로 ECMAScript 명세를 따라갑니다.
기본적인 import
, export
구문은 이미 알고 있음을 가정합니다.
Node.js는 ESM 방식 모듈에서 CommonJS 방식 모듈을 가져오는 경우 module.exports
에 할당된 값을 ESM 모듈의 export default ~~
값처럼 생각하고 가져올 수 있습니다.
따라서 다음과 같이 Default import(레퍼런스) 방식으로 가져올 수 있습니다.
import { default as cjs1 } from 'cjs';
import cjs2 from 'cjs';
console.log(cjs1); // module.exports 출력
console.log(cjs1 === cjs2); // true 출력
물론 Namespace import(레퍼런스) 방식으로도 가져올 수 있습니다.
import * as cjs from 'cjs';
console.log(cjs); // [Module] { default: <module.exports> }
Node.js는 이에 더해 정적 분석 과정을 통해 CommonJS 모듈의 모든 named exports를 ES 모듈 export로 접근할 수 있도록 해줍니다.
예를 들어 다음의 CommonJS 모듈이 있다고 하면,
// cjs.cjs
exports.foo = 'foo';
exports.bar = 'bar';
ES 모듈에서 다음과 같이 import하게 됩니다.
import { foo, bar } from './cjs.cjs';
console.log(foo, bar); // foo bar
import cjs from './cjs.cjs';
console.log(cjs); // { foo: 'foo', bar: 'bar' }
import * as CJS from './cjs.cjs';
console.log(CJS); // [Module] { default: { foo: 'foo', bar: 'bar' }, foo: 'foo', bar: 'bar' }
하지만 이러한 과정의 exports 복사 과정은 import 시점에 이루어지기 때문에 live binding(참고)이나 import 이후 module.exports
에 추가되는 값이 자동적으로 추가되지는 않습니다.
또한 모듈에서 export하는 패턴에 따라 named export 인식이 제대로 되지 않을 수 있습니다.
이러한 경우가 있기 때문에 default import를 사용하는 것이 보다 안전한 방법이라 생각합니다.
타입스크립트의 ESM/CJS Interoperability
tsconifg
의 esModuleInterop
을 true로 설정하지 않으면 타입스크립트는 CommonJS 모듈을 ES6 모듈과 비슷한 방식으로 처리합니다.
- namespace import
import * as moment from "moment"
→const moment = require("moment")
와 같이 동작하도록 컴파일 - default import
import moment from "moment"
→const moment = require("moment").default
와 같이 동작하도록 컴파일
ES6 모듈에서는 namespace import 결과물(import * as x
)은 object가 되어야 합니다.
하지만 타입스크립트에서 이를 = require("x")
와 같이 처리함으로써 object가 아니라 호출 가능한 함수가 될 수도 있게 되었습니다. 이는 ES6 스펙과 맞지 않습니다.
tsconfig
의 esModuleInterop
설정을 true로 설정하면 다음과 같이 처리하게 됩니다.
- namespace import
import * as x from "x"
require("x").__esModule
값이 truthy인 경우x = require("x")
- 그렇지 않은 경우
require("x")
의 모든 키/밸류를 복사하고'default'
키에require("x")
를 할당한 object를x
에 할당
- default import
import x from "x"
require("x").__esModule
값이 truthy인 경우x = require("x").default
- 그렇지 않은 경우
x = require("x")
즉 ES 모듈이 아니라고 판단하면 전체 모듈 exports를 default
키 안에 한 번 감싸는 형식으로 처리하게 됩니다.