본문 바로가기

CommonJS와의 상호 운용성

require와 module.exports를 쓰는 CommonJS 모듈의 모양과, module 옵션에 따라 달라지는 출력, CommonJS의 전체 값과 default 속성의 차이, esModuleInterop과 export =, Node.js에서 CommonJS와 ES 모듈 파일을 함께 실행하는 구성을 예제로 다룹니다.

Node.js는 오랫동안 ES 모듈이 아니라 CommonJS라는 모듈 방식을 써 왔고, 지금도 많은 npm 패키지와 기존 프로젝트가 CommonJS로 되어 있습니다. CommonJS는 require로 다른 모듈을 불러오고, module.exports에 넣은 값을 내보냅니다. 그래서 타입스크립트 프로젝트에서도 ES 모듈의 문법으로 CommonJS 모듈을 가져오거나, 거꾸로 코드를 CommonJS로 출력해야 하는 일이 자주 생깁니다.

이 글은 CommonJS 모듈이 내보내는 값의 모양을 먼저 보고, module 옵션에 따라 같은 소스가 어떻게 다르게 출력되는지 비교합니다. 이어서 CommonJS의 값을 ES 모듈의 기본 가져오기로 받을 때 생기는 문제와 그 해결 방법을 보고, 두 형식의 파일을 Node.js에서 함께 실행해 봅니다.

CommonJS 모듈의 모양

CommonJS에서 모듈이 내보내는 것은 module.exports에 담긴 값 하나입니다. 그 값은 객체일 수도 있고 함수나 클래스일 수도 있으며, require를 부른 쪽은 그 값을 그대로 받습니다.

myUtility.js
module.exports = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b,
};
main.cjs
const myUtility = require("./myUtility.js");
console.log(myUtility.add(10, 5), myUtility.subtract(10, 5));
15 5

require("./myUtility.js")는 두 함수를 가진 객체 전체를 돌려주므로 add와 subtract를 그 객체의 속성으로 부릅니다. ES 모듈에는 이름 있는 내보내기와 기본 내보내기가 따로 있지만, CommonJS에는 module.exports라는 값 하나뿐이라는 점이 두 방식이 어긋나는 출발점입니다.

본문 myUtility의 module.exports 객체, TypeScript CommonJS 출력의 exports.default, esModuleInterop 헬퍼와 Node ESM의 CommonJS 기본 가져오기를 구분한다.

TYPE / RUNTIME / CONTRACT

CommonJS의 전체 값과 default 속성은 다르다

require는 module.exports에 담긴 값을 받습니다. exports.default라는 속성과 ES 모듈에서 받는 기본 값은 실행 방식에 따라 해석이 달라집니다.

MODULE.EXPORTS

myUtility의 전체 객체

module.exports = {add, subtract}입니다. CommonJS 출력의 require가 이 객체를 받으므로 add(10,5)는 15, subtract(10,5)는 5입니다.

EXPORTS.DEFAULT

이름이 default인 속성

myModule의 CommonJS 출력은 greeting과 sayHello, default:Greeter를 속성으로 내보냅니다. default 속성이 전체 module.exports 값과 같은 것은 아닙니다.

TYPESCRIPT → COMMONJS

esModuleInterop의 헬퍼

기본 import를 require로 변환할 때 헬퍼는 CommonJS 값을 감싸거나 __esModule 표시를 확인합니다. 실제 내보내기 모양과 선언이 여전히 맞아야 합니다.

NODE ESM

Node가 만드는 기본 연결

Node ESM에서 CommonJS를 가져오면 기본 값은 module.exports입니다. 이름 있는 내보내기는 정적 분석에 의존하여 항상 제공된다고 보장할 수 없습니다.

esModuleInterop은 모든 실행 환경의 로더를 바꾸는 옵션이 아닙니다. 패키지 버전의 공개 경로와 내보내기 모양에 맞는 import를 사용하세요.

위 그림처럼 module.exports 전체 값과, 그 객체의 default라는 이름의 속성은 서로 다른 것입니다. 이 차이가 어떻게 생기는지는 타입스크립트가 ES 모듈의 코드를 CommonJS로 출력하는 모습에서 드러납니다.

module 옵션에 따른 출력

다음 소스는 이름 있는 내보내기 두 개와 기본 내보내기 하나를 가집니다. 이 파일 하나를 module 설정만 바꿔 컴파일하며 출력을 비교하므로, export default가 붙은 Greeter가 출력에서 어떤 이름으로 바뀌는지를 눈여겨보세요.

src/myModule.ts
export const greeting = "Hello";

