ESM과 CommonJS를 위한 하이브리드 NPM 패키지 만드는법

ESM과 CommonJS를 위한 하이브리드 NPM 패키지 만드는법

2024, Aug 21    

ESM과 CommonJS를 위한 라이브러리 쉽게 개발하기

단일 소스 코드베이스를 사용하면서, Webpack 없이 ESM과 CommonJS를 모두 타겟으로 하는 NPM 모듈을 쉽게 만들 수 있는 방법은 무엇일까요?

이 질문은 오랫동안 많은 사람들을 고민하게 했습니다.

단일 코드베이스에서 CommonJS와 ES 모듈을 모두 쉽게 타겟으로 하는 NPM 모듈을 만드는 것은 종종 좌절감을 주는 작업이 될 수 있습니다. “하이브리드” 패키지라고도 불리는 이 목표는 import 또는 require를 통해 간단히 소비할 수 있는 NPM 모듈을 쉽게 만드는 것이지만, 이 목표는 실현하기 어렵습니다.

이 주제에 대해 수 많은 블로그 기사, Stack Overflow 질문, 참고 페이지가 존재합니다.

이들은 다양한 경쟁 전략을 제시하며, 이들을 시도해보면 일부 상황에서는 작동하지만 다른 상황에서는 불안정하게 작동하거나 실패하는 경우가 많습니다.

대부분의 해결책은 Webpack, Rollup, 커스텀 스크립팅 및 빌드 도구를 사용하거나 이중 소스 베이스를 생성하고 유지관리해야 합니다. 또한 대부분은 효율적이고 순수한 ESM 코드를 생성하지 않습니다.

Node.js 문서를 읽다 보면 Webpack과 Rollup, ESM, CommonJS, UMD, AMD에 대해 배우게 됩니다. .mjs.cjs 확장자가 해결책이자 미래라고 읽게 되지만, 대부분의 개발자들은 이들 확장자를 싫어하는 것 같습니다.

또한, package.jsontype = "module"exports 키워드가 모든 문제를 마법처럼 해결할 것이라고 읽게 되지만, 이들 또한 광고된 대로 작동하지 않습니다.

하이브리드 모듈을 만드는 것 이제 어렵지 않습니다!

저는 .mjs와 .cjs 확장자를 사용해 보았지만, 이들 확장자는 몇 가지 필수 빌드 도구와 호환되지 않았습니다.

또한, Webpack과 Rollup 같은 번들러를 사용해 보았습니다.

package.jsontype 필드를 사용해 보았지만, package.jsonexports 맵과 조합해서 사용할 때 실패했습니다 (자세한 내용은 아래에 설명합니다).

여러 가지 접근 방식을 시도했지만, 하나 이상의 사용 사례에서 실패하는 것을 발견했습니다.

결국, 쉽고 잘 작동하며 효율적인 ESM 코드를 생성하는 솔루션을 찾았습니다. 이 솔루션은 단일 소스 코드베이스를 지원하며, CommonJS 및 ESM 앱과 모듈에서 소비할 수 있는 모듈을 생성합니다.

이 방법이 모든 사용 사례에서 작동한다고 보장할 수는 없습니다. 하지만 Webpack, 서버리스 프레임워크, ESM 명령줄 도구 및 기타 ESM 또는 CommonJS 라이브러리에서 사용하는 것을 포함하여 모든 사용 사례에서 작동합니다.

.mjs의 문제점

해결책을 설명하기에 앞서, 널리 사용되고 있는 몇 가지 기법에 대해 짚어보겠습니다.

왜 그냥 .mjs나 .cjs 확장자를 사용해서 ESM(ECMAScript Module)이나 CommonJS 코드를 구분하지 않을까요? Node.js는 소스 파일의 타입을 나타내기 위해 이러한 확장자를 도입했습니다. 처음에는 합리적으로 보일 수 있습니다. 보통 확장자는 파일 타입을 설명하는 데 사용되니까요.

이 방법은 간단하고 독립적인 비하이브리드(Non-Hybrid) 상황에서는 잘 동작합니다.(ESM 환경에서만 개발할 경우)
하지만 하이브리드 모듈을 개발할 때 .mjs나 .cjs를 사용한다면, 코드베이스가 하나로 통일되어 있지 않거나, 소스를 복사해 확장자를 변경한 후 import 구문에서 적절한 확장자를 사용하도록 소스를 수정하는 커스텀 도구를 사용하고 있다는 것을 의미합니다.

ESM 코드는 import 구문에서 가져오는 파일의 경로를 명시해야 합니다. .mjs 확장자를 사용하는 URL에서 import할 경우, 이 코드를 .cjs 파일에서 require하려면 코드 수정이 필요하고 그 반대도 마찬가지입니다.

게다가 대부분의 툴 체인들이 아직 .mjs 파일을 제대로 지원하지 않습니다. 또한, 일부 웹 서버는 .mjs 확장자를 ‘application/json’ MIME 타입으로 정의하지 않았습니다. 자주 사용하는 번들러 또한 이러한 파일을 이해하지 못할 수 있습니다. 결국, 이러한 파일들을 관리하기 위해 설정과 매핑을 추가하거나 커스텀 스크립트를 작성해야 하는 상황이 발생합니다.

