Search
🐺

[번역] 왜 CommonJS와 ES Modules는 함께 쓸 수 없는가 | 내 라이브러리에서 esm과 cjs를 동시에 지원하는 법

subtitle
혼용은 가능하지만 번거로운 짓이다
Tags
자바스크립트
Created
2021/07/11
2 more properties
Dan Fabulich의 Node Modules at War: Why CommonJS and ES Modules Can’t Get Along 을 번역한 글입니다. 이미지나 설명이 필요한 부분은 추가로 덧붙여 놓았습니다.
바쁘다면 여기부터: '좋은 Dual Package(CJS와 ESM을 동시에 사용하는 패키지)를 만드는 방법은 뭘까?'
Node 14에서는 현재 두 가지 종류의 스크립트가 있다. 하나는 옛날 방식인 CommonJS(CJS) 스크립트고 다른 하나는 importexport를 사용하는 새로운 방식의 ESM 스크립트이다.
ESM과 CJS는 완전히 다른 동물이다. 표면적으로는 ESM은 CJS와 매우 비슷해보이지만 각각의 구현은 다르다. 하나는 꿀벌이고 다른 하나는 장수말벌이다.
말벌과 꿀벌. 이들 중 하나는 ESM, 다른 하나는 CJS인데 어느게 말벌이고 꿀벌인지 기억나진 않는다. 출처: wikimedia, wikimedia

ESM에서 CJS를 부르는 것도 그 반대도 가능하지만 쉽지는 않다.

이에 대한 몇 가지 규칙이 있는데 아래에 구체적으로 설명해봤다.
1.
ESM 스크립트를 require() 로 가져올 수 없다. 오직 import { foo } from 'foo' 처럼 import 로만 ESM 스크립트를 import할 수 있다.
2.
CJS 스크립트에서는 1번과 같은 *static import문을 사용할 수 없다.
3.
CJS 스크립트에서는 비동기적으로 dynamic import 를 사용하여 ESM을 사용할 수 있지만 동기적으로 require 를 사용하는 것과 비교했을 때 더 번거롭다.
sync require vs async dynamic import
4.
ESM 스크립트는 CJS 스크립트를 import 할 수 있지만 import _ from 'lodash' 처럼 오직 default import로만 가능하다. import { shuffle } from 'lodash' 와 같은 named import 문법으로는 CJS 스크립트를 import할 수 없다. 이때 CJS가 named exports 를 사용하는 경우 문제가 발생할 수 있다.
5.
ESM 스크립트에서 require() 를 사용해 CJS 스크립트를 가져올 수 있다. 심지어 CJS 스크립트에서 named exports 하는 경우도 가능하다. 하지만 일반적으로는 굳이 이렇게 할 필요가 없다. 이렇게 하려면 더 많은 라이브러리가 필요하고 최악의 경우 Webpack이나 Rollup같은 번들러에서는 require를 사용하는 ESM 스크립트가 어떻게 동작하는지 알지 못하기 때문이다.
6.
CJS는 기본값이므로 ESM을 선택해야 한다. 스크립트를 .js에서 .mjs로 이름을 바꿔 ESM 모듈을 선택할 수 있다. 또는 package.json에서 "type": "module" 을 설정할 수 있다. 반대로 스크립트 이름을 .js에서 .cjs로 변경하여 명시적으로 ESM을 선택하지 않을 수 있다(하위 디렉토리 각각의 package.json 파일에 { "type": "module" } 을 넣어 일부만 ESM으로 사용하는 것도 가능하다)
이런 규칙들을 전부 숙지하는 것은 고통스러운 일이다. 특히 Node 뉴비들을 포함해 많은 사용자들은 이런 규칙들을 이해하기 어렵다. (하지만 이 글에서 전부 설명할테니 두려워 말라!)
Node 생태계를 주시하고 있는 많은 사람들은 그런 규칙들이 리더십의 부재로부터 나온다고 생각했다. 심지어는 ESM에 대한 적의라고도 생각한다. 하지만 우리가 알게 될 것처럼 모든 규칙들에는 다 그만한 이유가 있으며 미래에도 이런 규칙들을 깨기란 어려울 것이다.
나는 다음과 같이 라이브러리 author가 따라야할 세 가지 가이드라인을 제시한다.
라이브러리를 CJS 버전으로 제공한다.
CJS named exports를 사용한다면 ESM wrapper로 감싸서 제공한다.
package.jsonexports map을 추가한다.
이렇게 한다면 모든 게 괜찮을 것이다.

