ESM/CJS 상호 운용

ESM/CJS 상호 운용 (Modules - ESM/CJS Interoperability)

ES 모듈과 CommonJS 모듈이 서로 만날 때 어떤 일이 일어나는지를 깊이 들여다보는 문서예요. 트랜스파일러가 어떻게 ESM을 CJS로 내리면서 시작했는지, esModuleInteropallowSyntheticDefaultImports가 왜 등장했는지, 그리고 Node.js에서 실제로 어떤 규칙이 적용되는지를 하나의 이야기로 풀어볼게요.

출처: TypeScript 핸드북

때는 2015년, 여러분이 ESM-to-CJS 트랜스파일러를 작성하고 있다고 상상해 보세요. 이를 위한 명세는 없어요. 여러분이 가진 건 ES 모듈이 서로 어떻게 상호작용해야 하는지에 대한 명세, CommonJS 모듈이 서로 어떻게 상호작용하는지에 대한 지식, 그리고 알아내는 재능뿐이죠. ES 모듈을 export하는 다음 코드를 생각해 볼게요:

export const A = {};
export const B = {};
export default "Hello, world!";

이걸 CommonJS 모듈로 어떻게 바꾸시겠어요? default export가 특별한 문법을 가진 named export일 뿐이라는 걸 기억하면, 선택지는 하나뿐인 것 같아요:

exports.A = {};
exports.B = {};
exports.default = "Hello, world!";

이건 훌륭한 유사물(analog)이고, 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 export를 작성할 방법이 없다는 점을 눈치챘을 거예요:

// @Filename: exports-function.js
module.exports = function hello() {
  console.log("Hello, world!");
};

하지만 기존 CommonJS 모듈은 자주 이런 형태를 취합니다. 우리 트랜스파일러로 처리한 ESM import는 이 모듈에 어떻게 접근할 수 있을까요? 방금 네임스페이스 import(import *)가 일반 require 호출로 트랜스파일된다는 걸 확립했으니, 이런 입력을 지원할 수 있어요:

import * as hello from "./exports-function";
hello();

// transpiles to:

const hello = require("./exports-function");
hello();

우리 출력은 런타임에서 동작하지만, 규정 준수(compliance) 문제가 있어요. JavaScript 명세에 따르면 네임스페이스 import는 항상 Module Namespace Object, 즉 멤버가 모듈의 export인 객체로 해석됩니다. 이 경우 require는 함수 hello를 반환하지만, import *는 절대 함수를 반환할 수 없어요. 우리가 가정한 대응이 유효하지 않은 것 같습니다.

여기서 잠시 멈추고 목표가 무엇인지 명확히 하는 게 좋겠어요. 모듈이 ES2015 명세에 등장하자마자, 트랜스파일러들이 ESM을 CJS로 내리는(downlevel) 지원을 내놓았고, 런타임이 이를 구현하기 훨씬 전에 사용자가 새 문법을 채택할 수 있게 됐어요. ESM 코드를 작성하는 것이 새 프로젝트를 "미래 대비(future-proof)"하는 좋은 방법이라는 생각까지 있었죠. 이것이 사실이려면, 런타임이 지원을 개발한 뒤 실행되는 트랜스파일러의 CJS 출력에서 기본 ESM 입력으로의 원활한 마이그레이션 경로가 필요했습니다. 목표는 ESM을 CJS로 내리는 방법을 찾는 것, 그래서 그 트랜스파일된 출력 중 어떤 것이든 미래 런타임에서 진짜 ESM 입력으로 교체되더라도 관찰 가능한 동작 변화가 없는 방법을 찾는 것이었습니다.

명세를 따르면, 트랜스파일러가 자신의 트랜스파일된 CommonJS 출력의 의미론이 ESM 입력의 명세된 의미론과 일치하게 만드는 변환 집합을 찾는 것은 쉬웠어요 (화살표는 import를 나타냄):