지금까지 .mjs와 .cjs 확장자를 누구나 “좋다”라고 말하는 사람을 본 적이 없습니다. 다행히도 대안이 있습니다. 바로 package.json의 “type” 속성입니다.

package.json의 “type” 속성 문제점

.js 확장자를 가진 파일이 ES 모듈인지 CommonJS 모듈인지 결정하기 위해, Node.js는 package.json의 “type” 속성과 관련된 규칙을 도입했습니다.

이 속성을 “module”로 설정하면 해당 디렉토리와 하위 디렉토리의 모든 파일이 ESM으로 간주됩니다.
“type”을 “commonjs”로 설정하면, 해당 디렉토리 내 모든 파일이 CommonJS로 간주됩니다.

이 기본 동작은 파일 확장자를 .cjs나 .mjs로 명시하여 덮어쓸 수 있습니다.

package.json

{
  "version": "1.2.3",
  "type": "module"
}

이 방식은 꽤 잘 동작하지만, 기본적으로 패키지는 “module” 또는 “commonjs” 중 하나로 설정됩니다.

❗️문제는 패키지가 하이브리드 형태이기 때문에 ESM과 CommonJS 형식을 모두 제공해야 할 때 발생합니다.
아쉽게도, ESM으로 사용할 때는 “module”로, CommonJS로 사용할 때는 “commonjs”로 설정할 수 있는 조건부 타입을 지정하는 방법은 없습니다.

Node.js는 패키지의 export 진입점을 정의하는 조건부 exports 속성을 제공합니다. 하지만 이 속성은 패키지 타입을 재정의하지 않으며, type과 exports 속성이 잘 결합되지도 않습니다.

package.json의 조건부 exports 문제점

조건부 exports 속성은 여러 진입점을 정의합니다. 하이브리드 모듈을 위해 import와 require 선택자를 사용하여 ESM과 CommonJS에서 각각 다른 진입점을 정의할 수 있다는 점에서 유용합니다.

package.json:

{
  "exports": {
    "import": "./dist/mjs/index.js",
    "require": "./dist/cjs/index.js"
  }
}

단일 소스 코드베이스에서 ESM과 CommonJS를 타겟으로 두 가지 배포본을 생성합니다.
그 후, exports 속성을 통해 Node.js가 적절한 진입점을 로드하도록 설정합니다.

그러나 만약 패키지의 type을 “module”로 설정하고 ESM과 CommonJS 모두를 위한 exports를 정의했다면 문제가 발생할 수 있습니다. index.js를 로드하는 데는 문제가 없지만, 그 파일이 다른 서브모듈(예: ./submodule.js)을 로드하려 할 때 해당 파일은 package.json의 type 설정에 따라 로드되고, exports 설정이 반영되지 않습니다.

즉, CommonJS 앱/라이브러리가 이 모듈을 사용해 “./dist/cjs/index.js”에서 require를 통해 로드하고, 그 index.js가 다시 require(‘./submodule.js’)를 호출하면 오류가 발생합니다. 그 이유는 모듈의 package.json이 type을 “module”로 설정했기 때문에 ESM 모듈에서 require를 사용하는 것이 금지되기 때문입니다.

안타깝게도 Node.js가 exports.require를 사용해 파일을 로드하더라도, 그 하위 코드를 CommonJS로 간주하지 않습니다. 이상적으로는, exports가 최상위 package.json의 type을 덮어쓸 수 있도록 모듈 타입을 정의할 수 있어야 합니다.

예를 들어, 아래는 가상의 package.json 예시입니다(예시일뿐 아래는 Node.js에서 지원되지 않음):

{
  "exports": {
    "import": {
      "path": "./dist/mjs/index.js",
      "type": "module"
    },
    "require": {
      "path": "./dist/cjs/index.js",
      "type": "commonjs"
    }
  }
}

하지만 이것은 그저 꿈에 불과합니다.

또 하나의 문제는 TypeScript가 아직 exports 속성에 제대로 대응하지 않는다는 점입니다. 그래서 TypeScript를 위해 기존의 module과 main 속성도 포함해야 합니다. main 속성은 CJS 진입점을, module 속성은 ESM 진입점을 가리키도록 설정해야 합니다.

{
  "main": "dist/cjs/index.js",
  "module": "dist/mjs/index.js"
}

Solution

그렇다면, 다음을 만족하는 접근법은 무엇일까요?

  • 단일 소스 코드베이스
  • 간편한 빌드
  • 네이티브 ESM 코드 생성
  • 기존 툴과의 호환성
  • ESM과 CommonJS 모두를 위한 하이브리드 패키지 생성

Single Source Base

코드는 ES6, ES-Next 또는 TypeScript로 작성하고, import와 export를 사용하는 것으로 합니다.

이 소스 기반에서, import를 사용해 ES 모듈이나 CommonJS 모듈을 모두 가져올 수 있습니다. 그러나 반대의 경우는 성립하지 않습니다. CommonJS로 작성된 코드는 ES 모듈을 쉽게 가져올 수 없습니다.

