# 프론트엔드는 CJS에서 ESM으로 넘어가려고 할까?

JavaScript에서 모듈을 불러오는 방법은 두 가지다. Node.js가 오랫동안 사용해온 **CJS(CommonJS)** 와, ES2015 표준에 추가된 **ESM(ES Modules)** 이다.

라이브러리 문서에서 "This package is ESM only"라는 문구를 처음 봤을 때, 단순히 문법이 다른 것 정도로 생각했다. 그런데 파고들수록 두 시스템은 모듈을 불러오는 방식 자체가 근본적으로 달랐다. 단순히 `require` 대신 `import`를 쓰는 게 아니라, 브라우저와 런타임이 코드를 이해하는 방식이 바뀌는 것이었다.

### JavaScript에는 원래 모듈이 없었다?

JavaScript가 처음 설계됐을 때 모듈 시스템은 없었다. 브라우저에서 스크립트 파일을 순서대로 나열하는 게 전부였다.

```javascript
<script src="jquery.js"></script>     <!-- 전역에 $ 생성 -->
<script src="utils.js"></script>      <!-- 전역에 utils 생성 -->
<script src="app.js"></script>        <!-- 전역에서 $, utils 사용 -->
```

모든 변수가 전역 스코프에 쌓였다. 파일이 많아질수록 어떤 파일이 어떤 변수를 만드는지 추적하기 어려워졌고, 이름이 겹치면 나중에 로드된 쪽이 덮어썼다. 의존 순서를 틀리면 런타임에서야 에러가 났다.

### CJS — Node.js가 직접 만든 모듈 시스템

2009년 Node.js가 등장했다. 서버에서 수백 개의 파일을 다루려면 모듈 시스템이 필수였는데, 당시 JavaScript 표준에는 없었다. 그래서 Node.js가 직접 만든 게 **CommonJS(CJS)** 다.

```javascript
// math.js
function add(a, b) { return a + b }
module.exports = { add }             // ← Node.js가 런타임에 주입하는 객체
```

```javascript
// app.js
const { add } = require('./math')   // ← Node.js가 런타임에 주입하는 함수
```

### CJS의 내부 동작

`require`와 `module.exports`는 JavaScript 문법이 아니다. Node.js가 파일을 실행하기 직전에 **모듈 래퍼 함수**로 감싸서 인자로 주입하는 것들이다.

```javascript
// Node.js가 실제로 파일을 실행하는 방식
(function(exports, require, module, __filename, __dirname) {
  // 우리가 작성한 코드
  function add(a, b) { return a + b }
  module.exports = { add }
})
```

`require()`는 그냥 함수다. 그래서 코드 어디서든 호출할 수 있다.

```javascript
// 전부 유효한 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()`가 호출되면 Node.js는 아래 순서로 동작한다.

```javascript
require('./math') 호출
  1. 캐시 확인 — 이미 로드된 모듈이면 캐시에서 반환
  2. 파일 탐색 — ./math.js, ./math/index.js 순으로 탐색
  3. 파일 읽기 — fs.readFileSync()로 동기적으로 읽음
  4. 래퍼 함수로 감싸서 실행
  5. module.exports 반환
  6. 캐시에 저장
```

핵심은 **동기적**이라는 것이다. `require()`가 호출되면 파일을 다 읽고 실행할 때까지 다음 줄로 넘어가지 않는다.

### CJS의 값 복사 방식

CJS에서 `module.exports`로 내보낸 값은 **복사본**이다. 원본이 바뀌어도 가져간 쪽에서는 모른다.

```javascript
// counter.js
let count = 0
function increment() { count++ }
module.exports = { count, increment }
```

```javascript
// app.js
const { count, increment } = require('./counter')

console.log(count)   // 0
increment()
console.log(count)   // 0 ← 여전히 0. count는 복사된 값이기 때문
```

`count`를 구조분해할 때 그 시점의 값(`0`)이 복사됐다. 이후 `increment()`로 원본 `count`가 바뀌어도, 복사본은 그대로다. 객체를 내보내면 참조가 공유되지만, 원시값은 항상 이 문제가 생긴다.

### ESM — 뒤늦게 나온 표준

2015년, ES6 표준에 드디어 JavaScript 언어 자체의 모듈 시스템이 들어왔다.

```javascript
// math.js
export function add(a, b) { return a + b }
```

```javascript
// app.js
import { add } from './math.js'
```

`import`는 함수 호출이 아니라 **선언**이다. 그래서 파일 최상단에만 위치할 수 있고, 조건부로 쓸 수 없다.

