혼자 정리

Node.js의 CommonJS, ESM 모듈 호환성 본문

자바스크립트, 타입스크립트

Node.js의 CommonJS, ESM 모듈 호환성

tbonelee 2024. 4. 1. 21:22

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하는 모듈에서 사용하는 exportsmodule.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

tsconifgesModuleInterop 을 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 스펙과 맞지 않습니다.

tsconfigesModuleInterop 설정을 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 키 안에 한 번 감싸는 형식으로 처리하게 됩니다.