본문 바로가기

CommonJS와의 상호 운용성

ES 모듈과 CommonJS의 내보내기 형식을 비교하고 module 옵션·상호 운용 플래그·타입 패키지로 Node 의존성을 연결합니다.

타입스크립트는 ES 모듈(import/export)을 강력하게 지원합니다.

하지만 Node.js 생태계에서는 오랫동안 CommonJS(require/module.exports)가 사실상 표준이었습니다.

지금도 많은 기존 프로젝트와 npm 패키지가 CommonJS를 사용합니다.

그래서 타입스크립트 프로젝트를 개발할 때 CommonJS 모듈과의 상호 운용은 여전히 자주 마주치는 과제입니다.

타입스크립트는 tsconfig.json 파일의 module 컴파일러 옵션을 통해 ES 모듈 코드를 CommonJS를 포함한 다양한 모듈 형식으로 트랜스파일(Transpile)할 수 있게 하며, 반대로 CommonJS 모듈을 타입스크립트 프로젝트에서 가져와 사용할 수 있도록 지원합니다.


module 컴파일러 옵션

tsconfig.json 파일의 compilerOptions.module 설정은 타입스크립트 코드를 어떤 모듈 시스템으로 컴파일할지 결정합니다.

  • "ESNext" 또는 "ES2015" 이상: ES 모듈(import/export) 구문을 그대로 유지하거나 최신 자바스크립트 표준에 맞춰 컴파일합니다. 웹팩(Webpack), Vite 등 최신 번들러와 함께 사용하기에 적합합니다.
  • "CommonJS": import와 export 구문을 CommonJS의 require와 module.exports 구문으로 변환합니다. 출력 파일을 Node.js가 CommonJS로 판정하도록 패키지 설정과 확장자도 맞춰야 합니다.