export function sayHello(name: string): void {
  console.log(`${greeting}, ${name}!`);
}

export default class Greeter {
  greet(name: string): void {
    console.log(`Greetings from Greeter, ${name}!`);
  }
}

module을 commonjs로 두고 컴파일하면, 내보낸 이름은 모두 exports 객체의 속성이 됩니다. 다음 출력에서 exports.greeting = void 0;은 내보낼 이름을 먼저 undefined 값으로 만들어 두는 줄이고, 실제 값 "Hello"는 그 아래 줄에서 들어갑니다.

dist/myModule.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.greeting = void 0;
exports.sayHello = sayHello;
exports.greeting = "Hello";
function sayHello(name) {
    console.log(`${exports.greeting}, ${name}!`);
}
class Greeter {
    greet(name) {
        console.log(`Greetings from Greeter, ${name}!`);
    }
}
exports.default = Greeter;

export default는 exports.default라는 속성으로 바뀌고, 맨 위의 __esModule 표시는 "이 객체는 ES 모듈을 CommonJS로 옮긴 것"임을 알립니다. 그래서 이 모듈을 require한 쪽이 받는 값은 Greeter 클래스가 아니라 greeting, sayHello, default를 속성으로 가진 객체입니다. 컴파일러의 버전에 따라 exports.sayHello = sayHello;처럼 함수를 연결하는 줄의 위치는 조금 다를 수 있지만, 내보낸 이름이 exports의 속성이 된다는 점은 같습니다.

module을 esnext로 두면 import와 export가 그대로 남고, 타입 표기만 지워집니다. 이렇게 같은 소스라도 module 옵션에 따라 출력의 형식이 달라지므로, 그 출력을 실행할 환경이 어느 형식으로 읽을지와 함께 정해야 합니다.

CommonJS 값을 기본 가져오기로 받기

ES 모듈의 문법으로 import myUtility from "./myUtility.js"처럼 CommonJS 모듈을 기본 가져오기로 받으면 문제가 생깁니다. ES 모듈의 기본 가져오기는 원래 default 내보내기를 받는 것인데, myUtility.js의 module.exports에는 default 속성이 없기 때문입니다.

이 차이를 메우는 것이 esModuleInterop 옵션입니다. 이 옵션이 켜져 있으면 타입스크립트는 CommonJS로 출력할 때 기본 가져오기를 __importDefault라는 도우미 함수로 감싸, __esModule 표시가 없는 모듈은 module.exports 전체를 기본 값으로 쓰게 합니다. 그래서 import myUtility from ...가 두 함수를 가진 객체 전체를 받습니다.

이 옵션을 끄면 기본 가져오기가 실제로는 require(...).default를 읽게 되어, default가 없는 CommonJS 모듈에서는 undefined를 받고 실행 중에 멈추기 쉬웠습니다. 이 동작은 오래전부터 권장되지 않았고, TypeScript 6.0에서는 esModuleInterop: false와 allowSyntheticDefaultImports: false가 폐기 예정으로 지정되어 진단이 나옵니다. 새 프로젝트에서는 이 옵션을 끄지 않습니다.

CommonJS의 module.exports = 값과 같은 내보내기를 타입스크립트에서 직접 쓰려면 export =를 씁니다. 이렇게 내보낸 모듈은 import utility = require("./utility")처럼 타입스크립트의 전용 문법으로 가져올 수 있고, esModuleInterop이 켜져 있으면 기본 가져오기로도 받을 수 있습니다.

Node.js에서 두 형식 함께 실행하기

이제 CommonJS 파일과 ES 모듈 파일이 섞인 작은 프로젝트를 실행해 봅니다. 새 폴더 ts-interop을 만들고 다음 파일을 둡니다. 확장자 .cts는 언제나 CommonJS로 출력되는 타입스크립트 파일이며, 출력 파일의 확장자는 .cjs입니다.

src/utility.cts
const utility = {
  add(a: number, b: number): number {
    return a + b;
  },
  subtract(a: number, b: number): number {
    return a - b;
  },
};

export = utility;
src/app.ts
import utility from "./utility.cjs";

console.log(utility.add(10, 5));
console.log(utility.subtract(10, 5));
tsconfig.json
{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true
  },
  "include": ["src"]
}

먼저 package.json을 { "type": "commonjs" }로 저장하고 tsc -p .과 node dist/app.js를 실행합니다. 이때 app.ts도 CommonJS로 출력되어, 기본 가져오기는 __importDefault(require("./utility.cjs"))로 바뀌고 module.exports의 객체 전체를 받습니다.

