1) 요약
- ESM: 표준 모듈 시스템. import/export, 정적 분석 가능, 트리 셰이킹 유리, Top-Level await 가능, 브라우저/Node 공통 표준.
- CJS: Node 전통 모듈. require/module.exports, 런타임 로딩(동적), 간편하지만 정적 분석·트리 셰이킹에 불리.
실무적으로:
- 새 프로젝트·프론트엔드·라이브러리는 ESM 권장.
- 레거시 Node 코드나 간단한 스크립트는 CJS가 편할 수 있음.
2) 문법 비교
ESM
// math.js (또는 .mjs / ESM 프로젝트면 .js도 가능)
export const add = (a, b) => a + b;
export default function mul(a, b) { return a * b; }
// 사용처
import mul, { add } from './math.js'; // 확장자 주의(ESM+Node 규칙)
console.log(add(2,3), mul(2,3));
특징:
- 정적 import: 컴파일 시점에 의존성 그래프가 고정 → 트리 셰이킹 가능.
- Top-Level await 가능:
- const data = await fetch('https://...').then(r=>r.json());
- 모듈 스코프에서 this === undefined (브라우저 ESM과 일치).
CJS
// math.cjs (또는 .js, CJS 프로젝트면 기본 .js)
exports.add = (a, b) => a + b;
module.exports.mul = (a, b) => a * b;
// 사용처
const { add, mul } = require('./math.cjs');
console.log(add(2,3), mul(2,3));
특징:
- 동적 require: 런타임에 조건부 로딩 가능.
- 모듈 스코프에서 this === module.exports.
- Top-Level await 불가(별도 트랜스파일/러너 요건 제외).
3) 런타임 동작/해석 차이
해석 시점
- ESM: 정적 의존성 파악 → 번들러 최적화, 사이드이펙트 최소화.
- CJS: require가 실행될 때 로딩 → 조건부/동적 로딩 쉬움.
바인딩 특성
- ESM: 라이브 바인딩. 원본이 바뀌면 import한 쪽도 갱신을 “볼 수” 있음(재할당은 안 됨).
- CJS: 값이 복사(스냅샷) 되어 넘어오는 경우가 흔함(객체 참조 제외).
순환 의존성
- ESM: 순환에도 대비되지만 초기화 순서를 주의.
- CJS: 순환 시 exports가 부분적으로 채워진 상태로 노출될 수 있어 더 주의.
4) Node.js에서의 설정/파일 확장자
ESM로 프로젝트 운영
- package.json
- { "type": "module" }
- 파일 확장자:
- 프로젝트가 ESM이면 일반 .js가 ESM으로 인식.
- 개별 파일을 강제 ESM/CJS로 나누고 싶다면:
- ESM: *.mjs
- CJS: *.cjs
- 상대 경로 import는 확장자 필수:
- import { foo } from './utils.js'; // 소스가 utils.ts여도 .js로 적기(빌드 산출 기준)
CJS로 프로젝트 운영
- package.json에 type을 넣지 않거나 "type": "commonjs" 명시.
- 기본 .js는 CJS로 인식, ESM은 *.mjs로 구분 가능.
5) TypeScript에서의 차이/권장 설정
ESM(+Node) 권장 tsconfig
{
"compilerOptions": {
"module": "NodeNext", // Node ESM 규칙
"moduleResolution": "NodeNext",
"target": "ES2022",
"lib": ["ES2022"],
"verbatimModuleSyntax": true, // import/export를 TS가 임의 변환하지 않음
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"baseUrl": "src",
"sourceMap": true
},
"include": ["src/**/*"],
"ignoreDeprecations": "6.0"
}
- 중요: ESM 규칙에서는 import 경로에 .js 확장자를 적는다.
- 경로 별칭은 번들러/런타임 해석기(tsconfig-paths)로 보완.
CJS 권장 tsconfig
{
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "node",
"target": "ES2020",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"ignoreDeprecations": "6.0"
}
6) 상호 운용(ESM ↔︎ CJS) 패턴
ESM에서 CJS 패키지 사용
- 보통 default import로 들어옴:
- import express from 'express'; // 내부는 CJS지만 default로 매핑되는 경우 많음
- 예외 케이스가 있으면 createRequire 사용:
- import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const cjsOnly = require('some-cjs-only');
CJS에서 ESM 패키지 사용
- 정적 require 불가 → 동적 import() 사용:
- (async () => { const { default: dayjs } = await import('dayjs'); // ESM console.log(dayjs().format()); })();
ESM에서 Node 내장 CJS 유틸 대체
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
7) 번들링·최적화 관점
- ESM: 정적 의존성 → 트리 셰이킹과 미니파이에 유리. 라이브러리나 프론트엔드에서는 사실상 표준.
- CJS: 정적 분석 어려움 → 트리 셰이킹 불리. Node 전용 도구나 스크립트형 유틸에는 여전히 실용적.
8) 테스트 러너/빌드 도구
- Vitest: ESM 친화적. ESM 프로젝트면 거의 “그냥 동작”.
- Jest: ESM 제약이 큼. ts-jest의 useESM: true, extensionsToTreatAsEsm 설정, NODE_OPTIONS=--experimental-vm-modules 등 필요.
- 빌드: 라이브러리는 tsup/esbuild/rollup로 ESM + CJS 이중 타깃 산출을 자주 함.
- 예: "exports"에 import(ESM)와 require(CJS) 경로를 모두 제공.
9) 마이그레이션 체크리스트(CJS → ESM)
- package.json에 "type": "module" 추가(또는 파일별 .mjs로 점진적 전환).
- require/module.exports → import/export로 변환.
- 상대 import 경로에 .js 확장자 추가.
- __dirname/__filename 치환 코드 적용(fileURLToPath).
- CJS 전용 패키지는 default import 또는 createRequire로 처리.
- 테스트 러너(Vitest/Jest) ESM 설정 맞추기.
- TS 사용 시 module: "NodeNext", moduleResolution: "NodeNext", verbatimModuleSyntax: true.
- 실행 환경(Node 18+)과 target/lib 정합 확인.
- CI/배포 스크립트 업데이트(예: node dist/index.js).
10) 어느 쪽을 선택할까?
- 새 프로젝트/라이브러리/프론트엔드/장기 유지보수: ESM 권장.
- 레거시 코드/간단 스크립트/도입 비용 최소: CJS 유지 가능.
- 패키지 배포: 사용자 호환을 위해 ESM + CJS 동시 제공이 베스트.