```javascript
// 문법 에러
if (condition) {
  import { add } from './math.js'   // ← SyntaxError
}

// 동적으로 불러올 때는 별도 문법을 쓴다
const { add } = await import('./math.js')  // ← dynamic import, Promise 반환
```

### ESM의 세 단계 로딩

ESM이 CJS와 근본적으로 다른 이유는, 모듈 로딩이 세 단계로 분리되기 때문이다.

**1단계 — Construction (파싱)**

코드를 실행하기 전에 모든 `import` 선언을 분석해서 의존성 그래프를 만든다. 재귀적으로 모든 모듈 파일을 파싱한다. 이 단계에서는 아직 코드가 실행되지 않는다.

```javascript
app.js 파싱
  → import { add } from './math.js' 발견
  → math.js 파싱
    → math.js의 import 확인
    → 더 이상 없으면 완료
  → 의존성 그래프 완성
```

**2단계 — Instantiation (링킹)**

각 모듈의 `export`에 메모리 공간을 할당한다. 아직 값은 없다. `import`를 해당 메모리 공간에 연결(link)한다. 이 연결이 **라이브 바인딩(live binding)** 이다.

```javascript
math.js의 export add → 메모리 주소 0x001 할당
app.js의 import add  → 0x001 주소를 참조하도록 연결
(아직 값은 없음, 주소만 연결됨)
```

**3단계 — Evaluation (평가)**

실제로 코드를 실행해서 메모리 공간에 값을 채운다.

```javascript
math.js 실행 → add 함수 생성 → 0x001에 저장
app.js 실행  → add를 참조할 때 0x001의 값을 읽음
```

이 구조 덕분에 ESM은 **라이브 바인딩**을 제공한다.

> **엇, 모듈을 다른 파일에서도 똑같이 호출했는데 값을 공유되는데 왜 그런걸까?**

모듈 자체는 항상 싱글톤 패턴으로 구성되다보니, 모듈은 딱 한번만 평가가 된다.

그래서 여러 파일이 같은 모듈을 `import` 해도 모두 같은 메모리 슬롯을 공유하게 되는 것이다.

그래서 만약 호출 시마다 독립적인 초기 값을 가지고 싶게 하고 싶다면, 팩토리 함수나 클래스 형태로 내보내야한다.

```javascript
// 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
```

### ESM의 라이브 바인딩

CJS와 달리 ESM에서 `import`한 값은 복사본이 아니라 **원본에 대한 살아있는 참조**다. 원본이 바뀌면 `import`한 쪽에서도 바뀐 값이 보인다.

```javascript
// counter.mjs
export let count = 0
export function increment() { count++ }   // ← 원본 count를 직접 변경
```

```javascript
// app.mjs
import { count, increment } from './counter.mjs'

console.log(count)   // 0
increment()
console.log(count)   // 1 ← 라이브 바인딩이라 원본 변경이 반영됨
```

CJS는 복사, ESM은 참조. 이 차이가 순환 참조 처리 방식을 바꾸고, 상태 공유 방식도 바꾼다.

### 순환 참조에서의 차이

CJS에서 A가 B를 require하고, B가 A를 require하면 문제가 생긴다.

```javascript
// 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
```

a.js를 로드하는 도중 b.js가 필요해서 b.js를 로드하는데, b.js가 다시 a.js를 요구한다. a.js는 아직 실행이 끝나지 않았으므로 Node.js는 현재까지 만들어진 불완전한 `module.exports`를 반환한다. 결과적으로 `valueA`가 `undefined`가 된다.

ESM은 다르다. 링킹 단계에서 메모리 주소를 먼저 연결해두기 때문에, 순환이 있어도 주소 연결 자체는 성공한다. 값이 채워지는 건 평가 단계에서 일어나고, 이때는 이미 양쪽이 서로를 참조하는 구조가 완성되어 있다.

### 왜 지금 ESM으로 넘어가는가

CJS가 잘 동작했는데도 ESM으로 가려는 이유는 현대 개발의 요구사항과 CJS의 설계 사이의 간극이 점점 커졌기 때문이다.

### Tree Shaking이 가능해진다

CJS에서는 `require()`가 런타임에 실행되기 때문에, 빌드 타임에 어떤 코드가 실제로 사용되는지 알 수 없다.

```javascript
// CJS — 번들러 입장에서는 결국 _ 전체가 필요
const _ = require('lodash')
_.debounce(fn, 300)
```