Background: CJS와 ESM이 뭘까?

Node가 나온 이래로, Node module들은 CJS 모듈로 쓰여져 왔다. Node module들을 import하기 위해서는 require() 를 사용한다. 다른 사람들이 쓸 모듈을 구현할 때 우리는 "named exports"(module.exports.foo = 'bar')나 "default export"(module.exports = 'baz')를 사용해 exports를 정의할 수 있다.
named exports를 사용하는 CJS 예시를 보자. util.cjssum 이라는 이름으로 export를 한다.
// @filename: util.cjs module.exports.sum = (x, y) => x + y; // @filename: main.cjs const {sum} = require('./util.cjs'); console.log(sum(2, 4));
JavaScript
복사
이번에는 default export를 사용하는 CJS 예시가 있다. default export에는 이름이 없다. required()를 사용하는 모듈 쪽에서 이름을 정의한다.
// @filename: util.cjs module.exports = (x, y) => x + y; // @filename: main.cjs const whateverWeWant = require('./util.cjs'); console.log(whateverWeWant(2, 4));
JavaScript
복사
ESM 스크립트에서 import와 export는 언어의 일부이다. CJS와 마찬가지로 named exports와 default export에 대한 두 가지 구문이 있다.
named export를 사용하는 ESM 예시이다. util.mjs는 sum이라는 이름으로 export한다.
// @filename: util.mjs export const sum = (x, y) => x + y; // @filename: main.mjs import { sum } from './util.mjs' console.log(sum(2, 4));
JavaScript
복사
default export를 사용하는 ESM 예시이다. CJS처럼 default export는 이름이 없고 모듈을 사용하는 쪽에서 이름을 정의한다.
// @filename: util.mjs export default (x, y) => x + y; // @filename: main.mjs import whateverWeWant from './util.mjs' console.log(whateverWeWant(2, 4));
JavaScript
복사

ESM와 CJS 은 완전히 다른 동물이다.

CommonJS에서 require()는 동기적으로 동작한다. require() 는 promise를 리턴하거나 callback을 호출하지 않는다. require()는 디스크에서(또는 네트워크로부터) 데이터를 읽어온 후, 그 자체로 I/O를 하거나 사이드 이펙트가 있을 수 있는 스크립트를 즉시 실행시킨다. 그런 다음 어떤 값이건 module.exports에 할당된 값을 반환한다.
ESM에서는 비동기적인 단계들을 거쳐 모듈 로더가 실행된다. 첫 단계에서는 import한 스크립트를 실행시키지 않고 import와 export하는 구문이 있는지 스크립트를 파싱한다. 파싱 단계에서 ESM 로더는 즉시 named imports에서 오타를 감지하여 import한 스크립트를 실행시키지 않고 에러를 발생시킨다.
ESM 모듈 로더는 import한 스크립트를 비동기적으로 다운로드하고 파싱한 후 dependency들의 "module graph"를 빌드한다. import하는 게 아무것도 없는 스크립트를 찾을 때까지 이 과정을 반복한다. 마지막으로 스크립트는 실행을 허락하고 dependency 스크립트 또한 실행을 허락한다.
ESM - module graph 참고 이미지
ES 모듈 그래프에서 모든 "형제(siblings)" 스크립트(같은 레벨의 스크립트)는 병렬적으로 다운로드되지만, 실행은 로더 스펙에 정의된 순서대로 실행한다.

ESM이 많은 것을 바꾸기 때문에 기본값은 CJS다.

ESM은 Javascript에서 많은 것들을 바꿔놓는다. ESM 스크립트에서는 기본적으로 strict mode(use strict)를 사용하고 this는 전역 객체를 참조하지 않고, scope가 다르게 동작한다.
이때문에 브라우저조차도 <script> 태그에서 ESM을 기본값으로 사용하지 않는다. ESM mode를 선택하고 싶으면 script 태그에 type="module" 속성을 추가해야 한다.
기본값인 CJS를 ESM으로 바꾸면 이전 버전과의 호환성에서 큰 문제가 발생할 수 있다. Deno(라이언 달이 만든 Node의 대안)는 ESM을 기본값으로 설정했으나 그 결과로 ESM 생태계는 처음부터 다시 시작해야 했다.

Top-Level Await 때문에 CJS에서는 ESM 스크립트를 require()할 수 없다