15
5

이어서 package.json만 { "type": "module" }로 바꾸고 다시 컴파일해 실행합니다. 이번에는 app.ts가 ES 모듈로 출력되어 import 문이 그대로 남고, utility.cts는 확장자 때문에 여전히 CommonJS로 출력됩니다. Node.js는 ES 모듈에서 CommonJS 모듈을 기본 가져오기로 받으면 module.exports의 값을 넘겨주므로, 출력은 그대로 15와 5입니다.

CommonJS 출력은 CommonJS로 판정되는 파일과 패키지, NodeNext ESM 출력은 type:module과 실행 파일 확장자를 함께 맞춘다. 선언은 별도의 타입 계약이다.

TYPE / RUNTIME / CONTRACT

출력 모듈 형식과 Node의 파일 판정을 맞춘다

같은 TypeScript 소스도 설정에 따라 CommonJS 또는 ESM으로 출력됩니다. 아래는 Node가 출력 JavaScript를 직접 실행하는 두 구성을 비교합니다.

출력 모듈 형식과 Node의 파일 판정을 맞춘다CommonJS 출력은 CommonJS로 판정되는 파일과 패키지, NodeNext ESM 출력은 type:module과 실행 파일 확장자를 함께 맞춘다. 선언은 별도의 타입 계약이다.CommonJS 직접 실행컴파일 설정module:CommonJSCommonJS 출력require · exportsNode CommonJS.cjs 또는type: commonjsESM 직접 실행NodeNext 설정module · resolutiontype: moduleESM 출력import · exportNode ESM.mjs 또는type: module
출력 모듈 형식과 Node의 파일 판정을 맞춘다CommonJS 출력은 CommonJS로 판정되는 파일과 패키지, NodeNext ESM 출력은 type:module과 실행 파일 확장자를 함께 맞춘다. 선언은 별도의 타입 계약이다.CommonJS 직접 실행컴파일 설정module:CommonJSCommonJS 출력require · exportsNode CommonJS.cjs 또는type: commonjsESM 직접 실행NodeNext 설정module · resolutiontype: moduleESM 출력import · exportNode ESM.mjs 또는type: module

두 영역은 서로 다른 직접 실행 구성입니다. NodeNext의 ESM 행은 package type:module을 전제로 하며, CommonJS로 출력한 행은 그 출력에 맞는 파일 판정이 필요합니다. 타입 선언은 실행 파일을 만들지 않습니다.

COMMONJS

출력을 CommonJS로 실행

본문의 module:"CommonJS"는 ESM 구문을 require/exports 형태로 바꿉니다. 출력 .js는 해당 package type 등으로 CommonJS로 판정되게 맞춥니다.

NODE ESM

파일과 경로도 ESM 규칙에 맞춘다

본문의 NodeNext 구성은 package의 type:"module"을 사용합니다. 상대 import에는 출력 .js 등 실행 파일 확장자를 지정합니다.

PACKAGE API

공개된 진입점을 사용한다

exports가 있으면 허용한 root와 subpath를 사용합니다. 패키지 내부 파일이 존재한다고 모든 경로를 import할 수 있는 것은 아닙니다.

DECLARATION

실제 내보내기의 타입을 제공

자체 타입 선언이나 @types는 export 모양을 설명합니다. 타입 선언이 존재해도 실행 구현이나 잘못된 모듈 구성이 자동으로 해결되지는 않습니다.

번들러가 실행 구성을 처리하는 프로젝트는 해당 번들러의 규칙에 맞춥니다. 위 두 행은 서로 다른 Node 직접 실행 구성입니다.

위 그림처럼, 같은 소스라도 출력의 형식과 Node.js가 파일을 읽는 형식이 맞아야 실행됩니다. CommonJS로 출력했다면 그 파일이 CommonJS로 읽히도록 "type"이나 .cjs 확장자를 맞추고, ES 모듈로 출력했다면 "type": "module"이나 .mjs와 함께 상대 경로에 확장자를 적습니다. .cts와 .mts는 package.json의 "type"과 관계없이 출력 형식을 고정하므로, 한 프로젝트에서 두 형식을 섞어야 할 때 씁니다. 이 규칙은 공식 모듈 참조의 Node.js 모드에 정리되어 있습니다.

더 깊이: CommonJS에서 ES 모듈을 require하기

