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