A flowchart with two similar flows side-by-side. Left: ESM. Right: ESM transpiled to CJS. In the ESM flow: "Importing module" flows to "Imported module" through arrow labeled "specified behavior". In the ESM transpiled to CJS flow: "Importing module" flows to "Imported module" through arrow labeled "designed based on spec".

하지만 CommonJS 모듈(ESM을 CommonJS로 트랜스파일한 게 아니라 CommonJS로 작성된)은 이미 Node.js 생태계에 잘 자리 잡고 있었기 때문에, ESM으로 작성되어 CJS로 트랜스파일된 모듈이 CommonJS로 작성된 모듈을 "import"하기 시작하는 것은 불가피했어요. 그러나 이 상호 운용을 위한 동작은 ES2015에 명세되지 않았고, 어떤 실제 런타임에도 아직 존재하지 않았습니다.

A flowchart with three areas side-by-side. Left: ESM. Middle: True CJS. Right: ESM transpiled to CJS. Left: ESM "Importing module" flows to ESM "Imported module" through arrow labeled "specified behavior," and to True CJS "Imported module" through dotted arrow labeled "unspecified behavior." Right: ESM transpiled to CJS "Importing module" flows to ESM transpiled to CJS "Imported module" through arrow labeled "designed based on spec," and to True CJS "Imported module" through dotted arrow labeled "❓🤷‍♂️❓"

트랜스파일러 작성자가 아무것도 하지 않더라도, 트랜스파일된 코드에서 자신들이 emit한 require 호출과 기존 CJS 모듈에 정의된 exports 사이의 기존 의미론에서 어떤 동작이 등장할 것입니다. 그리고 서로 다른 트랜스파일러가 emit한 require 호출을 진짜 ESM으로 원활하게 전환할 수 있게 하려면, 그 동작은 런타임이 구현하기로 선택한 것과 일치해야 했습니다.

런타임이 지원할 상호 운용 동작을 추측하는 일은 ESM이 "진짜 CJS" 모듈을 import하는 데만 국한되지 않았어요. ESM이 CJS로부터 트랜스파일된 ESM을 CJS와 구별할 수 있는지, CJS가 ES 모듈을 require할 수 있는지도 명세되지 않았죠. ESM import가 CJS require 호출과 동일한 모듈 해석 알고리즘을 사용할지조차 알 수 없었습니다. 트랜스파일러 사용자에게 네이티브 ESM으로의 원활한 마이그레이션 경로를 주려면, 이 모든 변수가 정확히 예측되어야 했습니다.

allowSyntheticDefaultImportsesModuleInterop

명세 준수 문제로 돌아와서, 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에 에러를 냄으로써 이 문제를 다뤘어요:

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 문법으로 돌아가는 것이었습니다:

import hello = require("./exports-function");

사용자가 비-ESM 문법으로 되돌아가도록 강제하는 것은 본질적으로 "우리는 "./exports-function" 같은 CJS 모듈이 미래에 ESM import로 접근 가능할지, 어떻게 가능할지 모르지만, import *로는 _불가능하다_는 것만은 안다. 우리가 쓰는 트랜스파일 체계에서 런타임에 동작할지라도 말이다."라고 인정하는 것이었어요. 이는 이 파일을 변경 없이 진짜 ESM으로 마이그레이션할 수 있게 하는 목표를 충족하지 못합니다. 하지만 import *가 함수에 연결되도록 허용하는 대안도 마찬가지로 목표를 충족하지 못해요. 이 상황은 여전히 allowSyntheticDefaultImportsesModuleInterop이 비활성화된 오늘날의 TypeScript 동작입니다.

아쉽게도 이건 약간의 단순화예요. TypeScript는 함수의 네임스페이스 import가, 함수 선언이 네임스페이스 선언과 병합되는 한 (네임스페이스가 비어 있더라도) 동작하고 호출 시그니처를 유지하도록 허용했기 때문에, 이 에러로 규정 준수 문제를 완전히 피하지는 못했습니다. 그래서 빈 함수를 export하는 모듈이 "비-모듈 개체"로 인식되는 동안:

declare function $(selector: string): any;
export = $; // Cannot `import *` this 👍

의미 없는 수준의 변경으로 무효한 import가 에러 없이 타입 검사를 통과할 수 있었습니다:

declare namespace $ {}
declare function $(selector: string): any;
export = $; // Allowed to `import *` this and call it 😱

한편 다른 트랜스파일러들은 같은 문제를 해결하는 방법을 찾아내고 있었습니다. 사고 과정은 대략 이랬어요:

  1. 함수나 기본값을 export하는 CJS 모듈을 import하려면 분명히 default import를 사용해야 해요. 네임스페이스 import는 불법이고, named import는 여기서 말이 안 됩니다.
  2. 아마도 ESM/CJS 상호 운용을 구현하는 런타임은 CJS 모듈의 default import가 exports가 함수나 기본값일 때에만이 아니라 항상 전체 exports에 직접 연결되도록 선택할 것 같습니다.
  3. 그래서 진짜 CJS 모듈의 default import는 require 호출처럼 동작해야 합니다. 하지만 우리의 트랜스파일된 CJS 모듈과 진짜 CJS 모듈을 구별할 방법이 필요해요. 그렇게 해야 export default "hello"를 여전히 exports.default = "hello"로 트랜스파일하고, 모듈의 default import가 exports.default에 연결되게 할 수 있으니까요. 기본적으로, 우리 자신의 트랜스파일된 모듈 중 하나의 default import는 한 방식으로 동작해야 하고 (ESM-to-ESM import를 시뮬레이션), 다른 기존 CJS 모듈의 default import는 다른 방식으로 동작해야 합니다 (ESM-to-CJS import가 어떻게 될지에 대한 우리의 생각을 시뮬레이션).
  4. ES 모듈을 CJS로 트랜스파일할 때 출력에 특별한 추가 필드를 넣읍시다:
    exports.A = {};
    exports.B = {};
    exports.default = "Hello, world!";
    // Extra special flag!
    exports.__esModule = true;
    
    우리가 default import를 트랜스파일할 때 이것을 확인할 수 있어요:
    // 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에 default import를 직접 연결하도록 허용했어요. 이 플래그는 import나 export가 어떻게 emit되는지 수정하지 않았지만, default import가 다른 트랜스파일러가 그것들을 어떻게 취급하는지를 반영하도록 허용했습니다. 구체적으로, import *가 에러인 "비-모듈 개체"로 해석하는 데 default 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가 불평하지 않고 이미 그 시스템에서 동작하는 코드를 쓸 수 있게 하기에 충분했지만, 부분적인 해결책일 뿐 몇 가지 문제를 남겼어요:

  1. Babel 등은 대상 모듈에서 __esModule 속성이 발견되는지에 따라 default import 동작을 달리했지만, allowSyntheticDefaultImports는 대상 모듈의 타입에 default export가 없을 때에만 폴백 동작을 활성화했어요. 이는 대상 모듈에 __esModule 플래그가 있지만 default export가 없는 경우 불일치를 만들었죠. 트랜스파일러와 번들러는 그러한 모듈의 default import를 여전히 exports.default에 연결하는데 그건 undefined가 될 거고, 진짜 ESM import는 연결할 수 없으면 에러를 내므로 이상적으로는 TypeScript에서 에러여야 해요. 하지만 allowSyntheticDefaultImports를 쓰면 TypeScript는 그런 import의 default import가 전체 exports 객체에 연결된다고 생각해, named export를 그 속성으로 접근할 수 있게 합니다.
  2. allowSyntheticDefaultImports는 네임스페이스 import가 어떻게 타입 되는지 바꾸지 않아서, 둘 다 사용 가능하고 같은 타입을 갖게 되는 이상한 불일치를 만들었어요:
    // @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")'.
    
  3. 가장 중요하게, allowSyntheticDefaultImportstsc가 emit하는 JavaScript를 바꾸지 않았어요. 그래서 이 플래그는 코드가 Babel이나 Webpack 같은 다른 도구에 공급되는 한 더 정확한 검사를 가능하게 했지만, tsc--module commonjs를 emit하고 Node.js에서 실행하는 사용자에게는 실제 위험이 됐습니다. import *로 에러를 만났을 때 allowSyntheticDefaultImports를 켜면 고쳐질 것처럼 보였지만, 사실은 Node에서 충돌할 코드를 emit하면서 빌드 타임 에러만 잠재운 것일 뿐이었죠.

