go to post list

Dual CJS/ESM 패키지 만들기

2024. 04. 28 • 패키지를 배포하면서 JavaScript 모듈 시스템 찍먹하기

지금은 자바스크립트에서 자연스럽게 모듈 시스템을 사용하지만, Node.js 이전에는 자바스크립트의 런타임이 브라우저 하나밖에 없었고, 모듈 시스템 또한 없었습니다. 파일을 쪼개도 파일만 쪼갰을 뿐, 쪼갠 파일이 순서대로 로드되었기 때문에 로드 순서에 따라 변수를 관리해야 했습니다. 그 이후에도 ESM이 자바스크립트 표준에 포함되기 전에는 브라우저 자체적으로 모듈 시스템을 지원하지 않았기 때문에 쪼개놓은 스크립트 파일이 순서대로 잘 로드되도록 해야 했고, 모듈화를 하려면 우회적인 방법을 사용해야 해서 빌드 없이는 모듈화를 하기 어려웠다고 합니다. Node.js가 등장하고 본격적으로 모듈 시스템이 생기게 되었는데, 여기서는 CommonJS Modules와 ECMAScript Modules 두 가지만 정리하겠습니다.

CommonJS Modules (CJS)

Node.js에서는 기본적으로 CommonJS(CJS) 문법(require)을 통해 외부로 코드를 내보내거나, 외부의 코드를 불러옵니다.

Javascript

// foo.js
module.exports.add = (a, b) => a + b;

// bar.js
const { add } = require('./foo');

add(100, 10);

ECMAScript Modules(ESM)

2015년 ECMAScript Modules(ESM)가 도입된 이후에는 브라우저에서도 <script type="module">을 통해 모듈 문법(import)을 통해 빌드나 우회적인 방법 없이 코드 모듈화를 할 수 있게 됐습니다. 또 이 구문은 Node 12 이후의 Node 런타임에서도 사용할 수 있습니다. 즉 ESM 도입 이후 Node에서는 requireimport 구문 모두 사용할 수 있게 됐습니다.

Javascript

// foo.js
export const add = (a, b) => a + b;

// bar.js
import { add } from './foo.js';

add(100, 10);

두 모듈 시스템의 차이

Node에서 두 모듈은 근본적으로 다르게 동작한다고 하는데요, 그 중 가시적인 차이점 두 가지를 알아보겠습니다.

1. 모듈 로딩 방식

CJS는 모듈을 동기적으로, ESM은 비동기적으로 import합니다. ESM은 Top-level Await을 지원하기 때문이라고 하는데요, 일반적으로 모듈의 전역 레벨에서는 async 키워드 없이 await 키워드를 바로 사용할 수 없습니다. (ES2022 이전)

Javascript

const fs = require('fs');

const data = await fs.readFile('./data.txt', 'utf8');
console.log(data);

//SyntaxError: await is only valid in async functions and the top level bodies of modules

중요한 것은 Top-level Await 지원이라는 이유 때문에 ESM을 사용할 수 있는 환경에서는 CJS를 import할 수 있지만 반대는 불가능하다는 점인데요, 다시 말해 두 모듈 시스템이 호환될 수 없다는 것입니다.

2. 모듈 경로 사용 방식

두 모듈 시스템의 구문에는 아래와 같은 규칙이 있습니다.

  • CJS의 require, module.exports 구문에는 동적인 값을 사용할 수 있지만, ESM의 import 구문에는 동적인 값을 쓸 수 없다.

Javascript

const text = require(`./${file}`); //OK

import { text } from './file.js'; //OK
import { text } from `./${file}.js`; //Error

이 규칙은 CJS의 모듈 위치를 런타임에만 특정할 수 있도록 강제합니다. 반면 ESM의 경우 모듈의 위치가 정적이기 때문에 런타임 이전에 모듈의 위치를 모두 파악할 수 있습니다. 즉 코드를 빌드하는 과정에서 실질적으로 사용되지 않은 모듈의 코드를 파악해 쳐낼(Tree Shaking)수 있게 됩니다. Tree-Shaking을 하면 빌드 결과, 즉 JS파일의 크기가 작아집니다. 빌드 결과의 크기가 작아진다는 건 특히 클라이언트 사이드에서 의미가 큰데요, 브라우저에서 로드하게 될 자바스크립트 파일의 크기가 작아져 결과적으로 어플리케이션의 로딩 시간이 빨라지게 됩니다.

모듈과 동작 환경 그리고 패키지의 지원 범위