import Shape from './Shape.js'

export class MyShape {
    constructor() {
        this.shape = new Shape()
    }
}

export default를 사용하고 CommonJS를 통해 require로 가져올 때는 주의해야 합니다. TypeScript나 Babel 트랜스파일러는 자동으로 module.exports에 묶어주고, require로 가져올 때 .default 참조를 생성해줍니다. 그러나 네이티브 Node.js에서는 그렇지 않습니다. 즉, 트랜스파일러를 사용하지 않는다면 .default 참조를 수동으로 사용해야 할 수 있습니다.

import Shape from './Shape.js'

const shape = new Shape.default()

빌드

소스를 두 번 빌드합니다. 한 번은 ESM용으로, 또 한 번은 CommonJS용으로 빌드합니다.

우리는 TypeScript를 트랜스파일러로 사용하며, 코드를 ES6/ES-Next 또는 TypeScript로 작성합니다. ES6를 위한 빌드에는 Babel도 문제없이 사용할 수 있습니다.

JavaScript 파일은 .mjs.cjs가 아닌 .js 확장자를 사용해야 합니다. TypeScript 파일은 .ts 확장자를 사용합니다.

다음은 package.json 빌드 스크립트 예시입니다:

{
  "scripts": {
    "build": "rm -fr dist/* && tsc -p tsconfig.json && tsc -p tsconfig-cjs.json && ./fixup"
  }
}

tsconfig 설정

tsconfig.json은 ESM을 위한 빌드를 설정하고, tsconfig-cjs.json은 CommonJS를 위한 빌드를 설정합니다.
설정의 중복을 피하기 위해, ESM과 CommonJS 빌드 모두에서 사용하는 공통 빌드 설정을 포함하는 tsconfig-base.json을 정의합니다.
기본 tsconfig.json은 ESM을 위한 설정으로, "esnext"을 사용해 빌드합니다. 필요에 따라 "es2015" 또는 원하는 프리셋으로 변경할 수 있습니다.

tsconfig.json

{
  "extends": "./tsconfig-base.json",
  "compilerOptions": {
    "module": "esnext",
    "outDir": "dist/mjs",
    "target": "esnext"
  }
}

tsconfig-cjs.json

{
  "extends": "./tsconfig-base.json",
  "compilerOptions": {
    "module": "commonjs",
    "outDir": "dist/cjs",
    "target": "es2015"
  }
}

다음은 ES6 코드를 위한 공통 설정을 포함한 tsconfig-base.json입니다:

tsconfig-base.json

{
  "compilerOptions": {
    "allowJs": true,
    "allowSyntheticDefaultImports": true,
    "baseUrl": "src",
    "declaration": true,
    "esModuleInterop": true,
    "inlineSourceMap": false,
    "lib": [
      "esnext"
    ],
    "listEmittedFiles": false,
    "listFiles": false,
    "moduleResolution": "node",
    "noFallthroughCasesInSwitch": true,
    "pretty": true,
    "resolveJsonModule": true,
    "rootDir": "src",
    "skipLibCheck": true,
    "strict": true,
    "traceResolution": false,
    "types": [
      "node",
      "jest"
    ]
  },
  "compileOnSave": false,
  "exclude": [
    "node_modules",
    "dist"
  ],
  "include": [
    "src"
  ]
}

ESM/CJS 별 package.json 설정

빌드의 마지막 단계는 각 배포본에 맞는 package.json 파일을 생성하는 간단한 수정 스크립트입니다. 이 package.json 파일들은 .dist/* 하위 디렉토리의 기본 패키지 타입을 정의합니다.

fixup 파일 생성하기

cat >dist/cjs/package.json <<!EOF
{
    "type": "commonjs"
}
!EOF

cat >dist/mjs/package.json <<!EOF
{
    "type": "module"
}
!EOF

package.json 설정

우리의 package.json 파일에는 type 속성이 없습니다. 대신, 이 속성은 ./dist/* 하위 디렉토리의 package.json 파일로 내려갑니다.

우리는 패키지의 진입점을 정의하는 exports 맵을 정의합니다: 하나는 ESM용, 다른 하나는 CJS용입니다. 조건부 exports에 대한 자세한 내용은 Node.js 문서를 참조하세요.

다음은 우리의 package.json 일부 예시입니다:

{
  "main": "dist/cjs/index.js",
  "module": "dist/mjs/index.js",
  "exports": {
    ".": {
      "import": "./dist/mjs/index.js",
      "require": "./dist/cjs/index.js"
    }
  }
}

요약

하이브리드 패키지 설계를 통해 import 또는 require를 쉽게 사용할 수 있는 환경을 구축할 수 있습니다.
이를 통해 CommonJS 또는 ESM 사용자 모두 만족하는 호환성 있는 라이브러리 생태 환경을 구축할 수 있습니다.

아래는 위 전략대로 설계한 라이브러리인 arehs 입니다.
자세한 내용은 소스코드를 확인해주세요.

npm: arehs

CommonJS

const {Arehs} = require("arehs");

ES Modules

import {Arehs} from "arehs";