8.1 ESM vs CJS — 모듈 시스템 완전 이해
모듈 시스템이란?
JavaScript에는 두 가지 주요 모듈 시스템이 있습니다.
- CJS (CommonJS): Node.js에서 기본으로 사용하던 방식 (
require/module.exports) - ESM (ES Modules): 브라우저와 Node.js 모두 지원하는 표준 방식 (
import/export)
TypeScript는 두 방식을 모두 지원하지만, 올바르게 설정하지 않으면 런타임 오류가 발생합니다.
CJS (CommonJS)
기본 문법
// math.js (CJS)
function add(a, b) {
return a + b;
}
module.exports = { add };
// main.js (CJS)
const { add } = require('./math');
console.log(add(1, 2)); // 3
TypeScript에서 CJS
// tsconfig.json: "module": "CommonJS"
// math.ts
export function add(a: number, b: number): number {
return a + b;
}
// main.ts
import { add } from './math';
// 컴파일 결과: const { add } = require('./math');
ESM (ES Modules)
기본 문법
// math.ts (ESM)
export function add(a: number, b: number): number {
return a + b;
}
export default function multiply(a: number, b: number): number {
return a * b;
}
// main.ts (ESM)
import multiply, { add } from './math.js'; // ESM에서는 .js 확장자 필수!
console.log(add(1, 2)); // 3
console.log(multiply(2, 3)); // 6
package.json 설정
{
"type": "module"
}
"type": "module" 설정 시 .js 파일이 ESM으로 처리됩니다.
tsconfig.json module 옵션
{
"compilerOptions": {
"module": "CommonJS", // Node.js CJS
"module": "ESNext", // 최신 ESM
"module": "NodeNext", // Node.js ESM (Node16과 동일)
"module": "Preserve", // 입력 형식 그대로 유지 (TS 5.4+)
"moduleResolution": "node", // 전통적인 Node 해석
"moduleResolution": "bundler", // Vite/webpack 등 번들러용 (TS 4.9+)
"moduleResolution": "NodeNext" // Node.js ESM 해석
}
}
module vs moduleResolution
| 옵션 | 역할 |
|---|---|
module | TypeScript가 출력할 모듈 형식 |
moduleResolution | import 경로를 어떻게 해석할지 |
ESM에서의 파일 확장자 규칙
ESM 모드(NodeNext)에서 TypeScript는 .ts 파일을 작성하지만 import는 .js로 해야 합니다.
// ✅ 올바른 방법 (NodeNext 모드)
import { add } from './math.js'; // .ts 파일이지만 .js로 import
// ❌ 틀린 방법
import { add } from './math'; // 확장자 없음 — 오류 발생
import { add } from './math.ts'; // .ts 확장자 — 오류 발생
왜 .js로 써야 하나요?
TypeScript는 .ts → .js로 컴파일하므로, 런타임에는 .js 파일이 존재합니다. NodeNext 모드는 이 런타임 해석을 그대로 따릅니다.
CJS ↔ ESM 인터롭 (Interop)
ESM에서 CJS 모듈 가져오기
// CJS 라이브러리를 ESM에서 사용
import express from 'express'; // default import
import { Router } from 'express'; // named import
import * as fs from 'fs'; // namespace import
esModuleInterop 옵션
{
"compilerOptions": {
"esModuleInterop": true // 권장: CJS ↔ ESM 호환성 개선
}
}
esModuleInterop: true 없이는 import express from 'express'가 오류를 냅니다.
// esModuleInterop: false (구형)
import * as express from 'express';
const app = express();
// esModuleInterop: true (권장)
import express from 'express';
const app = express();
allowSyntheticDefaultImports
esModuleInterop: true를 설정하면 자동으로 활성화됩니다. default export가 없는 CJS 모듈도 import X from '...'로 가져올 수 있게 해줍니다.
moduleResolution: "bundler" 완전 이해
TypeScript 4.9에서 추가된 bundler 모드는 Vite, webpack, esbuild 등 번들러 환경에 최적화되어 있습니다.
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true // .ts/.tsx 확장자 허용
}
}
// bundler 모드: 확장자 없이 import 가능
import { add } from './math'; // ✅ 번들러가 처리
import { Button } from './Button'; // ✅ .tsx도 자동 해석
모드별 비교표
| 모드 | 확장자 필요 | 사용 환경 |
|---|---|---|
node | 불필요 | 구형 Node.js CJS |
NodeNext | 필요 (.js) | Node.js ESM |
bundler | 불필요 | Vite, webpack, Next.js |
실전 설정 예시
Node.js CJS 프로젝트 (Express API)
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true
}
}
Node.js ESM 프로젝트
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true
}
}
// package.json
{
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
Vite / Next.js 프론트엔드 프로젝트
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true
}
}
흔한 실수와 해결책
오류 1: Cannot use import statement in a module
SyntaxError: Cannot use import statement in a module
원인: package.json에 "type": "module" 없이 ESM 문법 사용
해결: "type": "module" 추가 또는 module: CommonJS로 변경
오류 2: ERR_REQUIRE_ESM
Error [ERR_REQUIRE_ESM]: require() of ES Module ...
원인: CJS 코드에서 ESM 전용 패키지를 require()로 불러옴
해결:
// 동적 import 사용
const { default: chalk } = await import('chalk');
오류 3: 확장자 관련 오류 (NodeNext 모드)
Relative import paths need explicit file extensions in ECMAScript imports.
해결: import 경로에 .js 확장자 추가
import { helper } from './helper.js'; // ✅
고수 팁
1. 프로젝트 유형별 권장 설정
백엔드 (Node.js): module: NodeNext + moduleResolution: NodeNext
프론트엔드: module: ESNext + moduleResolution: bundler
라이브러리: module: NodeNext (dual CJS/ESM 패키징 고려)
2. dual package (CJS + ESM 동시 지원)
// package.json
{
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
}
}
3. verbatimModuleSyntax (TS 5.0+)
{
"compilerOptions": {
"verbatimModuleSyntax": true
}
}
타입 전용 import를 명시적으로 표시하도록 강제합니다.
import type { User } from './types'; // ✅ 타입 전용
import { type User, getUser } from './api'; // ✅ 혼합