esModule (ESM) 라이브러리를 CommonJS (CJS) 앱에서 사용하기

"Kermit the frog in style of Ancient Egypt papyrus." from DALL-E 2

문제

현재 내가 사용하고 있는 Node.js는 14.18.3인데, 14 버전부터는 공식적으로 esModule (이하 ESM)을 사용할 수 있는데, ESM을 기반으로 하는 라이브러리 패키지를 개발하면, 기존의 CommonJS (이하 CJS) 기반의 앱이나 라이브러리에서는 해당 ESM 라이브러리 패키지를 의존성으로 사용할 수 없다. 이에 따라, ESM 기반의 라이브러리 패키지를 CJS 기반의 앱에서 사용하기 위한 방법을 찾아보았다.

특징

CJS는 동기, ESM은 비동기 방식

CJS는 한 번에 하나씩 디스크에서 파일을 읽어온 후에 즉시 실행한다. 반면, ESM은 형제 스크립트를 병렬적으로 다운로드하고, 실행은 나중에 한다. ESM은 import와 export하는 구문을 찾아서 오타를 감지하면 실행하기 전에 오류를 발생시킨다.

ESM은 모듈 그래프를 빌드

ESM은 import 하는게 아무것도 없는 스크립트를 찾을 때 까지 그래프 빌드를 반복한다. 마지막으로 찾은 스크립트는 실행되고, 다음의 의존성 스크립트가 실행된다. 모듈 그래프에서 형제 그래프 (같은 레벨의 스크립트)는 병렬적으로 다운로드 되지만, 실행은 로더 스펙에 정의된 순서대로 실행된다.

설정하기

esModule (ESM) 중심으로 사용

*.js를 ESM이 사용하고, CJS는 *.cjs를 사용하는 방식을 말한다.

// package.json
{
	"type": "module"
}

export 설정

더보기

 ESM export

// example.js
export function sum(a, b){
    return parseInt(a + b, 10)
}

export const obj = {
    a: 1,
    b: 'kim',
    c: false
}

export const arr = [1,2,3]

export class User {
    constructor(){}
    sayHello(){
        return 'hello'
    }
}

export default function multiply(a,b){
    return a * b
}

CJS export

// subtract.cjs
function subtract(a, b){
	return a - b
}
module.exports = {
	subtract
}

import 설정

- 파일 import

더보기

ESM에서 ESM 파일 import

// index.js
import {sum, User, obj, arr} from './example.js' // 반드시 확장명까지 기입

sum(3, 5)
arr[1]
obj.a
const user = new User()
user.sayHello()

ESM에서 CJS 파일 import

// index.js
import {subtract} from './subtract.cjs' // 반드시 확장명까지 기입
subtract(5,3) // 2
  • CommonJS의 파일 확장명은 반드시 *.cjs 이어야 함
  • import 할 경우, 확장명 (cjs) 까지 표기해야 함

CJS에서 ESM 파일 import

CJS에서 require()를 통한 ESM 모듈의 import는 지원하지 않는다.

- 모듈 import

더보기

ESM에서 CJS 모듈 import

import commonjs from '@inface/commonjs'
const {deepFreeze, json} = commonjs
const obj = {
    a: {
        b: 'blue'
    }
}
deepFreeze(obj)
console.log(json.stringify(obj))
  • CJS 모듈을 import와 동시에 디스트럭처링이 불가능함 (동기방식)

CommonJS (CJS) 중심으로 사용

*.js를 CJS이 사용하고, ESM는 *.mjs를 사용하는 방식을 말한다.

// package.json
{
	"type": "commonjs"
}
⚠️ "commonjs"가 기본 값이라 기입하지 않아도 되긴 합니다.

export 설정

더보기

ESM export

// multiply.mjs
export function multiply(a, b){
	return a * b
}

CJS export

// multiply.js
function multiply(a, b){
	return a * b
}
module.exports = {
	multiply
}

import 설정

- 파일 import

더보기

ESM에서 CJS 파일 import

// index.mjs
import { subtract } from './subtract.js' // 확장명 까지 명시해야 함
subtract(5, 3) // 2

ESM에서 ESM 파일 import

// index.mjs
import { multiply } from './multiply.mjs' // 확장명 까지 명시해야 함
multiply(3, 5) // 15

CJS에서 ESM 파일 import

CJS에서 require()를 통한 ESM 모듈의 import는 지원하지 않는다.

- 모듈 import

더보기

ESM에서 CJS 모듈 import

import commonjs from '@inface/commonjs'
const {deepFreeze, json} = commonjs
const obj = {
    a: {
        b: 'blue'
    }
}
deepFreeze(obj)
console.log(json.stringify(obj))

CJS 모듈을 import와 동시에 디스트럭처링이 불가능함 (동기방식)

특이사항

Object.freeze() 에서 발견한 문제

더보기