TypeScript는 2.7에서 esModuleInterop 플래그를 도입했어요. 이 플래그는 TypeScript 분석과 기존 트랜스파일러·번들러에서 사용되는 상호 운용 동작 사이의 남은 불일치를 해결하도록 import의 타입 검사를 세밀하게 다듬었고, 결정적으로 트랜스파일러들이 수년 전에 채택했던 것과 동일한 __esModule 조건부 CommonJS emit을 채택했습니다. (import *를 위한 또 다른 새 emit 헬퍼는 결과가 항상 객체가 되도록 보장하고 호출 시그니처를 제거하며, 앞서 언급한 "비-모듈 개체로 해석" 에러가 완전히 피하지 못한 명세 준수 문제를 완전히 해결했습니다.) 마침내 새 플래그가 켜지면 TypeScript의 타입 검사, TypeScript의 emit, 그리고 나머지 트랜스파일·번들링 생태계가, 명세에 부합하고 어쩌면 Node가 채택할 법한 CJS/ESM 상호 운용 체계에 대해 일치하게 됐어요.

Node.js에서의 상호 운용 (Interop in Node.js)

Node.js는 v12에서 플래그 없이 ES 모듈 지원을 제공했습니다. 번들러와 트랜스파일러가 수년 전부터 시작한 것처럼, Node.js는 CommonJS 모듈에 exports 객체의 "합성 default export(synthetic default export)"를 부여해서, ESM에서 default import로 모듈 전체 내용에 접근할 수 있게 했어요:

// @Filename: export.cjs
module.exports = { hello: "world" };

// @Filename: import.mjs
import greeting from "./export.cjs";
greeting.hello; // "world"

원활한 마이그레이션을 위한 하나의 승리예요! 아쉽게도 유사점은 대부분 여기서 끝납니다.

__esModule 감지 없음 ("이중 default" 문제)

Node.js는 __esModule 표시를 존중해 default import 동작을 달리할 수 없었어요. 그래서 "default export"가 있는 트랜스파일된 모듈은, 다른 트랜스파일된 모듈이 "import"할 때 한 방식으로, Node.js의 진짜 ES 모듈이 import할 때는 다른 방식으로 동작합니다:

// @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();

트랜스파일된 default import가 대상 모듈에 __esModule 플래그가 없을 때만 합성 default export를 만드는 반면, Node.js는 항상 default export를 합성해서, 트랜스파일된 모듈에 "이중 default"를 만듭니다.

신뢰할 수 없는 named exports

CommonJS 모듈의 exports 객체를 default import로 사용 가능하게 하는 것에 더해, Node.js는 exports의 속성을 찾아 named import로 사용 가능하게 만듭니다. 이 동작은 동작할 때 번들러와 트랜스파일러와 일치하지만, Node.js는 어떤 코드도 실행되기 전에 syntactic analysis로 named export를 합성하는 반면, 트랜스파일된 모듈은 런타임에 named import를 해석합니다. 그 결과, 트랜스파일된 모듈에서 동작하는 CJS 모듈의 import가 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에서 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는 require 호출을 해석하는 오랜 알고리즘과 크게 다른, ESM import 해석을 위한 새 모듈 해석 알고리즘을 도입했어요. 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.

결론 (Conclusions)

분명히, 적어도 Node.js에서는 트랜스파일된 모듈에서 ESM으로의 원활한 마이그레이션은 불가능해요. 그렇다면 우리는 어디에 서 있나요?

