<script src="jquery.js"></script> <!-- 전역에 $ 생성 -->
<script src="utils.js"></script> <!-- 전역에 utils 생성 -->
<script src="app.js"></script> <!-- 전역에서 $, utils 사용 -->// math.js
function add(a, b) { return a + b }
module.exports = { add } // ← Node.js가 런타임에 주입하는 객체// app.js
const { add } = require('./math') // ← Node.js가 런타임에 주입하는 함수// Node.js가 실제로 파일을 실행하는 방식
(function(exports, require, module, __filename, __dirname) {
// 우리가 작성한 코드
function add(a, b) { return a + b }
module.exports = { add }
})// 전부 유효한 CJS 코드
if (process.env.NODE_ENV === 'test') {
const mock = require('./mock') // ← 조건부 로드
}
function loadPlugin(name) {
return require(`./plugins/${name}`) // ← 동적 경로
}
setTimeout(() => {
const config = require('./config') // ← 나중에 로드
}, 1000)require('./math') 호출
1. 캐시 확인 — 이미 로드된 모듈이면 캐시에서 반환
2. 파일 탐색 — ./math.js, ./math/index.js 순으로 탐색
3. 파일 읽기 — fs.readFileSync()로 동기적으로 읽음
4. 래퍼 함수로 감싸서 실행
5. module.exports 반환
6. 캐시에 저장// counter.js
let count = 0
function increment() { count++ }
module.exports = { count, increment }// app.js
const { count, increment } = require('./counter')
console.log(count) // 0
increment()
console.log(count) // 0 ← 여전히 0. count는 복사된 값이기 때문// math.js
export function add(a, b) { return a + b }// app.js
import { add } from './math.js'// 문법 에러
if (condition) {
import { add } from './math.js' // ← SyntaxError
}
// 동적으로 불러올 때는 별도 문법을 쓴다
const { add } = await import('./math.js') // ← dynamic import, Promise 반환app.js 파싱
→ import { add } from './math.js' 발견
→ math.js 파싱
→ math.js의 import 확인
→ 더 이상 없으면 완료
→ 의존성 그래프 완성math.js의 export add → 메모리 주소 0x001 할당
app.js의 import add → 0x001 주소를 참조하도록 연결
(아직 값은 없음, 주소만 연결됨)math.js 실행 → add 함수 생성 → 0x001에 저장
app.js 실행 → add를 참조할 때 0x001의 값을 읽음
// counter.mjs
let globalCount = 0; // 싱글톤
export function createCounter() {
let count = 0
return {
increment() { count++ },
getCount() { return count }
}
}
// file-a.mjs
import { createCounter } from './counter.mjs'
const counterA = createCounter() // ← 새 클로저, 독립적인 count
// file-b.mjs
import { createCounter } from './counter.mjs'
const counterB = createCounter() // ← 또 다른 클로저, 독립적인 count// counter.mjs
export let count = 0
export function increment() { count++ } // ← 원본 count를 직접 변경// app.mjs
import { count, increment } from './counter.mjs'
console.log(count) // 0
increment()
console.log(count) // 1 ← 라이브 바인딩이라 원본 변경이 반영됨// a.js (CJS)
const { valueB } = require('./b') // ← b.js 로드 시작
module.exports = { valueA: 'A', valueB }
// b.js (CJS)
const { valueA } = require('./a') // ← a.js가 아직 로드 중
// 미완성 module.exports({}) 반환
module.exports = { valueB: 'B', valueA } // valueA는 undefined// CJS — 번들러 입장에서는 결국 _ 전체가 필요
const _ = require('lodash')
_.debounce(fn, 300)// ESM — 번들러가 debounce만 사용한다는 걸 빌드 타임에 확정
import { debounce } from 'lodash-es'
debounce(fn, 300)lodash (CJS) → ~70KB (전체 포함)
lodash-es (ESM) → ~2KB (debounce만 사용 시)<script type="module" src="app.js"></script>Webpack 개발 서버:
코드 변경 → 전체(또는 청크) 다시 번들링 → 번들 파일 전달
Vite 개발 서버:
코드 변경 → 해당 파일만 다시 변환 → 브라우저가 직접 요청// ESM — 모듈 최상단에서 await 사용 가능
const config = await fetch('/api/config').then(r => r.json())
const db = await connectDatabase()
export { config, db }// CJS — 최상단 await 불가
// 비동기 초기화가 필요하면 이런 우회 패턴을 써야 했다
let config
async function init() {
config = await fetch('/api/config').then(r => r.json())
}
init() // 언제 완료될지 보장 없음
module.exports = {
getConfig: () => config // ← init()이 끝나기 전 호출되면 undefined
}// CJS — 타입 추론이 어려운 패턴들
const moduleName = getModuleName()
const mod = require(moduleName) // ← 어떤 타입인지 알 수 없음
const { utils } = require('./utils')
utils.doSomething() // ← utils의 타입을 런타임 전에 알기 어려움// ESM — 정적으로 타입 추론 가능
import { doSomething } from './utils' // ← IDE가 doSomething의 타입을 즉시 알 수 있음.mjs → 항상 ESM
.cjs → 항상 CJS
.js → package.json의 "type" 필드에 따라 결정
"type": "module" → ESM
"type": "commonjs" → CJS (기본값, 생략 시)// CJS — 자동으로 사용 가능
console.log(__dirname) // /Users/kim/project/src
console.log(__filename) // /Users/kim/project/src/app.js
// ESM — 직접 만들어야 함
import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// Node.js 22+에서는 기본 제공
console.log(import.meta.dirname) // /Users/kim/project/src
console.log(import.meta.filename) // /Users/kim/project/src/app.js{
"exports": {
"import": "./dist/index.mjs", // ← ESM 환경에서 로드
"require": "./dist/index.cjs" // ← CJS 환경에서 로드
}
}앱 (ESM)
├── packageA (ESM 버전 로드) → 인스턴스 #1
└── packageB (CJS)
└── packageA (CJS 버전 로드) → 인스턴스 #2
↑
동일 패키지인데 인스턴스가 두 개항목 | CJS | ESM |
문법 | require() / module.exports | import / export |
분석 시점 | 런타임 (동적) | 파싱 타임 (정적) |
로딩 방식 | 동기 | 비동기 (세 단계) |
내보낸 값 | 복사본 | 라이브 바인딩 (참조) |
Tree Shaking | 불가 | 가능 |
브라우저 지원 | 불가 (번들러 필요) | 네이티브 지원 |
Top-level await | 불가 | 가능 |
조건부 로드 | 가능 | dynamic import 사용 |
순환 참조 | 불안정 (복사 시점 문제) | 안정적 (라이브 바인딩) |
Node.js 기본값 | O (.js) | .mjs 또는 "type": "module" |
{
"type": "module"
}{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler" // ← Vite, webpack 등 번들러 환경
// "moduleResolution": "NodeNext" ← Node.js ESM 환경 (확장자 명시 필요)
}
}npx tsup src/index.ts --format cjs,esm
# dist/index.js ← CJS
# dist/index.mjs ← ESM{
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
}
}