모듈 - ESM/CJS 상호운용성
모듈 - ESM/CJS 상호운용성
ES 모듈과 CommonJS는 서로 다른 세계예요. 코드의 문법도 다르고, 모듈이 서로를 바라보는 방식도 달라요. 그런데 실제 Node.js 환경에서는 두 세계가 공존해야 했죠. 이 문서는 TypeScript가 이 두 세계를 어떻게 이어주는지, 그리고 트랜스파일된 모듈에서 진짜 ESM으로 옮겨가는 길이 왜 매끄럽지 않은지 차근차근 풀어내는 글입니다.
출처: TypeScript 공식문서
본문
2015년, 당신은 ESM을 CJS로 바꿔주는 트랜스파일러를 만들고 있다고 생각해 봐요. 이걸 어떻게 하는지에 대한 명세는 없어요. 다만 ES 모듈끼리 어떻게 상호작용해야 하는지에 대한 명세, CommonJS 모듈끼리 어떻게 상호작용하는지에 대한 지식, 그리고 뭔가를 알아내는 감각만 있을 뿐이죠. 먼저 내보내기(export)를 하는 ES 모듈 하나를 봐 볼게요.
export const A = {};
export const B = {};
export default "Hello, world!";
이걸 어떻게 CommonJS 모듈로 바꿀 수 있을까요? default 내보내기는 사실 특별한 문법을 쓴 이름 있는 내보내기(named export)에 불과하니까, 선택지는 하나뿐인 것처럼 보여요.
exports.A = {};
exports.B = {};
exports.default = "Hello, world!";
깔끔한 대응이죠. 이번에는 가져오기(import) 쪽도 비슷하게 구현해 볼게요.
import hello, { A, B } from "./module";
console.log(hello, A, B);
// transpiles to:
const module_1 = require("./module");
console.log(module_1.default, module_1.A, module_1.B);
지금까지는 CJS 세상과 ESM 세상이 일대일로 딱 맞아떨어져요. 위의 동치 관계를 한 단계 더 확장하면 이런 경우도 성립해요.
import * as mod from "./module";
console.log(mod.default, mod.A, mod.B);
// transpiles to:
const mod = require("./module");
console.log(mod.default, mod.A, mod.B);
여기서 한 가지 눈치채셨을 거예요. 이 방식으로는 exports가 함수, 클래스, 또는 원시값(primitive)으로 지정된 결과를 만들어 내는 ESM 내보내기를 작성할 방법이 없어요.
// @Filename: exports-function.js
module.exports = function hello() {
console.log("Hello, world!");
};
그런데 기존 CommonJS 모듈은 이런 형태를 자주 취해요. 이 모듈을 ESM 가져오기로, 그것도 우리 트랜스파일러를 거쳐서 접근하려면 어떻게 해야 할까요? 아까 네임스페이스 가져오기(import *)가 그냥 require 호출로 트랜스파일된다고 했으니까, 이런 입력을 지원해 볼 수 있어요.
import * as hello from "./exports-function";
hello();
// transpiles to:
const hello = require("./exports-function");
hello();
런타임에서는 잘 동작해요. 그런데 여기서 문제가 하나 생겨요. 자바스크립트 명세에 따르면 네임스페이스 가져오기는 항상 Module Namespace Object로 평가되거든요. 그러니까 모듈의 내보내기들을 멤버로 갖는 객체로 평가돼요. 이 경우 require는 함수 hello를 반환하지만, import *는 함수를 반환할 수가 없어요. 우리가 전제했던 대응 관계가 유효하지 않은 셈이에요.
여기서 한 발 물러서서 목표가 무엇인지 짚고 넘어가는 게 좋겠어요. 모듈이 ES2015 명세에 포함되자마자, 트랜스파일러들이 ESM을 CJS로 다운레벨(downlevel)하는 기능을 지원하기 시작했어요. 덕분에 런타임이 그 문법을 지원하기 훨씬 전부터 사용자들이 새 문법을 쓸 수 있었죠. 심지어 ESM으로 코드를 작성하는 것이 새 프로젝트를 '미래 대비'하는 좋은 방법이라는 인식까지 생겼어요. 이게 사실이 되려면, 트랜스파일러가 만든 CJS 출력을 실행하던 것에서, 런타임이 ESM을 지원하게 된 뒤에는 ESM 입력을 그대로 실행하는 것으로 매끄럽게 이어지는 경로가 필요했어요. 목표는 어디까지나 이거였죠. ESM을 CJS로 다운레벨하는 방식 중에서, 트랜스파일된 출력을 미래의 런타임에서 진짜 ESM 입력으로 바꿔치기해도 관찰 가능한 동작 변화가 없게 만드는 방식. 그런 방식을 찾는 거였어요.
명세를 따르기만 하면, 트랜스파일러들이 트랜스파일된 CommonJS 출력의 의미(semantics)를 ESM 입력의 명시된 의미와 일치시켜 주는 변환 집합을 찾기는 쉬웠어요. (아래 다이어그램의 화살표는 가져오기를 나타내요.)
하지만 CommonJS 모듈(ESM을 CJS로 트랜스파일한 게 아니라 CommonJS로 직접 작성한 모듈)은 이미 Node.js 생태계에 깊이 자리 잡고 있었어요. 그래서 ESM으로 작성되고 CJS로 트랜스파일된 모듈이, CommonJS로 작성된 모듈을 '가져오기' 시작하는 것은 피할 수 없는 일이었죠. 그런데 이 상호운용(interop)의 동작은 ES2015에서 규정되지도 않았고, 어떤 실제 런타임에도 아직 존재하지 않았어요.
트랜스파일러 작성자가 아무것도 하지 않아도, 트랜스파일 코드에서 내보낸 require 호출과 기존 CJS 모듈에 정의된 exports 사이의 기존 의미론에서 어떤 동작이 자연스럽게 드러났어요. 그리고 사용자들이 런타임이 ESM을 지원하게 된 뒤 트랜스파일된 ESM에서 진짜 ESM으로 매끄럽게 전환하게 하려면, 그 동작이 런타임이 택한 동작과 일치해야 했어요.
런타임들이 어떤 상호운용 동작을 지원할지 추측해야 했던 건, ESM이 '진짜 CJS' 모듈을 가져오는 경우만이 아니었어요. ESM이 'CJS에서 트랜스파일된 ESM'을 CJS와 구별해서 인식할 수 있을지, CJS가 ES 모듈을 require할 수 있을지도 정해지지 않았어요. ESM 가져오기가 CJS require 호출과 같은 모듈 해석 알고리즘을 쓸지조차 알 수 없었죠. 이러한 모든 변수를 정확히 맞혀야만, 트랜스파일러 사용자들에게 네이티브 ESM으로 가는 매끄러운 이주 경로를 줄 수 있었어요.
allowSyntheticDefaultImports와 esModuleInterop
아까 보았던 명세 준수 문제로 돌아가 볼게요. import *가 require로 트랜스파일되는 그 상황 말이에요.
// Invalid according to the spec:
import * as hello from "./exports-function";
hello();
// but the transpilation works:
const hello = require("./exports-function");
hello();
TypeScript가 ES 모듈을 작성하고 트랜스파일하는 기능을 처음 지원했을 때, 컴파일러는 exports가 네임스페이스 같은 객체가 아닌 모듈을 네임스페이스 가져오기할 때 오류를 냄으로써 이 문제를 해결했어요.
import * as hello from "./exports-function";
// TS2497 ^^^^^^^^^^^^^^^^^^^^
// External module '"./exports-function"' resolves to a non-module entity
// and cannot be imported using this construct.
유일한 우회 방법은 사용자들이 CommonJS require를 나타내는 옛 TypeScript 가져오기 문법으로 돌아가는 것이었어요.
import hello = require("./exports-function");
사용자들을 ESM이 아닌 문법으로 되돌리게 한 것은, 사실상 이런 고백이나 다름없었어요. ""./exports-function" 같은 CJS 모듈을 미래에 ESM 가져오기로 접근할 수 있을지, 어떻게 할 수 있을지는 모르겠다. 다만 import *로는 안 된다는 것, 우리가 쓰는 트랜스파일 방식에서 런타임은 되지만 말이다." 이는 이 파일을 수정 없이 진짜 ESM으로 이주할 수 있게 하겠다는 목표를 충족하지 못해요. 물론 import *가 함수에 연결되게 하는 대안도 목표를 충족하지 못하죠. 이 동작은 오늘날에도 allowSyntheticDefaultImports와 esModuleInterop가 꺼져 있을 때 TypeScript가 보이는 동작이에요.
사실 이 설명은 약간 단순화된 것이에요. TypeScript는 이 오류로 명세 준수 문제를 완전히 피하지는 못했어요. 함수 선언이 네임스페이스 선언과 병합(merge)되기만 하면, 그 네임스페이스가 비어 있더라도 함수의 네임스페이스 가져오기를 허용하고 호출 시그니처를 유지했거든요. 그래서 맨 함수 하나만 내보내는 모듈은 '비모듈 엔터티(non-module entity)'로 인식됐어요.
declare function $(selector: string): any;
export = $; // Cannot `import *` this 👍
그런데 의미가 없어 보이는 변경 하나가, 이 무효한 가져오기가 오류 없이 타입 체크를 통과하게 만들었어요.
declare namespace $ {}
declare function $(selector: string): any;
export = $; // Allowed to `import *` this and call it 😱
한편 다른 트랜스파일러들은 같은 문제를 푸는 방법을 하나씩 찾아내고 있었어요. 그 사고 과정은 대략 이랬어요.
함수나 원시값을 내보내는 CJS 모듈을 가져오려면, 분명 기본 가져오기(default import)를 써야 해. 네임스페이스 가져오기는 불법이고, 이름 있는 가져오기도 여기선 말이 안 되고.
이 말은 아마도, ESM/CJS 상호운용을 구현하는 런타임들은 CJS 모듈의 기본 가져오기를 항상 exports 전체에 직접 연결하게 할 거라는 뜻이야. exports가 함수나 원시값일 때만 그렇게 하는 게 아니라.
그렇다면 진짜 CJS 모듈의 기본 가져오기는 require 호출과 똑같이 동작해야 해. 그런데 우리가 트랜스파일한 CJS 모듈과 진짜 CJS 모듈을 구별할 방법이 필요해. 그래야 export default "hello"를 여전히 exports.default = "hello"로 트랜스파일하고, 그 모듈의 기본 가져오기가 exports.default에 연결되게 할 수 있으니까. 기본적으로, 우리가 트랜스파일한 모듈의 기본 가져오기는 한 방식으로 동작해야 해(ESM에서 ESM으로 가져오는 것을 흉내내기 위해). 반면 그 외의 기존 CJS 모듈의 기본 가져오기는 다른 방식으로 동작해야 하죠(우리가 생각하기에 ESM에서 CJS로 가져오는 방식이 그럴 것 같다고 흉내내기 위해).
ES 모듈을 CJS로 트랜스파일할 때, 출력에 특별한 필드를 하나 추가해 볼게요.
exports.A = {};
exports.B = {};
exports.default = "Hello, world!";
// Extra special flag!
exports.__esModule = true;
이 필드는 기본 가져오기를 트랜스파일할 때 확인할 수 있어요.
// import hello from "./module";
const _mod = require("./module");
const hello = _mod.__esModule ? _mod.default : _mod;
__esModule 플래그는 처음 Traceur에 등장했고, 곧이어 Babel, SystemJS, Webpack에도 들어갔어요. TypeScript는 1.8에서 allowSyntheticDefaultImports를 추가해서, export default 선언이 없는 모듈 타입의 경우 타입 체커가 기본 가져오기를 exports.default가 아니라 exports에 직접 연결하게 했어요. 이 플래그는 가져오기나 내보내기가 어떻게 내보내지는지는 바꾸지 않았지만, 기본 가져오기가 다른 트랜스파일러들이 다루는 방식과 같아지도록 해줬어요. 즉 import *가 오류였던 '비모듈 엔터티'를 기본 가져오기로 해석될 수 있게 한 거죠.
// Error:
import * as hello from "./exports-function";
// Old workaround:
import hello = require("./exports-function");
// New way, with `allowSyntheticDefaultImports`:
import hello from "./exports-function";
이 정도면 Babel이나 Webpack 사용자들이 이미 그 시스템에서 동작하던 코드를 TypeScript가 불평하지 않게 작성하기에 보통 충분했어요. 하지만 이건 부분적인 해결책에 불과했고, 몇 가지 문제는 남겨두고 있었어요.
Babel 등은 대상 모듈에서 __esModule 프로퍼티가 발견되는지에 따라 기본 가져오기 동작을 달리했어요. 그런데 allowSyntheticDefaultImports는 대상 모듈 타입에서 기본 내보내기가 발견되지 않을 때만 폴백 동작을 켜줬죠. 이 때문에 대상 모듈에 __esModule 플래그는 있는데 기본 내보내기가 없다면 불일치가 생겼어요. 트랜스파일러와 번들러는 그런 모듈의 기본 가져오기를 여전히 exports.default에 연결하고, 그 값은 undefined가 돼요. 진짜 ESM 가져오기는 연결할 수 없으면 오류를 일으키니까, TypeScript에서도 이상적으로는 오류가 나야 하는 상황이에요. 하지만 allowSyntheticDefaultImports가 켜져 있으면 TypeScript는 그런 가져오기의 기본 가져오기가 exports 객체 전체에 연결된다고 생각해서, 이름 있는 내보내기들을 그 객체의 프로퍼티로 접근할 수 있게 해줘요.
allowSyntheticDefaultImports는 네임스페이스 가져오기가 어떻게 타이핑되는지를 바꾸지 않아서, 둘 다 쓸 수 있고 같은 타입을 갖는 이상한 불일치가 생겼어요.
// @Filename: exportEqualsObject.d.ts
declare const obj: object;
export = obj;
// @Filename: main.ts
import objDefault from "./exportEqualsObject";
import * as objNamespace from "./exportEqualsObject";
// This should be true at runtime, but TypeScript gives an error:
objNamespace.default === objDefault;
// ^^^^^^^ Property 'default' does not exist on type 'typeof import("./exportEqualsObject")'.
무엇보다 중요한 건, allowSyntheticDefaultImports가 tsc가 내보내는 자바스크립트를 바꾸지 않았다는 점이에요. 그래서 이 플래그는 코드가 Babel이나 Webpack 같은 다른 도구에 들어갈 때만 더 정확한 체크를 가능하게 해줬죠. 그런데 tsc로 --module commonjs를 내보내고 Node.js에서 실행하는 사용자에게는 실제 위험이 됐어요. import *에서 오류를 만났을 때, allowSyntheticDefaultImports를 켜면 고쳐질 것처럼 보일 수 있어요. 하지만 실제로는 빌드 타임 오류만 조용히 만들고, Node에서 크래시날 코드를 내보내는 것이었죠.
TypeScript는 2.7에서 esModuleInterop 플래그를 도입했어요. 이 플래그는 가져오기 타입 체킹을 다듬어서 TypeScript의 분석과 기존 트랜스파일러·번들러의 상호운용 동작 사이에 남아 있던 불일치를 해결했어요. 그리고 결정적으로, 트랜스파일러들이 몇 년 전부터 채택했던 것과 똑같은 __esModule 조건부 CommonJS 내보내기 방식을 채택했죠. (import *를 위한 또 다른 새 내보내기 헬퍼는 결과가 항상 객체가 되도록, 호출 시그니처를 제거하면서 보장해서, 앞서 말한 '비모듈 엔터티로 해석된다'는 오류가 완전히 피하지 못했던 명세 준수 문제를 깨끗하게 해결했어요.) 마침내 새 플래그가 켜지면, TypeScript의 타입 체킹, TypeScript의 내보내기, 그리고 나머지 트랜스파일·번들링 생태계가 하나의 CJS/ESM 상호운용 방식으로 합의하게 됐어요. 그것도 명세에 부합하는, 그리고 어쩌면 Node가 채택할 법한 방식으로요.
Node.js에서의 상호운용
Node.js는 v12에서 플래그 없이 ES 모듈 지원을 출시했어요. 몇 년 전부터 번들러와 트랜스파일러가 해왔던 것처럼, Node.js는 CommonJS 모듈에 그 exports 객체의 '합성 기본 내보내기(synthetic default export)'를 부여해서, ESM에서 기본 가져오기로 모듈 내용 전체에 접근할 수 있게 했어요.
// @Filename: export.cjs
module.exports = { hello: "world" };
// @Filename: import.mjs
import greeting from "./export.cjs";
greeting.hello; // "world"
매끄러운 이주를 위한 승리 하나죠. 안타깝게도 유사점은 대부분 여기서 끝나요.
__esModule 감지 없음 ("이중 기본값" 문제)
Node.js는 __esModule 마커를 존중해서 기본 가져오기 동작을 달리하지 못했어요. 그래서 '기본 내보내기'를 가진 트랜스파일된 모듈은, 다른 트랜스파일된 모듈이 '가져올' 때와 Node.js 안의 진짜 ES 모듈이 가져올 때 서로 다르게 동작해요.
// @Filename: node_modules/dependency/index.js
exports.__esModule = true;
exports.default = function doSomething() { /*...*/ }
// @Filename: transpile-vs-run-directly.{js/mjs}
import doSomething from "dependency";
// Works after transpilation, but not a function in Node.js ESM:
doSomething();
// Doesn't exist after transpilation, but works in Node.js ESM:
doSomething.default();
트랜스파일된 기본 가져오기는 대상 모듈에 __esModule 플래그가 없을 때만 합성 기본 내보내기를 만들지만, Node.js는 항상 기본 내보내기를 합성해요. 그 결과 트랜스파일된 모듈에는 '이중 기본값(double default)'이 생기죠.
신뢰할 수 없는 이름 있는 내보내기
Node.js는 CommonJS 모듈의 exports 객체를 기본 가져오기로 제공할 뿐 아니라, exports의 프로퍼티들을 찾아서 이름 있는 가져오기로 제공하려고 시도해요. 이 동작은 잘 동작할 때 번들러와 트랜스파일러와 일치해요. 그런데 Node.js는 코드가 실행되기 전에 구문 분석을 통해 이름 있는 내보내기를 합성하는 반면, 트랜스파일된 모듈은 런타임에 이름 있는 가져오기를 해석해요. 그 결과, 트랜스파일된 모듈에서는 동작하던 CJS 모듈 가져오기가 Node.js에서는 동작하지 않을 수 있어요.
// @Filename: named-exports.cjs
exports.hello = "world";
exports["worl" + "d"] = "hello";
// @Filename: transpile-vs-run-directly.{js/mjs}
import { hello, world } from "./named-exports.cjs";
// `hello` works, but `world` is missing in Node.js 💥
import mod from "./named-exports.cjs";
mod.world;
// Accessing properties from the default always works ✅
Node.js v22 이전에는 진짜 ES 모듈을 require할 수 없음
진짜 CommonJS 모듈은 트랜스파일된 ESM-to-CJS 모듈을 require할 수 있어요. 런타임에서 둘 다 CommonJS이기 때문이에요. 하지만 v22.12.0보다 오래된 Node.js 버전에서는 require가 ES 모듈로 해석되면 크래시가 나요. 이는 배포된 라이브러리가, CommonJS(진짜든 트랜스파일된 것이든) 소비자를 깨뜨리지 않고는 트랜스파일된 모듈에서 진짜 ESM으로 이주할 수 없다는 뜻이에요.
// @Filename: node_modules/dependency/index.js
export function doSomething() { /* ... */ }
// @Filename: dependent.js
import { doSomething } from "dependency";
// ✅ Works if dependent and dependency are both transpiled
// ✅ Works if dependent and dependency are both true ESM
// ✅ Works if dependent is true ESM and dependency is transpiled
// 💥 Crashes if dependent is transpiled and dependency is true ESM
서로 다른 모듈 해석 알고리즘
Node.js는 ESM 가져오기를 해석하기 위한 새 모듈 해석 알고리즘을 도입했는데, 이는 오랫동안 require 호출을 해석하던 알고리즘과 상당히 달랐어요. CJS와 ES 모듈 사이의 상호운용과 직접 관련은 없지만, 이 차이 역시 트랜스파일된 모듈에서 진짜 ESM으로의 매끄러운 이주가 어려운 또 하나의 이유였어요.
// @Filename: add.js
export function add(a, b) {
return a + b;
}
// @Filename: math.js
export * from "./add";
// ^^^^^^^
// Works when transpiled to CJS,
// but would have to be "./add.js"
// in Node.js ESM.
결론
분명히 트랜스파일된 모듈에서 ESM으로의 매끄러운 이주는, 적어도 Node.js에서는 불가능해요. 그렇다면 우리는 어디에 서 있는 걸까요?
올바른 module 컴파일러 옵션을 설정하는 것이 중요하다
상호운용 규칙은 호스트마다 다르기 때문에, TypeScript는 보고 있는 각 파일이 어떤 종류의 모듈을 나타내고 어떤 규칙 집합을 적용해야 하는지 이해하지 못하면 올바른 체크 동작을 제공할 수 없어요. 이것이 바로 module 컴파일러 옵션의 목적이에요. (특히 Node.js에서 실행될 코드는 번들러가 처리할 코드보다 더 엄격한 규칙을 받아요. module이 node16, node18, nodenext로 설정되지 않으면 컴파일러 출력은 Node.js 호환성으로 검사되지 않아요.)
CommonJS 코드가 있는 애플리케이션은 항상 esModuleInterop을 켜야 한다
tsc로 자바스크립트 파일을 내보내는 TypeScript 애플리케이션(다른 사람이 소비할 라이브러리와는 반대로)에서는 esModuleInterop을 켜도 큰 결과 차이가 없어요. 특정 종류의 모듈에 대한 가져오기 작성 방식은 달라지겠지만, TypeScript의 체킹과 내보내기가 서로 맞물려 있기 때문에 오류 없는 코드는 어느 모드에서든 안전하게 실행돼야 해요. 이 경우 esModuleInterop을 끈 채 두는 단점은, ECMAScript 명세를 명백히 위반하는 의미를 가진 자바스크립트 코드를 작성할 수 있게 한다는 점이에요. 네임스페이스 가져오기에 대한 직관을 혼란스럽게 만들고, 미래에 ES 모듈로 실행하도록 이주하는 것을 더 어렵게 만들죠.
반면 제3자 트랜스파일러나 번들러가 처리하는 애플리케이션에서는 esModuleInterop을 켜는 것이 더 중요해요. 주요 번들러와 트랜스파일러는 모두 esModuleInterop과 비슷한 내보내기 전략을 쓰기 때문에, TypeScript도 그에 맞춰 체킹을 조정해야 해요. (컴파일러는 항상 tsc가 내보낼 자바스크립트 파일에서 무슨 일이 일어날지를 추론해요. 그래서 다른 도구를 tsc 대신 쓰더라도, 내보내기에 영향을 주는 컴파일러 옵션은 그 도구의 출력에 최대한 가깝게 맞춰 설정해야 해요.)
esModuleInterop 없이 allowSyntheticDefaultImports만 쓰는 것은 피해야 해요. tsc가 내보내는 코드는 바꾸지 않으면서 컴파일러의 체킹 동작만 바꾸기 때문에, 잠재적으로 안전하지 않은 자바스크립트가 내보내질 수 있어요. 게다가 그것이 도입하는 체킹 변경은 esModuleInterop이 도입한 것의 불완전한 버전이에요. 내보내기에 tsc를 쓰지 않더라도, allowSyntheticDefaultImports를 켜는 것보다 esModuleInterop을 켜는 것이 더 좋아요.
어떤 사람들은 esModuleInterop이 켜져 있을 때 tsc의 자바스크립트 출력에 포함되는 __importDefault와 __importStar 헬퍼 함수가 마음에 안 들 수 있어요. 이유는 디스크상의 출력 크기가 약간 늘어나거나, 그 헬퍼들이 쓰는 상호운용 알고리즘이 __esModule을 확인함으로써 Node.js의 상호운용 동작을 잘못 나타내는 것처럼 보이기 때문이에요. 그 결과 앞서 논의한 위험들이 생기죠. 이 두 가지 반대 의견은, esModuleInterop을 껐을 때 나타나는 결함 있는 체킹 동작을 받아들이지 않고도 적어도 부분적으로 해결할 수 있어요. 첫째, importHelpers 컴파일러 옵션을 쓰면 헬퍼 함수를 각 파일에 인라인하는 대신 tslib에서 가져올 수 있어요. 둘째 반대 의견을 다루기 위해 마지막 예시를 하나 볼게요.
// @Filename: node_modules/transpiled-dependency/index.js
exports.__esModule = true;
exports.default = function doSomething() { /* ... */ };
exports.something = "something";
// @Filename: node_modules/true-cjs-dependency/index.js
module.exports = function doSomethingElse() { /* ... */ };
// @Filename: src/sayHello.ts
export default function sayHello() { /* ... */ }
export const hello = "hello";
// @Filename: src/main.ts
import doSomething from "transpiled-dependency";
import doSomethingElse from "true-cjs-dependency";
import sayHello from "./sayHello.js";
src를 Node.js에서 쓰기 위해 CommonJS로 컴파일한다고 가정해 볼게요. allowSyntheticDefaultImports나 esModuleInterop이 없으면, "true-cjs-dependency"에서 doSomethingElse를 가져오는 것은 오류이고 나머지는 오류가 아니에요. 컴파일러 옵션을 바꾸지 않고 오류를 고치려면, 그 가져오기를 import doSomethingElse = require("true-cjs-dependency")로 바꾸면 돼요. 하지만 그 모듈의 타입(보이지 않는)이 어떻게 작성되어 있는지에 따라, 네임스페이스 가져오기를 작성하고 호출할 수도 있어요. 그건 언어 수준의 명세 위반이에요. esModuleInterop이 켜져 있으면 보이는 가져오기 중 오류인 것은 없어요(모두 호출 가능하죠). 다만 무효한 네임스페이스 가져오기는 잡힐 거예요.
src를 Node.js에서 진짜 ESM으로 이주하기로 마음먹었다면(가령 루트 package.json에 "type": "module"을 추가한다면) 무슨 일이 바뀔까요? 첫 번째 가져오기, "transpiled-dependency"의 doSomething은 더 이상 호출할 수 없게 돼요. '이중 기본값' 문제를 보여주는 거죠. doSomething() 대신 doSomething.default()를 호출해야 해요. (TypeScript는 --module node16—nodenext 아래에서 이를 이해하고 잡아줘요.) 그런데 눈에 띄는 점은, CommonJS로 컴파일할 때 동작하려면 esModuleInterop이 필요했던 두 번째 가져오기 doSomethingElse가 진짜 ESM에서는 잘 동작한다는 거예요.
여기서 불평할 게 있다면, 그것은 esModuleInterop이 두 번째 가져오기에 하는 일이 아니에요. 그것이 하는 변경, 기본 가져오기를 허용하고 호출 가능한 네임스페이스 가져오기를 막는 변경은, 모두 Node.js의 실제 ESM/CJS 상호운용 전략과 정확히 일치하고, 진짜 ESM으로의 이주를 더 쉽게 만들었어요. 문제가 있다면 그것은 esModuleInterop이 첫 번째 가져오기에 대해 매끄러운 이주 경로를 주지 못한다는 점이에요. 하지만 이 문제는 esModuleInterop을 켬으로써 생긴 것이 아니에요. 첫 번째 가져오기는 그것에 전혀 영향을 받지 않았으니까요. 안타깝게도 이 문제는 main.ts와 sayHello.ts 사이의 의미론적 계약을 깨지 않고는 해결할 수 없어요. sayHello.ts의 CommonJS 출력이 transpiled-dependency/index.js와 구조적으로 동일하게 보이기 때문이에요. 만약 esModuleInterop이 doSomething의 트랜스파일된 가져오기 방식을 Node.js ESM에서 동작할 방식과 동일하게 바꾼다면, sayHello 가져오기의 동작도 같은 방식으로 바꿔서 입력 코드가 ESM 의미를 위반하게 만들 거예요. 그래서 여전히 src 디렉터리를 수정 없이 ESM으로 이주하지 못하게 하죠.
우리가 살펴봤듯이 트랜스파일된 모듈에서 진짜 ESM으로 가는 매끄러운 이주 경로는 없어요. 하지만 esModuleInterop은 올바른 방향으로 가는 한 걸음이에요. 모듈 문법 변환과 가져오기 헬퍼 함수 포함을 최소화하고 싶은 사람들에게는, esModuleInterop을 끄는 것보다 verbatimModuleSyntax를 켜는 것이 더 나은 선택이에요. verbatimModuleSyntax는 CommonJS를 내보내는 파일에서 import mod = require("mod")와 export = ns 문법을 쓰도록 강제해서, 우리가 논의한 모든 종류의 가져오기 모호성을 피하게 해줘요. 그 대가로 진짜 ESM으로의 이주 용이성은 희생되죠.
라이브러리 코드는 특별한 고려가 필요하다
CommonJS로 배포되는 라이브러리는 기본 내보내기를 피해야 해요. 트랜스파일된 내보내기에 접근하는 방식이 도구와 런타임마다 달라서, 그중 일부는 사용자에게 혼란스러워 보일 수 있으니까요. tsc가 CommonJS로 트랜스파일한 기본 내보내기는 Node.js에서 기본 가져오기의 기본 프로퍼티로 접근할 수 있어요.
import pkg from "pkg";
pkg.default();
대부분의 번들러나 트랜스파일된 ESM에서는 기본 가져오기 자체로 접근할 수 있고요.
import pkg from "pkg";
pkg();
그리고 순수 CommonJS에서는 require 호출의 기본 프로퍼티로 접근할 수 있어요.
const pkg = require("pkg");
pkg.default();
사용자들이 기본 가져오기의 .default 프로퍼티에 접근해야 한다면 모듈이 잘못 설정된 냄새를 맡을 거예요. 그리고 Node.js와 번들러 둘 다에서 실행될 코드를 쓰려는 사람들은 막힐 수도 있죠. 일부 제3자 TypeScript 트랜스파일러는 이 차이를 완화하기 위해 기본 내보내기가 내보내지는 방식을 바꾸는 옵션을 제공해요. 하지만 그들은 자체 선언(.d.ts) 파일을 만들지 않아서, 런타임 동작과 타입 체킹 사이에 불일치가 생기고 사용자들을 더 혼란스럽고 답답하게 만들어요. CommonJS로 배포해야 하는 라이브러리는 기본 내보내기 대신, 단일 주요 내보내기가 있으면 export =를 쓰고 여러 내보내기가 있으면 이름 있는 내보내기를 써야 해요.
- export default function doSomething() { /* ... */ }
+ export = function doSomething() { /* ... */ }
선언 파일을 배포하는 라이브러리는, 자신이 작성한 타입이 다양한 컴파일러 옵션 아래에서 오류 없이 컴파일되도록 각별히 신경 써야 해요. 예를 들어 어떤 인터페이스가 다른 인터페이스를 상속하도록 작성하는 방식에 따라 strictNullChecks가 꺼져 있을 때만 컴파일이 성공할 수도 있어요. 만약 라이브러리가 그런 타입을 배포한다면 모든 사용자들이 strictNullChecks도 꺼야 하죠. esModuleInterop은 타입 선언에 비슷하게 '전염성 있는' 기본 가져오기가 들어갈 수 있게 해줘요.
// @Filename: /node_modules/dependency/index.d.ts
import express from "express";
declare function doSomething(req: express.Request): any;
export = doSomething;
이 기본 가져오기가 esModuleInterop이 켜져 있을 때만 동작하고, 그 옵션이 없는 사용자가 이 파일을 참조하면 오류를 일으킨다고 가정해 볼게요. 사용자는 어차피 esModuleInterop을 켜는 게 좋겠지만, 라이브러리가 자신의 설정을 이렇게 전염성 있게 만드는 것은 일반적으로 좋지 않은 매너로 여겨져요. 라이브러리가 이런 선언 파일을 배포하는 것이 훨씬 나아요.
import express = require("express");
// ...
이런 예들은 '라이브러리는 esModuleInterop을 켜면 안 된다'는 통념으로 이어졌어요. 이 조언은 합리적인 출발점이지만, 우리는 esModuleInterop을 켤 때 네임스페이스 가져오기의 타입이 바뀌면서 잠재적으로 오류를 도입하는 예를 살펴봤어요. 그래서 라이브러리가 esModuleInterop을 켜든 끄든, 자신의 선택을 전염성 있게 만드는 문법을 쓸 위험은 있는 거예요.
최대한의 호환성을 보장하고자 한 발 더 나아가려는 라이브러리 작성자는, 자신의 선언 파일을 여러 컴파일러 옵션의 매트릭스에 대해 검증하는 것이 좋아요. 하지만 verbatimModuleSyntax를 쓰면 CommonJS를 내보내는 파일이 CommonJS 스타일의 가져오기·내보내기 문법을 쓰도록 강제해서 esModuleInterop 문제를 완전히 우회해요. 게다가 esModuleInterop은 CommonJS에만 영향을 주므로, 시간이 지나면서 더 많은 라이브러리가 ESM 전용 배포로 옮겨가면 이 문제의 중요성도 줄어들 거예요.
더 알아보기
- Modules Reference — 모듈 전반에 대한 공식 레퍼런스
- Modules — 모듈 핸드북 본문
- Module Namespace Object (ECMAScript 명세) — 네임스페이스 객체의 명세 정의
- cjs-module-lexer — Node.js가 이름 있는 내보내기를 합성하는 데 사용하는 구문 분석 도구