올바른 module 컴파일러 옵션을 설정하는 것이 중요합니다

상호 운용 규칙이 호스트마다 다르기 때문에, TypeScript는 각 파일이 나타내는 모듈 종류가 무엇이고 그에 적용할 규칙 집합이 무엇인지 이해하지 못하면 올바른 검사 동작을 제공할 수 없어요. 이것이 module 컴파일러 옵션의 목적입니다. (특히 Node.js에서 실행될 의도가 있는 코드는 번들러가 처리할 코드보다 더 엄격한 규칙의 적용을 받습니다. 컴파일러의 출력은 modulenode16, node18, 또는 nodenext로 설정되지 않는 한 Node.js 호환성에 대해 검사되지 않습니다.)

CommonJS 코드가 있는 애플리케이션은 항상 esModuleInterop을 켜야 합니다

tsc로 JavaScript 파일을 emit하는 TypeScript 애플리케이션 (다른 사람이 소비할 수 있는 라이브러리와 반대)에서 esModuleInterop이 켜졌는지는 큰 결과를 만들지 않아요. 특정 종류의 모듈에 대한 import 작성 방식은 바뀌지만, TypeScript의 검사와 emit은 동기화되어 있으므로, 에러가 없는 코드는 두 모드 모두에서 안전하게 실행되어야 합니다. 이 경우 esModuleInterop을 끈 채 두는 단점은, ECMAScript 명세를 분명히 위반하는 의미론을 가진 JavaScript 코드를 작성할 수 있게 해서, 네임스페이스 import에 대한 직관을 혼란스럽게 하고 미래에 ES 모듈 실행으로 마이그레이션하기 어렵게 만든다는 점이에요.

반면 서드파티 트랜스파일러나 번들러로 처리되는 애플리케이션에서는 esModuleInterop을 켜는 것이 더 중요합니다. 모든 주요 번들러와 트랜스파일러는 esModuleInterop-류 emit 전략을 사용하므로, TypeScript는 이를 맞추기 위해 검사를 조정해야 해요. (컴파일러는 항상 tsc가 emit할 JavaScript 파일에서 무슨 일이 일어날지 추론하므로, tsc 대신 다른 도구를 사용하더라도 emit에 영향을 주는 컴파일러 옵션은 그 도구의 출력과 최대한 가깝게 일치하도록 설정해야 합니다.)

esModuleInterop 없이 allowSyntheticDefaultImports만 사용하는 것은 피해야 해요. tsc가 emit하는 코드를 바꾸지 않으면서 컴파일러의 검사 동작을 바꿔, 잠재적으로 안전하지 않은 JavaScript가 emit되도록 허용합니다. 게다가 그것이 도입하는 검사 변경은 esModuleInterop이 도입하는 것의 불완전한 버전이에요. tsc가 emit에 사용되지 않더라도, allowSyntheticDefaultImports보다 esModuleInterop을 켜는 것이 더 낫습니다.

어떤 사람들은 esModuleInterop이 켜졌을 때 tsc의 JavaScript 출력에 포함되는 __importDefault__importStar 헬퍼 함수의 포함을 반대해요. 헬퍼가 디스크의 출력 크기를 미미하게 늘린다거나, 헬퍼가 사용하는 상호 운용 알고리즘에 __esModule을 검사하는 것이 앞서 논의한 위험으로 이어져 Node.js의 상호 운용 동작을 잘못 나타내는 것처럼 보인다는 이유죠. 이 두 반대는 모두, esModuleInterop이 꺼졌을 때 나타나는 잘못된 검사 동작을 받아들이지 않고도 적어도 부분적으로 해결할 수 있어요. 첫째, importHelpers 컴파일러 옵션을 사용해 헬퍼 함수를 각 파일에 인라인하는 대신 tslib에서 import할 수 있습니다. 두 번째 반대를 논하려면 마지막 예시를 볼게요:

