ESM, CJS 대응하는 라이브러리

강동욱·2024년 8월 23일
0

CommonJS (CJS)

  • 역사적 배경: CommonJS는 Node.js 환경에서 모듈을 관리하기 위해 도입된 모듈 시스템입니다.
  • 모듈 사용 방법:
    • 모듈 가져오기: require() 함수를 사용해 다른 모듈을 가져옵니다.
      const math = require('./math');
    • 모듈 내보내기: module.exports를 사용해 모듈의 기능을 내보냅니다.
      module.exports = {
        add: (a, b) => a + b,
        subtract: (a, b) => a - b,
      };
  • 동기적 로딩: CommonJS는 동기적으로 모듈을 로딩합니다. 즉, 모듈을 가져오는 코드가 실행되면 해당 모듈을 즉시 불러오고 실행합니다.
  • Node.js 환경에서 널리 사용됨: Node.js는 기본적으로 CommonJS를 지원합니다.

ECMAScript Modules (ESM)

  • 역사적 배경: ESM은 JavaScript의 표준 모듈 시스템으로, ES6(ECMAScript 2015)에서 도입되었습니다. 이 표준은 브라우저와 Node.js 환경 모두에서 사용할 수 있습니다.
  • 모듈 사용 방법:
    • 모듈 가져오기: import 문을 사용해 모듈을 가져옵니다.
      import { add, subtract } from './math.js';
    • 모듈 내보내기: export 문을 사용해 모듈의 기능을 내보냅니다.
      export const add = (a, b) => a + b;
      export const subtract = (a, b) => a - b;
  • 비동기적 로딩: ESM은 비동기적으로 모듈을 로딩합니다. 즉, 모듈을 가져오는 과정이 비동기적으로 처리될 수 있습니다.
  • 브라우저와 Node.js 환경 모두에서 사용 가능: ESM은 브라우저와 Node.js 모두에서 공식적으로 지원됩니다. Node.js에서는 *.mjs 파일 확장자나 package.json에서 "type": "module"을 설정하여 ESM을 사용할 수 있습니다.

ESM의 작동원리

ESM의 동작원리는 간략히 말하면 다음과 같습니다

  1. 구성: 모듈 리소스를 다운로드하고 모듈 레코드로 분석 합니다.
  2. 인스턴스화: 모듈에서 export된 값을 import 해서 사용하는 또 다른 모듈과 연결합니다. 이때 같은 메모리 공간만 연결시켜주고 값은 할당을 하지는 않습니다
  3. 평가: 코드를 실행해 서로 연결된 메모리 공간에 값을 할당합니다.

cjs모듈은 모듈을 순회하면서 위의 3단계의 과정이 동기적으로 한번에 일어납니다.모듈을 분석할때 require 구문을 찾을때 까지 code를 실행시키고 require를 찾으면 현재 분석하고 있는 모듈을 잠깐 중단하고 require문에 있는 모듈을 먼저 분석, 인스턴스화, 평가를 진행합니다. 그러므로 path의 값을 동적으로 할당할 수 있습니다.

// module-en.js
export.format = function (content){
	// code
}

//
let path = "module" + lang;
let formatter = require(path)

formatter.format(content)

Construction(구성)

구성 단계에서는 3가지 상황이 일어납니다.

  1. 어디에서 모듈을 리소스 다운로드 할지 정합니다.
  2. 모듈 파일 불러오기
  3. 모듈 레코드로 파일 분석하기

모듈의 진입점을 찾거나 모듈을 불러오기위해 import 구문에 모듈 확장자 표시해놓는데 host(ex. brower, node)에 의해 이런 확장자를 해석하는 방법들이 다릅니다.(module resolution)
이러한 host별로 사용하는 module resolution을 통해 의존하고 있는 여러 모듈들을 차례로 모듈 레코드로 분석하여 트리형식의 모듈 그래프를 만듭니다.

아까 위에서 잠깐 설명한 CJS는 path의 동적으로 값을 할당할 수 있다고 했는데 ESM은 못할까요? 아닙니다. ESM도 dynamic import를 활용해서 모듈값을 동적으로 할당할 수 있습니다. 이때는 모듈그래프에서 또 다른 모듈 그래프를 형성합니다. 아래 그림을 참고하면 다음과 같습니다.

만약 기존에 모듈 그래프를 그리던 중 dynamic import를 만나 또 다른 모듈 그래프를 형성했을때 두 모듈 그래프 간에 공통으로 사용하는 모듈 인스턴스가 있다면 다시 생성하는 것이 아닌 공통된 모듈 인스턴스를 서로 공유합니다. 왜냐하면 Loader 모듈맵에 모듈을 캐싱하기 때문입니다. 모듈을 공유한다는 것은 모듈 인스턴스는 한번 밖에 생성이 안된다는걸로 이해할 수 있습니다.

Instantiation(인스턴스화)

