ESM과 CommonJS를 위한 하이브리드 NPM 패키지 만드는법
ESM과 CommonJS를 위한 라이브러리를 더 쉽게 개발하기
Webpack 같은 번들러 없이, 단일 코드베이스로 ESM과 CommonJS를 동시에 지원하는 NPM 모듈을 손쉽게 만들 수 있는 방법이 있을까요?
이 문제는 오랫동안 많은 개발자들의 고민거리였습니다.
하나의 코드베이스로 ESM과 CommonJS 환경 모두에서 동작하는 모듈을 만드는 일은 생각보다 쉽지 않습니다. 흔히 “하이브리드 패키지“라고 부르는 이 방식은 import나 require로 간단히 불러올 수 있는 모듈을 목표로 하지만, 실제 구현은 꽤나 까다롭습니다.
이 주제에 대한 블로그 글, Stack Overflow 질문, 공식 문서가 수도 없이 많지만 — 직접 시도해보면 일부는 작동하고, 일부는 불안정하거나 완전히 실패하기 일쑤입니다.
대부분의 해결책은 Webpack, Rollup, 커스텀 빌드 스크립트, 혹은 이중 코드베이스를 요구합니다. 하지만 이런 방식은 비효율적이고, 순수한 ESM 코드를 제대로 만들어내지 못하는 경우가 많습니다.
Node.js 문서를 보면 Webpack, Rollup, ESM, CommonJS, UMD, AMD 등 다양한 모듈 시스템 이야기가 등장하죠.
.mjs, .cjs 확장자가 해결책이라고 하지만, 솔직히 대부분의 개발자들은 이 확장자들을 별로 좋아하지 않습니다.
또한 package.json의 type = "module"과 exports 키워드가 모든 문제를 해결해줄 것처럼 보이지만, 실제로는 그렇게 매끄럽게 동작하지 않습니다.
하이브리드 모듈, 이제 어렵지 않습니다!
저도 .mjs, .cjs 확장자를 비롯해 Webpack, Rollup, 그리고 type 필드 설정까지 다양한 시도를 해봤습니다.
하지만 조합에 따라 매번 예상치 못한 문제들이 생겼습니다.
결국, 저는 단일 코드베이스로 작동하면서도 ESM과 CommonJS 모두에서 안정적으로 동작하는 간단한 솔루션을 찾았습니다. 이 방식은 순수한 ESM 코드를 생성하고, Webpack, 서버리스 환경, CLI 도구 등 대부분의 상황에서 문제없이 작동합니다.
물론 모든 경우에 100% 완벽하다고는 할 수 없지만, 일반적인 ESM/CJS 사용 시에는 충분히 실용적인 접근입니다.
.mjs의 문제점
먼저 흔히 사용되는 몇 가지 접근법을 살펴봅시다.
Node.js는 .mjs와 .cjs 확장자를 도입해 파일 타입을 구분하도록 했습니다.
언뜻 보기엔 합리적인 선택처럼 보이죠 — 파일 확장자는 보통 타입을 구분하는 용도로 쓰이니까요.
하지만 하이브리드 모듈을 개발할 때는 상황이 달라집니다.
.mjs와 .cjs를 사용하면 코드베이스가 분리되거나, 소스를 복사해 확장자를 바꾸고 import 구문을 다시 수정해야 하는 번거로움이 생깁니다.
ESM에서는 import 시 경로를 명시해야 하는데, .mjs로 작성된 코드를 CommonJS에서 require하려면 코드를 다시 고쳐야 하고, 그 반대도 마찬가지입니다.
게다가 아직도 많은 빌드 도구와 번들러들이 .mjs 파일을 완전히 지원하지 않습니다.
일부 웹 서버는 .mjs를 잘못된 MIME 타입(application/json 등)으로 처리하기도 하죠.
결국 추가 설정이나 매핑, 커스텀 스크립트를 작성해야 합니다.
이런 이유로 .mjs, .cjs를 적극 추천하는 사람은 거의 없습니다.
다행히 더 나은 대안이 있습니다 — 바로 package.json의 "type" 속성입니다.
package.json의 “type” 속성 문제
Node.js는 package.json의 "type" 필드를 이용해 .js 파일이 ESM인지 CommonJS인지 판별합니다.
"type": "module"→ 해당 디렉토리의 모든 파일을 ESM으로 인식"type": "commonjs"→ 해당 디렉토리의 모든 파일을 CommonJS로 인식
이 기본 동작은 .mjs 또는 .cjs 확장자로 개별 파일 단위에서 덮어쓸 수 있습니다.
{
"version": "1.2.3",
"type": "module"
}
이 방식은 단일 타입 패키지에서는 꽤 잘 작동합니다. 하지만 하이브리드 패키지처럼 ESM과 CommonJS를 동시에 지원해야 하는 경우에는 곤란해집니다.
"type" 속성은 전역적으로 하나의 타입만 지정할 수 있으며, 조건부 설정이 불가능합니다.
즉, ESM일 땐 "module", CJS일 땐 "commonjs"로 자동 전환하는 방법이 없습니다.
Node.js의 "exports" 조건부 속성으로 진입점을 구분할 수는 있지만, "type" 속성까지 재정의할 수는 없습니다.
두 속성은 제대로 결합되지 않습니다.
조건부 exports의 한계
exports 속성은 환경별로 다른 진입점을 정의할 수 있게 해줍니다.
예를 들어, ESM에서는 import, CommonJS에서는 require를 각각 지정할 수 있습니다.
{
"exports": {
"import": "./dist/mjs/index.js",
"require": "./dist/cjs/index.js"
}
}
이렇게 하면 Node.js는 상황에 맞는 진입점을 자동으로 로드합니다.
하지만 패키지의 "type"이 "module"로 설정되어 있으면 문제가 생깁니다.
CommonJS 앱에서 require("./dist/cjs/index.js")를 실행해도, 내부에서 다시 require('./submodule.js')를 호출하면 오류가 발생합니다.
왜냐하면 package.json의 "type": "module" 설정이 전체 모듈에 적용되어, require 사용이 금지되기 때문이죠.
즉, Node.js는 exports.require로 파일을 불러오더라도, 하위 모듈의 타입은 바꾸지 않습니다.
이상적으로는 아래와 같은 형태로 "type"을 진입점별로 정의할 수 있어야 하지만, 현재는 지원되지 않습니다.
{
"exports": {
"import": {
"path": "./dist/mjs/index.js",
"type": "module"
},
"require": {
"path": "./dist/cjs/index.js",
"type": "commonjs"
}
}
}
게다가 TypeScript는 아직 exports 속성을 완벽히 지원하지 않기 때문에,
기존의 "main"(CJS), "module"(ESM) 필드를 함께 유지해야 합니다.
{
"main": "dist/cjs/index.js",
"module": "dist/mjs/index.js"
}
해결책 (Solution)
이 모든 조건을 만족하려면 다음이 필요합니다:
- 단일 코드베이스
- 간단한 빌드 프로세스
- 네이티브 ESM 코드 생성
- 툴 호환성
- ESM과 CommonJS를 동시에 지원
단일 코드베이스 유지
코드는 ES6+, ESNext, 혹은 TypeScript로 작성합니다.
기본적으로 import/export를 사용하고, CommonJS에서는 require로 불러올 수 있게 합니다.
import Shape from './Shape.js'
export class MyShape {
constructor() {
this.shape = new Shape()
}
}
단, export default를 사용하는 경우 CommonJS에서 불러올 때 .default 참조가 필요할 수 있습니다.
const Shape = require('./Shape.js')
const shape = new Shape.default()
빌드
코드를 두 번 빌드합니다 — 한 번은 ESM용, 한 번은 CJS용.
TypeScript를 사용한다면 다음처럼 간단히 구성할 수 있습니다:
{
"scripts": {
"build": "rm -rf dist && tsc -p tsconfig.json && tsc -p tsconfig-cjs.json && ./fixup"
}
}
.js 확장자를 그대로 사용하고, .mjs나 .cjs는 사용하지 않습니다.
tsconfig 설정 예시
tsconfig-base.json: 공통 설정tsconfig.json: ESM 빌드용tsconfig-cjs.json: CJS 빌드용
이렇게 하면 설정 중복을 최소화할 수 있습니다.
// 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"
}
}
빌드 후 package.json 자동 생성
마지막 단계로 각 빌드 결과 디렉토리에 "type"을 지정하는 package.json을 자동 생성합니다.
cat > dist/cjs/package.json <<EOF
{
"type": "commonjs"
}
EOF
cat > dist/mjs/package.json <<EOF
{
"type": "module"
}
EOF
최종 package.json 설정
루트 package.json에는 "type"을 지정하지 않고, 대신 하위 dist/* 디렉토리에서 지정합니다.
{
"main": "dist/cjs/index.js",
"module": "dist/mjs/index.js",
"exports": {
".": {
"import": "./dist/mjs/index.js",
"require": "./dist/cjs/index.js"
}
}
}
정리
이 방식으로 빌드하면 import와 require 모두 자연스럽게 동작하는 하이브리드 패키지를 만들 수 있습니다.
즉, ESM과 CommonJS 사용자 모두를 만족시키는 라이브러리를 간단하게 유지할 수 있죠.
예시는 다음과 같습니다 👇
CommonJS
const { Arehs } = require("arehs");
ES Modules
import { Arehs } from "arehs";