Object.freeze()로 생성한 객체에 변경을 시도하면 ESM과 CJS가 다른 결과를 보여주었다.

CJS

별도의 에러 없이 결과가 바뀌지 않았다 (기대한 결과).

ESM

예외가 발생하였다.

TypeError: Cannot assign to read only property 'a' of object '#<Object>'

이를 통해, 두 모듈 방식에서 같은 함수에 대해 다른 결과가 나올 수 있다는 것을 보여주고 있다.

리캡

import CJS 을 (를) ESM 을 (를)
CJS 에서 호환 비호환
ESM 에서 호환 호환

CJS 기반 앱에서 ESM 기반 라이브러리를 사용하는 방법

CJS 기반 앱에서 ESM 기반 라이브러리를 사용할 수 있는 편법을 조사하였다.

필수) 조건부 exports

package.json에 다음 옵션을 추가하여 CJS 기반 앱에서는 index.js, ESM 기반 앱에서는 index.mjs를 임포트하도록 설정할 수 있다.

// package.json
{
	"exports": {
		"import": "./index.mjs",
		"require": "./index.js"
	}
}

선택 1) rollup으로 트랜스파일

더보기

ESM 소스를 CJS 소스로 트랜스파일 해주는 모듈

// index.mjs
export function sayHello(){
    return 'hello'
}

ESM 소스인 위 파일을 CJS 소스 파일로 트랜스파일

> rollup ./index.mjs -f cjs -o index.js

트랜스파일 결과 아래의 소스가 생성된다.

'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
function sayHello(){
    return 'hello'
}
exports.sayHello = sayHello;

조건부 exports를 통해 ESM 앱에서는 index.mjs가, CJS 앱에서는 index.js를 사용하게 된다.

선택 2) CJS Wrapper

- 라이브러리 설정

더보기

ESM 기반 라이브러리 (예: js-esm-lib)에 메인 파일 index.mjs가 있다고 가정한다.

//index.mjs
export function sayHello(){
	return 'hello'
}

다른 CJS 앱에서 사용할 수 있도록 래퍼 cjs-wrapper.js를 정의한다.

//cjs-wrapper.js
async function cjsWrapper(){
	return await import('./index.mjs')
}
module.exports = {
	cjsWrapper
}

- 앱 설정

더보기

CJS 앱에서는 다음과 같은 방식으로 사용이 가능하다.

const {cjsWrapper} = require('js-esm-lib')
async function main(){
	const esmLib = await cjsWrapper() // 반드시 초기화 작업이 필요함
	console.log(esmLib.sayHello()) // hello
}
main()

ESM 앱에서는 바로 사용이 가능하다.

import {sayHello} from 'js-esm-lib'
console.log(sayHello()) // hello

정리하자면, 조건부 exports를 통해 ESM 앱에서는 바로 사용 가능하며, CJS 앱에서는 cjsWrapper를 통해 사용하면 된다.

선택 3) esm 패키지

https://github.com/standard-things/esm

ESM 기반으로 작성된 라이브러리를 CJS 기반 또는 ESM 기반 앱에서 사용할 수 있게 만들어주는 패키지로, 사용자는 자신이 사용하는 모듈 방식에 상관없이 라이브러리 사용 가능하다!

⚠️ “필수) 조건부 exports” 설정을 해야 CJS 기반의 앱과 ESM 기반의 앱에서 모두 사용 가능

- 라이브러리 설정

더보기

ESM 기반의 라이브러리를 개발할 경우 다음과 같이 프로젝트 초기화 가능하다.

> npm init esm -y

초기화 하면 main.js와 index.js 가 자동 생성되는데, main.js의 이름을 main.mjs로 변경하고 ESM 기반의 함수를 작성한다.

// main.mjs
export function sayHello(){
    return 'hello'
}

메인 파일은 main.mjs지만, index.js가 esm 패키지를 통해 require를 래핑하여 외부 CJS 앱에서 사용할 수 있도록 지원하고 있다.

// index.js
require = require("esm")(module/* , options */)
module.exports = require("./main.mjs") // ./main.mjs로 수정하기!

“필수) 조건부 exports”의 설명과 같이 package.json에 exports 속성을 설정해야 한다.

// package.json
{
	"main": "index.js",
	"module": "main.mjs",
	"exports":{
		"import": "./main.mjs",
		"require": "./index.js"
	}
}

라이브러리 샘플을 다운로드 받을 수 있다.

esm-free.zip
0.00MB

- 앱 설정

더보기

CJS 기반 앱 설정

// cjs-index.js
const {sayHello} = require('esm-free')
sayHello() // hello

ESM 기반 앱 설정

// esm-index.mjs
import {sayHello} from 'esm-free'
sayHello() // hello

마무리

결론적으로, "선택 3) esm 패키지" 방식을 사용하면 라이브러리의 모듈 방식에 상관없이 CJS 기반의 앱에서 해당 라이브러리를 별도의 작업없이 사용할 수 있다.