CJS가 ESM 스크립트를 require()할 수 없는 가장 심플한 이유는 ESM이 top-level await을 할 수 있기 때문이다. 하지만 CJS 스크립트에서는 할 수 없다.
참고로 top-level await은 최상위 레벨에서 비동기 함수 밖에서도 await을 쓸 수 있게 해주는 기능이다.
여러 단계를 거치는 ESM loader는 ESM이 "*footgun" 없이 top-level-await을 구현하는 것을 가능하게 해준다.
* footgun: (프로그래밍 슬랭) 사용자가 자기 발에 총을 쏘게 만드는 기능 (from wiki)
참고 이미지
V8 팀의 블로그 포스트를 인용해보면 다음과 같다.
Rich Harris (Svelte, Rollup Owner)의 악명 높은 gist를 읽어봤을 지도 모른다. 그 글은 top-level await에 대한 많은 우려를 표하며 또한 Javascript 언어가 그 기능을 구현해서는 안된다고 말한다. 그가 우려하는 사안은 다음과 같았다. - top-level await은 실행을 blocking할 것이다. - top-level await은 리소스 fetching(가져오기)을 blocking할 것이다. - CommonJS 모듈에 대한 혼용 사례가 없다. 하지만 proposal의 스테이지 3 버전에서는 이런 이슈들을 직접적으로 해결한다: - 형제 스크립트가 실행할 수 있기 때문에 명확한 blocking이 없다. - 모듈 그래프의 실행 단계에서 top-level await이 발생한다. 이 시점에서 모든 리소스를 이미 가져오고 연결했다. 리소스 fetching을 blocking할 위험이 없다. - top-level await은 [ESM] 모듈로 제한된다. CommonJS 모듈에서는 이를 지원하지 않는다.
(Rich는 현재 top-level await 구현에 대해 동의했다)
CJS는 top-level await에 대해 지원하지 않기 때문에 ESM의 top-level await을 CJS로 트랜스파일링하는 것조차도 불가능하다. 이 코드를 어떻게 CJS로 변환하겠는가?
export const foo = await fetch('./data.json');
JavaScript
복사
ESM 스크립트의 대다수가 top-level await을 사용하지 않기 때문에 실망스럽긴 하다. 하지만 어떤 사람은 스레드에 "일부 기능을 사용하지 못할 것이라는 포괄적인 가정 하에 시스템을 설계하는 것은 (기술이) 발전할 수 있는 길이라고 생각하지 않는다"라고 말했다.
이 스레드에서 ESM 스크립트를 require()로 가져오는 방법에 대해 논의가 된 적이 있다(댓글을 달기 전 전체 스레드와 관련된 이슈들을 읽어보면 좋을 것이다. 읽다보면 top-level await만이 문제가 되는 게 아니라는 걸 알게된다. ESM을 동기적으로 불러오는 CJS를 비동기적으로 import하는 ESM을 동기적으로 require한다면 어떻게 될까? sync/async로 얼룩진 죽음의 얼룩말을 보게 될 것이다. top-level await은 관짝의 마지막 못이며 가장 설명하기 쉬운 문제일 뿐이다.)
대화를 살펴보면 앞으로도 require()로 ESM을 가져오는 건 불가능해보인다.

CJS는 ESM을 import() 할 수 있지만, 좋은 방법은 아니다

지금부터는 CJS에서 import를 사용해 ESM 스크립트를 가져오고 싶다면, 비동기적으로 dynamic import() 를 사용해야 한다.
(async () => { const {foo} = await import('./foo.mjs'); })();
JavaScript
복사
음.. 어떤 exports도 쓰지 않는다면 괜찮아 보인다. 만약 exports가 필요하다면 Promise를 export해야 하므로 (스크립트의) 사용자에게 엄청난 불편함을 줄 수 있다.
module.exports.foo = (async () => { const {foo} = await import('./foo.mjs'); return foo; })();
JavaScript
복사

CJS 스크립트가 비순차적으로 실행되지 않는 한 ESM은 named CJS exports를 import할 수 없다