인스턴스화에서는 처음으로 JS Engine이 모듈 레코드의 변수들을 관리하는 module 환경 레코드를 생성합니다. 그런 다음 모듈 그래프의 상위에 있는 모듈부터 차례대로 export 값들의 메모리 공간을 연결 시켜줍니다. (값은 할당하지 않습니다.) 그런 다음 다시 하위 모듈부터 상위 모듈까지 export 값들을 사용하는 import 값들의 메모리 공간을 연결 시켜줍니다. 그러면 다음과 같은 그림이 나옵니다.

모듈에서 export된 값은 언제든지 바꿀수 있지만 import한 값은 바꾸지 못합니다.

CJS에서는 export 한 값과 import한 값의 메모리 공간이 일치하지 않습니다. export한 값이 메모리 공간에 할당이 되면 import를 한 값은 export값을 복사해와서 다른 메모리 공간에 저장합니다.

Evaluation (평가)

평가 과정에서 JS 엔진은 top-level-code(함수 밖에 있는 코드)들을 실행하면서 import와 export가 연결된 메모리 공간에다가 값을 채워줍니다. 값을 채워주는것 외에도 top-level-code를 실행한다는 것을 사이드 이펙트를 발생할 수 있습니다. 예를 들어 모듈이 서버를 호출할 수 있습니다.

// top-level-code
let count = 5
updateCountOnServer()
export {count, updateCount}

// top-level-code 아님
function updateCountOnServer() {}
function updateCount() {}

이러한 사이드 이펙트 때문에 모듈은 딱 한번만 평가합니다. 평가 단계에서는 여러번 실행하느냐에 따라서 그 결과가 달라지기 때문입니다. 이것은 또한 모듈은 하나의 모듈 레코드만 가지고 하나의 인스턴스만 생성하는 이유이기도 합니다.

CJS, ESM에 대응하는 라이브러리 만들기

Rollup 설정

위와같이 CJS, ESM 차이에 대해서 알아봤는데 너무나도 다르게 동작하기 때문에 두가지 모듈에서 사용할 수 있는 라이브러리를 만들어야 합니다. 저는 Rollup이라는 번들러를 사용했는데요 웹팩을 사용하지 않은 이유는 웹팩은 기본적으로 HMR, dev server를 지원하는데 이러한 것들은 라이브러리를 만들려고할 때는 필요없다고 생각해서입니다. 또한 번들사이즈도 롤업이 더 작고 빌드 속도도 빨라서 롤업을 선택하게 되었습니다.

먼저 esm, cjs 모듈을 둘다 지원하기 위해선 해당 cjs 또는 mjs 파일로 번들링을 해야합니다.

typescript 플러그인을 사용하는 이유는 아무런 설정없이 번들링을하면 타입스크립트를 읽지 못해 다음과 같은 오류가 발생하기 때문입니다.

그런 다음 번들링을 해보면 다음과 같은 결과가 나옵니다.

declaration을 true로 설정하니까 각 파일마다 dts파일을 설정해서 번들링 결과물이 형성되었습니다. 하지만 제가 원하는건 index.d.ts파일 하나에다가 전체 결과물을 생성하는것입니다. 이를 해결하기위해 dts 플러그인을 사용하였습니다.

위와같이 dts파일을 생성해준다음에 이전에 생성된 types폴더는 더이상 필요가 없기 때문에 rollup-plugin-delete 플러그인을 사용하여 빌드가 끝날때 삭제를 해줍니다. 그러면 다음과 같이 깔끔한 빌드 결과물을 얻을 수 있습니다.

subpath export, conditional export

exports field를 사용해 subpath exports를 사용하면 작성한 subpath만 사용하고 filesystem 상의 위치와 import path를 다르게 지정할 수 있습니다.

import easyFetch from '@woogie0303/easyfetch'

//package.json
{
  "name": "@woogie0303/easyfetch",
  "exports": {
    ".": "./dist/index.js",
  },
}

conditional export를 적용해 import 일때 require일때 파일의 경로를 다르게 설정함으로써 cjs, esm환경을 둘다 지원할 수 있습니다.

  "exports": {
    ".": {
      "require": {
        "types": "./dist/cjs/index.d.ts",
        "default": "./dist/cjs/index.js"
      },
      "import": {
        "types": "./dist/esm/index.d.mts",
        "default": "./dist/esm/index.mjs"
      }
    }
  }

참고로 package.json type이 설정이 안되어있는경우 기본값이 commonjs입니다. 그러므로 .js 확장자는 .cjs로 인식되므로 import일때는 .js를 사용하면안되고 .mjs로 확장자를 명시해 줘야지 올바른 모듈 타입을 지원할 수 있습니다. dts 파일도 위와 같은 설명이 동일하게 적용됩니다.

출처
https://toss.tech/article/commonjs-esm-exports-field
https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/

profile
차근차근 개발자

0개의 댓글