제가 배포한 라이브러리는(@syyu/util) 자바스크립트, 타입스크립트 유틸리티 라이브러리인데요, 이름에서 알 수 있지만 범위를 사실상 특정하지 않고 있습니다. 심지어 @syyu/util/react로 subpath에는 React Hooks를 포함하고 있습니다. 예시로는 대표적으로 제가 Object.keys같은 메서드를 쓰다가 발생하는 타입 이슈를 해결한 것이 있습니다. 즉 브라우저와 Node 중 어느 한 쪽에서만 실행될 것이라고 보장할 수 있는 라이브러리가 아닙니다. React Hooks만 따로 떼어서 배포한다고 해도, Next.js 환경에서 SSR을 사용하는 경우도 생각해야 하기 때문에 똑같이 양쪽 모듈 시스템을 모두 지원해야 하는 상황이라고 생각했습니다. 그러면 어떻게 양쪽 모듈 시스템을 모두 지원할 수 있을까요? 정답은 package.json입니다.

Package.json

  • package.json의 type 필드의 기본값(아무 값도 없을 때)은 commonjs이고, 기본적으로 js 확장자 파일을 CJS로 간주합니다.
  • "type": "module"으로 바꿨을 때 ESM으로 간주합니다.
  • .cjs는 항상 CJS, .mjs는 항상 ESM으로 간주합니다.
  • exports 필드를 통해 CJS, ESM을 상황에 따라 조건부로 지원할 수 있습니다.

제 package.json의 exports 필드를 살펴보면 아래와 같습니다. (type 필드가 기본값인 CJS 패키지입니다)

JSON

{
"exports": {
    ".": { //import { ... } from @syyu/util
      "main": "./ts/dist/cjs/index.js",
      "types": "./ts/dist/cjs/index.d.ts",
      "module": "./ts/dist/es/index.mjs",
      "import": {
        "types": "./ts/dist/es/index.d.mts",
        "default": "./ts/dist/es/index.mjs"
      },
      "require": {
        "types": "./ts/dist/cjs/index.d.ts",
        "default": "./ts/dist/cjs/index.js"
      }
    },
    "./react": { //subpath를 지원합니다. import { ... } from @syyu/util/react
      "main": "./react/dist/cjs/index.js",
      "types": "./react/dist/cjs/index.d.ts",
      "module": "./react/dist/es/index.mjs",
      "import": {
        "types": "./react/dist/es/index.d.mts",
        "default": "./react/dist/es/index.mjs"
      },
      "require": {
        "types": "./react/dist/cjs/index.d.ts",
        "default": "./react/dist/cjs/index.js"
      }
    }
  },
}

CJS 패키지이기 때문에 .js 파일이 CJS로 해석될 것을 전제로 작성되어 있습니다. require 필드, 즉 CJS 필드에는 .js 파일을 export하고, import 필드에는 ESM에 해당하는 mjs / mts파일을 조건부로 export하고 있는 걸 보실 수 있습니다. 만약 "type": "module"인 ESM 패키지였다면 이 필드에 반대로 cjs / cts 파일을 명시적으로 번들링해 지원해야 합니다.

잘못된 type 필드를 사용하거나 확장자를 지키지 않으면 잘못된 모듈 로더가 사용되거나 환경에 따라 에러가 발생할 수 있습니다. 또 타입스크립트는 내용이 같더라도 타입 파일의 확장자도 구분하기 때문에 규칙을 잘 지키지 않으면 패키지를 가져왔을 때 에러가 발생할 수 있습니다.

마무리

처음에는 공통된 코드를 묶기만 하려다가 일이 커져서 NPM에 배포까지 하게 됐습니다. 패키지를 배포해 보면서 라이브러리가 두 런타임을 완벽히 지원하면 어떤 이점이 있는지, webpack같은 빌드 도구들이 어플리케이션을 빌드하는 과정에서 Tree-Shaking을 왜 할 수 있는지 알게 됐습니다.

CJS나 ESM 말고도 ES6 이전에 사용하던 AMD나 여러 모듈 방식을 모두 지원하기 위한 UMD같은 모듈 정의 방법도 있는데요, AMD로 작성된 라이브러리들은 현재 표준안인 ESM으로 전환되는 추세이고, UMD의 경우 보통 번들러가 생성하는 형태로 작성되며 보통 직접 작성하는 경우는 드물다고 합니다. 모듈 시스템은 언젠가 반드시 알아봐야 한다고 생각했는데 패키지를 배포하면서 대략적으로 정리할 수 있어서 좋았습니다.

혹시 제가 만든 패키지가 궁금하시다면 npm install @syyu/util yarn add @syyu/util 로 설치해서 사용해보실 수 있습니다. 자세한 사용 방법은 아래 문서를 꼭 참조해주세요.

참고한 글

다른 글