이렇게 할 수는 있지만:
import _ from './lodash.cjs'
JavaScript
복사
이건 안된다.
import { shuffle } from './lodash.cjs'
JavaScript
복사
ESM의 named exports가 파싱 단계에서 평가되는 것과 달리 CJS 스크립트는 스크립트가 실행될 때 named exports를 평가하기 때문이다.
다행스럽게도 이건 가능하다. 이런 작업은 귀찮을 수도 있지만 완전히 가능하다. 그냥 CJS 스크립트를 아래처럼 import하면 된다.
import _ from './lodash.cjs'; const { shuffle } = _;
JavaScript
복사
이런 방식이 바람직하지 않은 건 아니다. 그리고 ESM을 인지하고 있는 CJS 라이브러리들은 ESM wrapper를 제공하기도 한다.
이제 완전히 괜찮다. 나는 그저.. 조금 더 나았으면 좋겠다.

비순차적으로 실행된다면 ESM에서 named exports CJS를 import할 수 있지만 훨씬 더 안좋을 수 있다

많은 사람들이 비순차적으로 ESM import 전에 CJS를 import하자고 제안했다. 이렇게 하면 CJS named exports를 ESM named exports와 동시에 평가할 수 있다( ESM은 파싱 단계부터 평가되고 CJS는 실행할 때 평가되니까).
하지만 그렇게 하면 새로운 문제가 발생한다.
import { liquor } from 'liquor'; import { beer } from 'beer';
JavaScript
복사
만약 liquor와 beer가 CJS라면, liquor를 ESM으로 바꾸는 것은 liquor, beer 에서 beer, liquor 와 같이 순서에 변화를 줄것이다. 그런데 만약 beer가 먼저 실행되는 liquor에 의존하고 있다면 더 혐오스러운 문제가 될 것이다.
몇 주전부터 대부분의 논쟁이 흐지부지 되었지만 비순차적인 실행은 아직 논의 중( 지금은 끝남)이다.

Dynamic Modules가 우릴 구했지만 star export는 오염되었다.

비순차적 실행이나 wrapper script를 요구하지 않는 Dynamic Modules라는 대안책이 있었다( 지금은 아카이빙 되었다).
ESM 스펙에서는 exporter가 정적으로 모든 named exports를 정의하지만 dynamic modules에서는 importer가 export names를 정의한다. ESM loader는 dynamic modules(CJS 스크립트)가 모든 named exports를 제공하고 만약 이를 만족시키지 못하면 나중에 에러를 발생시킬 수 있다고 신뢰한다.
불행하게도 dynamic modules는 자바스크립트 언어의 변경을 요구한다. 그리고 그런 변경은 TC39 위원회의 승인을 받아야 하는데, 그들은 승인해주지 않았다.
ESM 스크립트는 export * from './foo.cjs'와 같이 쓸 수 있다. foo가 exports하는 모든 것을 다시 export 한다는 의미이다. (이것을 "star export"라고 한다. )
불행하게도 dynamic module로 start export를 사용할 때는 로더가 무엇이 export되고 있는지 알 방법이 없다.
또한 dynamic module에서의 star exports는 spec 준수에 대한 이슈가 있다. 예를 들어 export * from 'omg'; export * from 'bbq';는 omg나 bbq 둘 다 같은 이름의 wtf을 export할 때 에러를 발생시킨다. importer가 export names를 지정할 수 있도록 허용하면 이 검증 단계를 사후 처리/무시해야 한다.
dynamic module의 지지자들은 dynamic module에서 star exports를 금지할 것을 제안했다. 하지만 TC39에서 이 제안을 거절했다. 한 TC39 멤버가 이 제안을 "syntax poisoning"이라고 비유할 만큼 star exports는 dynamic modules에 의해 "poisoned"되었다.
This poison star is very angry with you. Credit: seekpng
(내 생각을 말하자면, 우리는 이미 syntax poison 세상에 살고 있다. Node 14에서는 named imports가 poison되었고 dynamic module에서는 star exports가 오염되었다. named imports가 극도로 범용적이고 star exports는 상대적으로 더 적기 때문에 dynamic module이 생태계에서 syntax poison을 더 적게 일으킬 것이다)
dynamic module의 마지막 길이 아닐 수 있다. 한 제안은 모든 Node modules가 dynamic module을 사용해야 한다고 제안했다. 순수한 ESM 모듈이라도 말이다. Node에서 ESM 의 multi-phase loader를 금지시켜서라도.
놀랍게도 사용자에게는 보이지 않는 영향이다. 성능에는 약간의 악영향이 있을 수 있다. ESM의 multi-phase loader는 느린 네트워크 환경에서도 스크립트 로딩을 최적화할 수 있도록 설계되었기 때문이다.
하지만 이게 좋다고 말할 수는 없다. dynamic module의 github 이슈에서 최근 closed되었다. 왜냐하면 작년에 dynamic module에서 논의가 전혀되지 않았기 때문이다.