오랫동안 두 형식의 관계는 한쪽으로만 열려 있었습니다. ES 모듈은 CommonJS 모듈을 가져올 수 있었지만, CommonJS 모듈은 ES 모듈을 require할 수 없었습니다. Node.js의 모듈 문서에 따르면 이 기능은 v22.12.0과 v20.19.0에서 플래그 없이 쓸 수 있게 되었고, v24.15.0에서 실험 단계를 벗어났습니다. require는 ES 모듈의 네임스페이스 객체를 돌려주므로 기본 내보내기는 .default 속성에 들어 있고, 기본 내보내기가 있는 모듈이면 변환 도구와 맞추기 위한 __esModule: true 속성도 붙습니다.

제한도 있습니다. 불러오려는 모듈이나 그 모듈이 가져오는 모듈 가운데 최상위 await를 쓰는 것이 있으면, 동기적으로 실행할 수 없으므로 ERR_REQUIRE_ASYNC_MODULE 오류가 나고 그런 모듈은 import()로 불러와야 합니다. 또 ES 모듈이 export { Point as "module.exports" }처럼 "module.exports"라는 문자열 이름으로 내보내면 require가 그 값을 바로 돌려주지만, 이때 다른 이름 있는 내보내기는 CommonJS 쪽에서 보이지 않습니다. 타입스크립트는 5.8부터 module: "nodenext"에서 이런 require를 오류로 보지 않으며, 5.9에는 Node.js v20의 동작을 본뜬 안정된 설정 node20이 추가되었습니다.

정리

  • CommonJS 모듈은 module.exports에 담긴 값 하나를 내보내고, require는 그 값을 그대로 돌려줍니다.
  • module: "commonjs"로 출력하면 내보낸 이름은 exports의 속성이 되고, export default는 exports.default라는 속성이 됩니다.
  • esModuleInterop은 __esModule 표시가 없는 CommonJS 모듈을 기본 가져오기로 받을 때 module.exports 전체를 쓰게 하며, TypeScript 6.0에서 이 옵션을 끄는 설정은 폐기 예정입니다.
  • 타입스크립트에서 module.exports = 값과 같은 내보내기는 export =로 쓰고, import x = require()나 기본 가져오기로 받습니다.
  • 출력의 형식과 Node.js가 파일을 읽는 형식을 "type"과 확장자로 맞추며, .cts와 .mts는 형식을 파일마다 고정합니다.

연습 문제

1. 다른 값으로 부르기

src/app.ts의 두 호출을 utility.add(7, 2)와 utility.subtract(7, 2)로 바꾸면 출력은 무엇인가요? utility.add("7", 2)는 실행 전에 허용되나요?

해설 보기

출력은 9와 5입니다. utility.cts의 add는 첫 매개변수가 number이므로, 문자열 "7"을 넘기면 Argument of type 'string' is not assignable to parameter of type 'number'.로 진단됩니다. 파일을 찾지 못하는 문제와 인수의 타입이 틀린 문제는 서로 다른 진단이라는 점도 구별합니다.

2. require로 받은 TypeScript 모듈

앞의 dist/myModule.js를 CommonJS 파일에서 const loaded = require("./dist/myModule.js");로 받았습니다. typeof loaded와 typeof loaded.default는 각각 무엇인가요? new loaded()는 실행되나요?

해설 보기

loaded는 exports 객체여서 typeof loaded는 "object"이고, loaded.default가 Greeter 클래스이므로 typeof loaded.default는 "function"입니다. 객체는 생성자가 아니므로 new loaded()는 loaded is not a constructor라는 TypeError로 멈추고, 클래스를 만들려면 new loaded.default()를 써야 합니다. 기본 내보내기가 CommonJS로 옮겨지면 전체 값이 아니라 default 속성이 된다는 것을 보여 줍니다.

3. 형식을 고정하는 확장자

package.json이 { "type": "module" }일 때, src/utility.cts의 이름을 src/utility.ts로 바꾸면 그 파일은 어떤 형식으로 출력되나요? 그러면 export = utility;는 어떻게 되나요?

해설 보기

.ts 파일은 가장 가까운 package.json의 "type"을 따르므로, 이제 ES 모듈로 출력됩니다. export =는 CommonJS의 module.exports를 위한 문법이어서 ES 모듈로 출력하는 파일에서는 쓸 수 없다는 진단이 나오므로, export default utility;로 바꾸거나 확장자를 .cts로 되돌려야 합니다. 가져오는 쪽의 경로도 출력 파일의 이름에 맞추어 ./utility.js로 고쳐야 합니다.

다음 글 네임스페이스 사용법에서는 모듈 이전에 타입스크립트가 코드를 묶던 방법인 네임스페이스와, 지금 그것을 쓰는 자리가 어디인지 다룹니다.