ESM에서는 파싱 타임에 의존성 그래프가 완성되므로, 번들러가 어떤 export가 실제로 사용되는지 정확하게 파악할 수 있다.

```javascript
// ESM — 번들러가 debounce만 사용한다는 걸 빌드 타임에 확정
import { debounce } from 'lodash-es'
debounce(fn, 300)
```

사용되지 않는 코드는 번들에서 제거된다. 이게 **Tree Shaking**이다.

```javascript
lodash (CJS)    → ~70KB (전체 포함)
lodash-es (ESM) → ~2KB (debounce만 사용 시)
```

### 브라우저에서 번들러 없이 동작한다

CJS의 `require()`는 Node.js 런타임이 주입하는 함수다. 브라우저는 이 함수를 모른다. CJS 코드를 브라우저에서 실행하려면 반드시 Webpack 같은 번들러로 변환해야 했다.

ESM은 JavaScript 표준이다. 브라우저가 직접 지원한다.

```javascript
<script type="module" src="app.js"></script>
```

브라우저가 `[app.js](https://app.js)`를 받으면, `import` 선언을 읽고 필요한 파일들을 직접 네트워크로 요청한다. 번들러 없이 모듈 시스템이 동작한다. 이게 프론트엔드 라이브러리로 대입하자면 `Vite`가 개발 환경에서 번들링 없이 빠르게 동작하는 이유다.

```javascript
Webpack 개발 서버:
  코드 변경 → 전체(또는 청크) 다시 번들링 → 번들 파일 전달

Vite 개발 서버:
  코드 변경 → 해당 파일만 다시 변환 → 브라우저가 직접 요청
```

### Top-level await를 쓸 수 있다

ESM의 모듈 로딩은 비동기다. 그래서 모듈 최상단에서 `await`를 쓸 수 있다.

```javascript
// ESM — 모듈 최상단에서 await 사용 가능
const config = await fetch('/api/config').then(r => r.json())
const db = await connectDatabase()

export { config, db }
```

CJS에서 `require()`는 동기 함수다. 비동기 작업이 완료될 때까지 기다릴 방법이 없다.

```javascript
// CJS — 최상단 await 불가
// 비동기 초기화가 필요하면 이런 우회 패턴을 써야 했다
let config

async function init() {
  config = await fetch('/api/config').then(r => r.json())
}

init()  // 언제 완료될지 보장 없음

module.exports = {
  getConfig: () => config   // ← init()이 끝나기 전 호출되면 undefined
}
```

### 정적 분석과 타입 추론이 정확해진다

`import`가 정적 선언이기 때문에, TypeScript 컴파일러나 IDE가 코드를 실행하지 않고도 의존성 구조를 정확히 파악할 수 있다.

```javascript
// CJS — 타입 추론이 어려운 패턴들
const moduleName = getModuleName()
const mod = require(moduleName)    // ← 어떤 타입인지 알 수 없음

const { utils } = require('./utils')
utils.doSomething()                // ← utils의 타입을 런타임 전에 알기 어려움
```

```javascript
// ESM — 정적으로 타입 추론 가능
import { doSomething } from './utils'   // ← IDE가 doSomething의 타입을 즉시 알 수 있음
```

자동완성이 더 정확해지고, 없는 export를 import하면 빌드 타임에 에러가 난다.

### Node.js가 ESM을 늦게 지원한 이유

ESM 표준이 2015년에 나왔는데, Node.js에서 안정적으로 지원된 건 2019년(Node.js 12)이다. 4년의 공백이 있다.

**비동기 설계 충돌**

브라우저에서 `import`는 네트워크를 통해 파일을 가져오므로 비동기로 설계됐다. Node.js의 `require()`는 파일 시스템에서 동기로 읽는다. ESM을 지원하려면 모듈 로딩 전체를 비동기로 바꿔야 하는데, 이는 기존 CJS 생태계를 전부 깨는 변경이었다. Node.js는 결국 CJS와 ESM을 병행 지원하는 방향으로 타협했다.

**파일 확장자 문제**

ESM과 CJS는 `.js` 확장자를 공유하는데, 파싱 방식이 다르다. 어떤 방식으로 처리해야 하는지 구분하기 위해 Node.js는 새 규칙을 만들었다.

```javascript
.mjs  → 항상 ESM
.cjs  → 항상 CJS
.js   → package.json의 "type" 필드에 따라 결정
         "type": "module"    → ESM
         "type": "commonjs"  → CJS (기본값, 생략 시)
```