ESM은 require()를 사용할 수 있지만 굳이 그럴 필요가 없다

require()는 ESM 스크립트에서 기본적으로 사용할 수 있는 문법이 아니다. 하지만 쉽게 require를 사용할 수 있다.
import { createRequire } from 'module'; const require = createRequire(import.meta.url); const {foo} = require('./foo.cjs');
JavaScript
복사
이 접근법의 문제는 사실상 그리 도움되지 않는다는 것이다. 그냥 default import해서 destructuring하는 것보다 더 많은 코드를 요구한다.
import cjsModule from './foo.cjs'; const { foo } = cjsModule;
JavaScript
복사
게다가 Webpack이나 Rollup같은 번들러는 createRequire같은 패턴을 알지 못한다. 그렇다면 핵심을 뭘까?

좋은 Dual Package(CJS와 ESM을 동시에 사용하는 패키지)를 만드는 방법은 뭘까?

만약 라이브러리를 maintain하고 있다면 CJS와 ESM을 지원한다면 사용자의 기호에 따라 CJS와 ESM 둘 다 완벽하게 동작하는 dual package를 만들자. dual package를 만드는 가이드라인은 다음과 같다.
1.
라이브러리에 CJS 버전을 제공한다.
이것은 CJS 유저들에게 편리함을 준다. 그리고 라이브러리가 Node의 예전 버전에서도 잘 동작한다는 것을 보장한다.
만약 라이브러리가 오직 default export만을 제공한다면 이미 할 일은 끝났다. 어떤 것도 할 필요가 없고 사용자가 import mylibrary from 'mylibrary' 를 사용하기만 하면 된다.
만약 사용자가 named exports를 쓸 것 같다면 다음 가이드라인을 계속 읽으면 된다.
2. CJS named exports를 감싸는 ESM wrapper를 제공한다.
(CJS 라이브러리를 ESM wrapper로 감싸는 것은 쉽지만 그 반대는 어렵다는 것을 알아두었으면 좋겠다)
import cjsModule from '../index.js'; export const foo = cjsModule.foo;
JavaScript
복사
ESM wrapper를 esm 하위 디렉토리에 넣고 그와 함께 package.json에 {"type": "module"} 를 추가한다. (또는 wrapper 파일을 .mjs로 써도 무방하다. Node 14에서는 완벽히 작동하나 어떤 라이브러리는 .mjs 파일과 완벽히 상호작용하지 못한다. 그래서 나는 하위 디렉토리에 넣는 것을 선호한다.)
그리고 double transpiling을 피해야 한다. 만약 타입스크립트를 자바스크립트로 트랜스파일링한다면 CJS와 ESM 두 스크립트 모두 만들 수 있다. 하지만 이 방법을 쓰면 사용자가 실수로 ESM 스크립트를 import 하고 별개로 CJS를 require()할 위험을 야기할 수 있다.
Node는 CJS와 ESM이 같은 파일이라는 것을 인식하지 못한다. 그래서 당신의 코드는 두 번 실행될 것이고 이것은 이상한 버그들을 만들어낼 수 있다.
3. package.json에 exports map을 추가한다.
"exports": { "require": "./index.js", "import": "./esm/wrapper.js" }
JavaScript
복사
exports map을 추가하는 것은 항상 breaking change를 만든다. 기본적으로 유저는 원하는 스크립트를 require()하고 내 패키지에 접근할 수 있다. 만약 내가 export를 의도하지 않은 내부 파일이라도 말이다 exports map은 사용자가 내가 의도적으로 노출한 entry point만 require/import를 할 수 있게 만들기 때문에 기존에 내부 파일을 썼던 사용자라면 에러가 발생할 수 있다.
breaking change를 빼면 아주 좋다.
만약 사용자가 내가 명시하지 않은 다른 파일에도 import/require()를 할 수 있게 만들려면 그 파일들 역시 entry point로 추가해줘야 한다. 자세한 것은 ESM에 대한 Node 문서를 읽어보길 바란다.
export map 타겟에 꼭 파일 확장자를 써줘야 한다. index.js는 index나 "./build"같은 디렉토리가 아니다.

위의 가이드를 따른다면 당신의 사용자는 무사할 것입니다! 모든 게 괜찮을 거에요 :)