예시: module 옵션에 따른 컴파일 결과 원본 TypeScript 코드 (myModule.ts)
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}!`);
  }
}
tsconfig.json 설정
tsconfig.json
{
  "compilerOptions": {
    "target": "es2018",
    "module": "CommonJS", // 또는 "ESNext"
    "rootDir": "./src",
    "outDir": "./dist",
    "strict": true
  }
}
출력 형태 예시 (dist/myModule.js) - module: "CommonJS"일 때
dist/myModule.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.sayHello = exports.greeting = void 0;
exports.greeting = "Hello";
function sayHello(name) {
    console.log(`${exports.greeting}, ${name}!`);
}
exports.sayHello = sayHello;
class Greeter {
    greet(name) {
        console.log(`Greetings from Greeter, ${name}!`);
    }
}
exports.default = Greeter;

export 구문이 exports.member = ... 또는 exports.default = ... 형태로 변환된 것을 볼 수 있습니다.

컴파일 결과 (dist/myModule.js) - module: "ESNext"일 때
// target이 es2018이므로, es2018의 문법을 따르되 모듈은 esnext를 따릅니다.
export const greeting = "Hello";
export function sayHello(name) {
    console.log(`${greeting}, ${name}!`);
}
export default class Greeter {
    greet(name) {
        console.log(`Greetings from Greeter, ${name}!`);
    }
}

import/export 구문이 그대로 유지되는 것을 볼 수 있습니다.

이 출력 예시는 모듈 형식의 차이를 읽기 위한 것입니다. 소스 폴더와 실제 진입 파일까지 만들고 실행하는 것은 별도 단계입니다. TypeScript 6.0에서 구형 node10 해석이 선택되면 폐기 예정 진단이 날 수 있으므로, 아래 직접 실행 실습은 module과 moduleResolution을 모두 NodeNext로 지정합니다. package.json의 type이 각 .ts와 출력 .js의 형식을 연결합니다.

이는 ES 모듈을 지원하는 브라우저·Node.js 또는 번들러에서 사용할 수 있습니다. Node.js에서는 패키지의 type과 파일 확장자를 함께 맞춥니다.


CommonJS 모듈 가져오기

본문 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를 사용하세요.

타입스크립트 프로젝트에서 CommonJS 모듈을 가져와 사용하는 것은 매우 자연스럽습니다.

타입스크립트 컴파일러는 .js 파일이나 .d.ts (타입 정의 파일)를 기반으로 CommonJS 모듈의 타입을 추론하려고 시도합니다.

require 구문 사용 (타입스크립트 런타임이 CommonJS인 경우)

module 옵션이 "CommonJS"로 설정된 경우, import 문은 내부적으로 require로 변환됩니다.

하지만 타입스크립트 소스 코드에서는 여전히 import 구문을 사용하는 것이 일반적입니다.

tsconfig.json

tsconfig.json
{
  "compilerOptions": {
    "module": "CommonJS",
    // ...
  }
}
myUtility.js
module.exports = {
  add: (a, b) => a + b,
  subtract: (a, b) => a - b
};
app.ts
// app.ts
import { add, subtract } from './myUtility'; // TypeScript는 .js 파일의 export를 이해하여 타입을 추론

console.log(add(10, 5));
console.log(subtract(10, 5));

allowJs로 myUtility.js를 포함하면 JavaScript에서 내보내기를 분석할 수 있습니다. 타입 선언도 없고 JavaScript 분석 대상도 아니라면 설정에 따라 any가 되거나 선언 누락 오류가 납니다.

명시적인 타입 안전성을 위해서는 declare module 또는 @types 패키지를 통해 타입 정의를 제공하는 것이 좋습니다.

JavaScript의 분석을 켜도 모든 매개변수의 구체 타입이 자동으로 정해지는 것은 아닙니다. 위 myUtility.js에는 숫자 매개변수 선언이 없으므로, 호출 계약을 숫자로 제한하려면 선언 파일이나 주석 타입 또는 TypeScript 구현에서 이를 적어야 합니다. 정적 선언은 실제 JavaScript 구현을 대신하지 않습니다.

esModuleInterop 옵션

CommonJS와 ES 모듈 간 호환성을 높이기 위해 tsconfig.json에서 compilerOptions.esModuleInterop: true 설정을 강력히 권장합니다.

이 옵션을 활성화하면 CommonJS 모듈의 export default 유사 동작을 ES 모듈의 import default 구문으로 자연스럽게 가져올 수 있도록 컴파일러가 추가 헬퍼 코드를 생성합니다.

tsconfig.json
{
  "compilerOptions": {
    "module": "CommonJS",
    "esModuleInterop": true, // 이것을 true로 설정
    // ...
  }
}
esModuleInterop: true가 없을 때의 문제점

CommonJS는 module.exports 자체에 값을 대입하거나 그 객체에 속성을 추가할 수 있습니다. exports.default는 이름이 default인 속성으로, 모듈 전체 값과 같지 않습니다.

esModuleInterop: false 상태에서 import SomeLib from 'some-lib'처럼 CommonJS 모듈 기본 내보내기를 가져오면, 실제 모듈에 default 속성이 없어 런타임 오류(undefined)가 발생할 수 있습니다.

esModuleInterop: true로 설정하면, TypeScript는 컴파일된 JavaScript 코드에 __importDefault 헬퍼 함수를 추가하여 이러한 비호환성을 자동으로 처리해줍니다.

이 비교는 기존 CommonJS 출력의 동작을 이해하기 위한 것입니다. TypeScript 6.0에서는 esModuleInterop: false에 폐기 예정 진단이 나므로 새 실습에서 꺼서 비교할 필요는 없습니다. 또한 ESM으로 그대로 출력하는 구성에서는 Node의 로더가 실제 연결을 맡습니다. 컴파일러의 헬퍼를 켠다는 사실과 모든 CommonJS 패키지의 이름 있는 내보내기가 보장된다는 주장은 구분합니다.

// date-fns v2의 subpath default export를 전제로 한 예시입니다.
// v3부터는 named export로 변경되었으므로 설치 버전에 맞는 import를 사용합니다.
import { format } from 'date-fns';
import addDays from 'date-fns/addDays'; // v2의 subpath default export

const today = new Date();
console.log(format(today, 'yyyy-MM-dd'));
console.log(addDays(today, 7));

@types 패키지를 통한 타입 지원

라이브러리에 자체 타입 선언이 없다면 DefinitelyTyped의 타입 정의 파일(.d.ts)을 확인합니다. 자체 types와 exports를 제공하는 패키지는 그 선언을 먼저 사용합니다.

이 타입 정의 파일들은 @types/<패키지명> 형태로 npm에 배포되며, 이를 설치하면 타입스크립트가 해당 라이브러리의 타입을 인식하여 타입 안전성을 확보할 수 있습니다.

예시: lodash 라이브러리 사용 시

npm install lodash # lodash 라이브러리 설치
npm install --save-dev @types/lodash # lodash의 타입 정의 파일 설치

이제 타입스크립트 코드에서 lodash를 타입 안전하게 사용할 수 있습니다.

import _ from 'lodash'; // lodash는 기본 내보내기를 하는 CommonJS 모듈이므로, esModuleInterop: true 필요

const arr = [1, 2, 3, 4, 5];
console.log(_.sum(arr)); // 15
console.log(_.shuffle(arr)); // [4, 1, 5, 2, 3] (예시)

Node.js 환경에서 타입스크립트 실행 시 고려사항

현재 Node.js는 별도 실험 플래그 없이 ES 모듈을 지원합니다. .mjs 또는 package.json의 "type": "module" 등으로 파일 형식을 명확히 지정합니다.

Node.js 프로젝트에서 타입스크립트를 사용할 때는 다음과 같은 방식들을 고려할 수 있습니다.

CommonJS로 컴파일 후 Node.js에서 실행: tsconfig.json의 module을 "CommonJS"로 설정하고, 출력 .js가 CommonJS로 해석되도록 패키지의 "type": "commonjs" 등도 맞춥니다.

# tsconfig.json: "module": "CommonJS"
tsc
node dist/index.js

ES 모듈로 컴파일 후 Node.js ESM에서 실행: Node.js에서 직접 실행할 아래 예제는 module과 moduleResolution을 "NodeNext"로 맞추고, package.json의 "type": "module"로 출력 .js를 ES 모듈로 해석합니다. 상대 import에는 출력 파일 확장자도 지정합니다.

package.json
{
  "type": "module"
}
tsconfig.json
{
  "compilerOptions": {
    "module": "NodeNext",
    "target": "ESNext",
    "outDir": "./dist",
    "esModuleInterop": true,
    "moduleResolution": "NodeNext" // module과 Node.js 해석 규칙을 맞춤
  }
}
tsc
node dist/index.js # Node.js 런타임이 ES 모듈을 인식함

이 방식은 최신 Node.js 환경에서 ES 모듈의 이점을 활용할 수 있게 하지만, 모든 CommonJS 모듈과의 호환성을 보장하기 어려울 수 있습니다.

외부 패키지 없이 두 실행 형식을 비교하기

앞의 myUtility.js와 별개의 작은 실습입니다. TypeScript가 준비된 독립 폴더에 src/utility.cts와 src/app.ts를 만듭니다. .cts는 CommonJS로 출력되는 TypeScript 파일이며 결과 확장자는 .cjs입니다. export =는 이 예제에서 객체 전체를 내보냅니다.

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": "ES2020",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "types": []
  },
  "include": ["src/**/*"]
}

최상위 package.json을 { "type": "commonjs" }로 저장한 뒤 tsc -p ., node dist/app.js를 실행합니다. 출력은 15, 5입니다. dist/utility.cjs는 객체 전체를 module.exports에 대입하고, dist/app.js는 CommonJS 가져오기 형태를 사용합니다. 헬퍼의 자세한 배치는 컴파일러 버전에 따라 달라질 수 있으므로 내보낸 전체 값과 실제 호출 결과를 확인합니다.

이제 package.json만 { "type": "module" }로 바꾸어 다시 컴파일하고 실행합니다. utility.cts의 출력은 계속 CommonJS이고 app.ts의 출력은 ESM입니다. 이 로컬 예제의 출력은 계속 15, 5입니다. dist/app.js에는 기본 import가 남고 Node가 .cjs의 전체 값을 연결합니다. 이 결과를 다른 패키지의 모든 이름 있는 내보내기까지 일반화하지 않습니다.

다른 값으로 확인하기

모듈 선언은 그대로 두고 두 호출의 인자를 7, 2로 바꾸면 각 출력은 무엇일까요? utility.add('7', 2)는 실행 전에 허용될지도 설명해 보세요. 필요한 파일을 찾지 못하는 문제와 인자 타입이 틀린 문제를 구별합니다.

전체 값과 호출 계약 확인

출력은 9, 5입니다. 문자열 인자는 add의 첫 매개변수 number에 맞지 않아 진단됩니다. 기본 가져오기는 두 함수를 가진 객체 전체를 받는 이 예제의 내보내기 모양과 연결합니다. exports.default라는 속성을 따로 추가하는 구현으로 바꾸면 공개된 값의 모양도 달라지므로 같은 계약으로 단정하지 않습니다.

출력 모드와 해석의 연결은 TypeScript 모듈 참조에서 대조할 수 있습니다. 이 실습은 외부 패키지 없이 로컬 모듈의 타입과 실행을 확인합니다. 위의 date-fns·lodash 예제를 적용할 때는 해당 패키지의 설치 버전과 내보내기 경로를 따로 확인합니다.


CommonJS와의 상호 운용성은 타입스크립트 개발에서 피할 수 없는 중요한 부분입니다.

tsconfig.json의 module과 esModuleInterop 옵션을 적절히 설정하고, 필요하다면 @types 패키지를 활용하여 CommonJS 라이브러리에 대한 타입 정의를 추가함으로써, 타입스크립트의 타입 안전성을 유지하면서도 광범위한 자바스크립트 생태계를 활용할 수 있습니다.

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 직접 실행 구성입니다.

마지막으로 실제 프로젝트에서 오류를 줄이려면 컴파일 출력, 런타임 해석, 타입 선언을 한 번에 점검해야 합니다.

CommonJS 호환 문제는 import 문법만 보고 판단하기 어렵기 때문에, 컴파일 결과와 Node.js 실행 규칙, 타입 선언 형태를 같은 줄에 놓고 비교해야 합니다.