`**__dirname**`**, **`**__filename**`** 부재**

CJS에서 당연하게 쓰던 변수들이 ESM에는 없다. Node.js가 모듈 래퍼로 주입해주던 것들이라서다.

```javascript
// 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
```

### 듀얼 패키지 위험(Dual Package Hazard)

패키지가 CJS와 ESM을 동시에 지원할 때 발생하는 문제다. 과도기 상황에서 많은 라이브러리가 둘 다 지원하려다 이 문제를 만났다.

```javascript
{
  "exports": {
    "import":  "./dist/index.mjs",    // ← ESM 환경에서 로드
    "require": "./dist/index.cjs"     // ← CJS 환경에서 로드
  }
}
```

문제는 같은 프로젝트 안에서 ESM과 CJS 환경이 섞이면, 같은 패키지가 두 번 로드될 수 있다는 것이다.

```javascript
앱 (ESM)
  ├── packageA (ESM 버전 로드) → 인스턴스 #1
  └── packageB (CJS)
        └── packageA (CJS 버전 로드) → 인스턴스 #2
                   ↑
        동일 패키지인데 인스턴스가 두 개
```

싱글톤 패턴이나 React Context처럼 하나의 인스턴스를 공유해야 하는 경우 이 문제가 발생하면 상태가 공유되지 않는 버그로 이어진다.

이 때문에 `chalk v5`, `node-fetch v3`, `got v12` 같은 라이브러리들이 CJS 지원을 완전히 끊고 ESM only로 전환했다. 인스턴스를 하나로 보장하기 위한 선택이었다.

### CJS와 ESM 핵심 비교하기

| 항목 | CJS | ESM |
| --- | --- | --- |
| 문법 | `require()` / `module.exports` | `import` / `export` |
| 분석 시점 | 런타임 (동적) | 파싱 타임 (정적) |
| 로딩 방식 | 동기 | 비동기 (세 단계) |
| 내보낸 값 | 복사본 | 라이브 바인딩 (참조) |
| Tree Shaking | 불가 | 가능 |
| 브라우저 지원 | 불가 (번들러 필요) | 네이티브 지원 |
| Top-level await | 불가 | 가능 |
| 조건부 로드 | 가능 | dynamic import 사용 |
| 순환 참조 | 불안정 (복사 시점 문제) | 안정적 (라이브 바인딩) |
| Node.js 기본값 | O (`.js`) | `.mjs` 또는 `"type": "module"` |

### 현재는 어떤 프로젝트를 구성해야할까?

**새로 시작하는 프로젝트로 프로덕션 환경을 구동하는 경우**

`package.json`에 `"type": "module"`을 추가하면 `.js` 파일이 모두 ESM으로 처리된다.

```javascript
{
  "type": "module"
}
```

TypeScript를 쓴다면 `tsconfig.json`도 맞춰준다.

```javascript
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler"    // ← Vite, webpack 등 번들러 환경
    // "moduleResolution": "NodeNext" ← Node.js ESM 환경 (확장자 명시 필요)
  }
}
```

**기존 CJS 프로젝트로 프로덕션 환경을 구동하는 경우**

의존하는 패키지 중 ESM only로 전환된 게 있다면 마이그레이션 압박이 생기지만, 그렇지 않다면 서두를 필요는 없다. 점진적으로 전환할 때는 `.mjs` 확장자로 파일을 하나씩 바꾸는 방식이 안전하다.

**라이브러리를 배포하는 경우**

듀얼 패키지 지원이 현실적인 선택이다. `tsup`으로 CJS와 ESM 빌드를 동시에 만들 수 있다.

```javascript
npx tsup src/index.ts --format cjs,esm
# dist/index.js   ← CJS
# dist/index.mjs  ← ESM
```

```javascript
{
  "main": "./dist/index.js",
  "module": "./dist/index.mjs",
  "exports": {
    ".": {
      "import":  "./dist/index.mjs",
      "require": "./dist/index.js"
    }
  }
}
```

CJS에서 ESM으로의 전환은 결국 모듈 시스템의 설계 철학이 바뀌는 것이다. 동기에서 비동기로, 동적에서 정적으로, 복사에서 참조로. 지금 쓰는 주요 패키지들이 어떤 방식으로 동작하는지 `[package.json](https://package.json)`의 `exports` 필드를 직접 파악해보는 연습도 필요하다는 생각이 든다 ^,^ ..

For the site tree, see the [root Markdown](https://slashpage.com/timmy.md).