// @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로 컴파일한다고 가정해 볼게요. allowSyntheticDefaultImportsesModuleInterop 없이는 "true-cjs-dependency"에서 doSomethingElse를 import하는 것이 에러이고, 나머지는 그렇지 않아요. 컴파일러 옵션을 바꾸지 않고 에러를 고치려면, import를 import doSomethingElse = require("true-cjs-dependency")로 바꾸면 됩니다. 하지만 모듈의 타입(표시되지 않음)이 어떻게 작성되었는지에 따라, 언어 수준의 명세 위반이 되는 네임스페이스 import를 작성하고 호출할 수도 있어요. esModuleInterop으로는 표시된 import 중 어떤 것도 에러가 아니고 (모두 호출 가능), 무효한 네임스페이스 import는 잡힐 겁니다.

만약 src를 Node.js의 진짜 ESM으로 마이그레이션하기로 결정한다면 (예: 루트 package.json에 "type": "module" 추가) 무엇이 바뀔까요? 첫 번째 import인 "transpiled-dependency"doSomething은 더 이상 호출할 수 없어요. doSomething()이 아니라 doSomething.default()를 호출해야 하는 "이중 default" 문제를 보여주니까요. (TypeScript는 --module node16nodenext에서 이를 이해하고 잡아냅니다.) 하지만 주목할 점은, CommonJS로 컴파일할 때 동작하려면 esModuleInterop이 필요했던 두 번째 import인 doSomethingElse가 진짜 ESM에서는 문제없이 동작한다는 것입니다.

여기서 불평할 것이 있다면, 그것은 esModuleInterop이 두 번째 import에 하는 일이 아닙니다. 그것이 만드는 변경들, default import를 허용하고 호출 가능한 네임스페이스 import를 막는 것 모두, Node.js의 실제 ESM/CJS 상호 운용 전략과 정확히 일치하고 마이그레이션을 더 쉽게 만들었어요. 문제가 있다면, esModuleInterop첫 번째 import에 대한 원활한 마이그레이션 경로를 주는 데 실패하는 것처럼 보인다는 점입니다. 하지만 이 문제는 esModuleInterop을 켜서 도입된 것이 아니에요. 첫 번째 import는 그것에 완전히 영향받지 않았습니다. 아쉽게도 이 문제는 main.tssayHello.ts 사이의 의미론적 계약을 깨뜨리지 않고는 해결할 수 없어요. sayHello.ts의 CommonJS 출력이 transpiled-dependency/index.js와 구조적으로 동일해 보이거든요. esModuleInterop이 트랜스파일된 doSomething import가 동작하는 방식을 Node.js ESM에서의 방식과 동일하게 바꾼다면, sayHello import의 동작도 같은 방식으로 바꾸게 되어 입력 코드가 ESM 의미론을 위반하게 됩니다 (따라서 여전히 src 디렉터리가 변경 없이 ESM으로 마이그레이션되는 것을 막습니다).

앞서 보았듯이, 트랜스파일된 모듈에서 진짜 ESM으로의 원활한 마이그레이션 경로는 없어요. 하지만 esModuleInterop은 올바른 방향으로의 한 걸음입니다. 여전히 모듈 문법 변환과 import 헬퍼 함수의 포함을 최소화하는 것을 선호하는 분들에게는, esModuleInterop을 끄는 것보다 verbatimModuleSyntax를 켜는 것이 더 나은 선택이에요. verbatimModuleSyntax는 CommonJS로 emit되는 파일에서 import mod = require("mod")export = ns 문법을 강제해서, 우리가 논의한 모든 종류의 import 모호성을 피하게 합니다. 대가로 진짜 ESM으로의 마이그레이션 용이성을 희생하지만요.

라이브러리 코드는 특별한 고려가 필요합니다

