--- id: js-module-system-esm-cjs title: JS Module System — ESM vs CJS 그리고 dual package category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [javascript, esm, cjs, package, vibe-coding] tech_stack: { language: "Node.js / TypeScript / bundler", applicable_to: ["Backend", "Library"] } applied_in: [] aliases: [import/export, require, exports field, package.json] --- # JS 모듈 시스템 — ESM vs CJS > 2026 현재도 라이브러리 작성자에게 가장 골치 아픈 영역. **신규 코드는 ESM 디폴트**, but Node 일부 도구 / 레거시는 CJS 만 — package.json `exports` 의 dual export 가 표준 답. ## 📖 핵심 개념 - ESM: `import`/`export`. 정적 분석 가능. tree-shake 친화. 비동기 가능. - CJS: `require()`/`module.exports`. 동적 (조건부 require). 동기. - Node.js: `package.json` `"type": "module"` 이면 .js = ESM. 아니면 CJS. - `.mjs` = 항상 ESM, `.cjs` = 항상 CJS. ## 💻 코드 패턴 ### 라이브러리 dual package (ESM + CJS) ```json { "name": "my-lib", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs", "default": "./dist/index.mjs" }, "./package.json": "./package.json" }, "files": ["dist"] } ``` ### TypeScript 빌드 — tsup 또는 unbuild ```json // package.json scripts { "build": "tsup src/index.ts --format esm,cjs --dts" } ``` ### Subpath exports ```json { "exports": { ".": "./dist/index.mjs", "./parser": "./dist/parser.mjs", "./internal": null } } ``` `null` → 임포트 차단. ### CJS 안에서 ESM 동적 import ```ts // CJS 환경 (.cjs) async function loadESM() { const mod = await import('esm-only-package'); // 동적 import 만 가능 return mod.default; } ``` ### ESM 안에서 CJS import ```ts // ESM (.mjs / "type":"module") import lodash from 'lodash'; // CJS default — Node 가 자동 wrap import { merge } from 'lodash'; // named export 동작 (Node 가 분석) ``` ## 🤔 의사결정 기준 | 상황 | 형식 | |---|---| | 신규 라이브러리 | ESM-first + dual export | | 신규 앱 (Next/Vite) | ESM (bundler 가 처리) | | Node 서버 (TypeScript) | ESM with `"type": "module"` | | 옛 도구 의존성 다수 (postcss plugin, eslint plugin) | CJS 또는 dual | | 동적 require 필요 | CJS (또는 ESM 의 `await import`) | | __dirname / __filename | ESM 에는 없음 — `import.meta.url` + fileURLToPath | ## ❌ 안티패턴 - **ESM 라이브러리에 `main` 만**: CJS 사용자 import 시 default 잘못 판정. exports 필드 명시. - **CJS + ESM 양쪽에서 같은 인스턴스 가정**: 두 번 evaluate 됨 → 두 인스턴스 (singleton 깨짐). - **`require("package")` 가 ESM-only 패키지**: 런타임 에러. 동적 import 로. - **`exports` 필드 빠뜨리고 internal path 노출**: 사용자가 `import 'pkg/dist/foo'` 가능. 명시 차단. - **TypeScript paths 만 의존**: 빌드 시 alias 안 풀림. tsc-alias 또는 bundler. - **순환 import**: ESM 도 깨짐. CJS 와 다른 방식으로 깨짐. 의존성 그래프 정리. - **default export 와 named 혼용**: tree shake / IDE 자동 import 헷갈림. named 권장. ## 🤖 LLM 활용 힌트 - 신규 = ESM. 라이브러리는 dual export. - exports 필드 + types + import + require 4종 세트. - Node ESM 환경: `import.meta.url`, top-level await OK. ## 🔗 관련 문서 - [[TypeScript_Const_Assertions]] - [[DevOps_Build_Performance]]