CommonJS로 배포되는 라이브러리는 default export 사용을 피해야 해요. 그 트랜스파일된 export에 접근하는 방식이 도구와 런타임마다 달라지고, 그중 일부는 사용자에게 혼란스러워 보일 수 있기 때문이죠. tsc로 CommonJS로 트랜스파일된 default export는 Node.js에서 default import의 default 속성으로 접근할 수 있어요:

import pkg from "pkg";
pkg.default();

대부분의 번들러나 트랜스파일된 ESM에서는 default import 자체로:

import pkg from "pkg";
pkg();

그리고 단순 CommonJS에서는 require 호출의 default 속성으로:

const pkg = require("pkg");
pkg.default();

사용자가 default import의 .default 속성에 접근해야 한다면 잘못 구성된 모듈 냄새를 감지하게 되고, Node.js와 번들러 모두에서 실행될 코드를 작성하려 한다면 막힐 수 있어요. 일부 서드파티 TypeScript 트랜스파일러는 이 차이를 완화하기 위해 default export가 emit되는 방식을 바꾸는 옵션을 노출하지만, 자체 선언(.d.ts) 파일을 만들지 않아 런타임 동작과 타입 검사 사이에 불일치가 생기고 사용자를 더 혼란스럽고 좌절하게 만들어요. default export 대신, CommonJS로 배포해야 하는 라이브러리는 단일 주요 export가 있는 모듈에 export =를, 여러 export가 있는 모듈에는 named export를 사용해야 해요:

- export default function doSomething() { /* ... */ }
+ export = function doSomething() { /* ... */ }

(선언 파일을 배포하는) 라이브러리는 또한 자신이 작성하는 타입이 넓은 범위의 컴파일러 옵션에서 에러가 없도록 각별히 주의해야 해요. 예를 들어, 하나의 인터페이스가 다른 인터페이스를 확장하도록 작성해서 strictNullChecks가 꺼졌을 때에만 컴파일되게 할 수 있어요. 라이브러리가 그런 타입을 게시하면 모든 사용자가 strictNullChecks도 끄도록 강제할 거예요. esModuleInterop은 타입 선언에 비슷하게 "전염적인(infectious)" default import를 포함하도록 허용할 수 있습니다:

// @Filename: /node_modules/dependency/index.d.ts
import express from "express";
declare function doSomething(req: express.Request): any;
export = doSomething;

이 default import가 esModuleInterop이 켜졌을 때만 동작하고, 그 옵션이 없는 사용자가 이 파일을 참조하면 에러를 일으킨다고 가정해 볼게요. 사용자는 어차피 esModuleInterop을 켜는 것이 아마 좋겠지만, 라이브러리가 자기 구성 문제를 이렇게 전염시키는 것은 일반적으로 나쁜 형식으로 여겨집니다. 라이브러리가 이렇게 선언 파일을 배포하는 것이 훨씬 나을 거예요:

import express = require("express");
// ...

이런 예시들은 라이브러리가 esModuleInterop을 켜지 말아야 한다는 통념으로 이어졌어요. 이 조언은 합리적인 출발점이지만, 우리는 esModuleInterop을 켤 때 네임스페이스 import의 타입이 바뀌어 에러를 도입 할 수 있는 사례를 살펴봤어요. 그래서 라이브러리가 esModuleInterop을 켜든 끄든, 자기 선택을 전염시키는 문법을 작성할 위험을 안게 됩니다.

최대 호환성을 보장하기 위해 한 걸음 더 나아가고 싶은 라이브러리 작성자는 컴파일러 옵션 매트릭스에 대해 선언 파일을 검증하는 것이 좋아요. 하지만 verbatimModuleSyntax를 사용하면 CommonJS로 emit되는 파일이 CommonJS 스타일 import·export 문법을 쓰도록 강제함으로써 esModuleInterop 문제를 완전히 우회합니다. 게다가 esModuleInterop은 CommonJS에만 영향을 미치므로, 시간이 지나 더 많은 라이브러리가 ESM-only 게시로 옮겨감에 따라 이 문제의 관련성도 줄어들 거예요.

더 알아보기 (Learn more)