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

작성자
현우
작성시각
수정시각
레퍼런스
Vacío
JavaScript에서 모듈을 불러오는 방법은 두 가지다. Node.js가 오랫동안 사용해온 CJS(CommonJS) 와, ES2015 표준에 추가된 ESM(ES Modules) 이다.
라이브러리 문서에서 "This package is ESM only"라는 문구를 처음 봤을 때, 단순히 문법이 다른 것 정도로 생각했다. 그런데 파고들수록 두 시스템은 모듈을 불러오는 방식 자체가 근본적으로 달랐다. 단순히 require 대신 import를 쓰는 게 아니라, 브라우저와 런타임이 코드를 이해하는 방식이 바뀌는 것이었다.

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) 다.
// math.js
function add(a, b) { return a + b }
module.exports = { add }             // ← Node.js가 런타임에 주입하는 객체
// app.js
const { add } = require('./math')   // ← Node.js가 런타임에 주입하는 함수

CJS의 내부 동작

requiremodule.exports는 JavaScript 문법이 아니다. Node.js가 파일을 실행하기 직전에 모듈 래퍼 함수로 감싸서 인자로 주입하는 것들이다.
// Node.js가 실제로 파일을 실행하는 방식
(function(exports, require, module, __filename, __dirname) {
  // 우리가 작성한 코드
  function add(a, b) { return a + b }
  module.exports = { add }
})
require()는 그냥 함수다. 그래서 코드 어디서든 호출할 수 있다.
// 전부 유효한 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는 아래 순서로 동작한다.
require('./math') 호출
  1. 캐시 확인 — 이미 로드된 모듈이면 캐시에서 반환
  2. 파일 탐색 — ./math.js, ./math/index.js 순으로 탐색
  3. 파일 읽기 — fs.readFileSync()로 동기적으로 읽음
  4. 래퍼 함수로 감싸서 실행
  5. module.exports 반환
  6. 캐시에 저장
핵심은 동기적이라는 것이다. require()가 호출되면 파일을 다 읽고 실행할 때까지 다음 줄로 넘어가지 않는다.

CJS의 값 복사 방식

CJS에서 module.exports로 내보낸 값은 복사본이다. 원본이 바뀌어도 가져간 쪽에서는 모른다.
// 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는 복사된 값이기 때문
count를 구조분해할 때 그 시점의 값(0)이 복사됐다. 이후 increment()로 원본 count가 바뀌어도, 복사본은 그대로다. 객체를 내보내면 참조가 공유되지만, 원시값은 항상 이 문제가 생긴다.

ESM — 뒤늦게 나온 표준

2015년, ES6 표준에 드디어 JavaScript 언어 자체의 모듈 시스템이 들어왔다.
// math.js
export function add(a, b) { return a + b }
// app.js
import { add } from './math.js'
import는 함수 호출이 아니라 선언이다. 그래서 파일 최상단에만 위치할 수 있고, 조건부로 쓸 수 없다.
// 문법 에러
if (condition) {
  import { add } from './math.js'   // ← SyntaxError
}

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

ESM의 세 단계 로딩

ESM이 CJS와 근본적으로 다른 이유는, 모듈 로딩이 세 단계로 분리되기 때문이다.
1단계 — Construction (파싱)
코드를 실행하기 전에 모든 import 선언을 분석해서 의존성 그래프를 만든다. 재귀적으로 모든 모듈 파일을 파싱한다. 이 단계에서는 아직 코드가 실행되지 않는다.
app.js 파싱
  → import { add } from './math.js' 발견
  → math.js 파싱
    → math.js의 import 확인
    → 더 이상 없으면 완료
  → 의존성 그래프 완성
2단계 — Instantiation (링킹)
각 모듈의 export에 메모리 공간을 할당한다. 아직 값은 없다. import를 해당 메모리 공간에 연결(link)한다. 이 연결이 라이브 바인딩(live binding) 이다.
math.js의 export add → 메모리 주소 0x001 할당
app.js의 import add  → 0x001 주소를 참조하도록 연결
(아직 값은 없음, 주소만 연결됨)
3단계 — Evaluation (평가)
실제로 코드를 실행해서 메모리 공간에 값을 채운다.
math.js 실행 → add 함수 생성 → 0x001에 저장
app.js 실행  → add를 참조할 때 0x001의 값을 읽음
이 구조 덕분에 ESM은 라이브 바인딩을 제공한다.
💬
엇, 모듈을 다른 파일에서도 똑같이 호출했는데 값을 공유되는데 왜 그런걸까?
모듈 자체는 항상 싱글톤 패턴으로 구성되다보니, 모듈은 딱 한번만 평가가 된다.
그래서 여러 파일이 같은 모듈을 import 해도 모두 같은 메모리 슬롯을 공유하게 되는 것이다.
그래서 만약 호출 시마다 독립적인 초기 값을 가지고 싶게 하고 싶다면, 팩토리 함수나 클래스 형태로 내보내야한다.
// 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한 쪽에서도 바뀐 값이 보인다.
// 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 ← 라이브 바인딩이라 원본 변경이 반영됨
CJS는 복사, ESM은 참조. 이 차이가 순환 참조 처리 방식을 바꾸고, 상태 공유 방식도 바꾼다.

순환 참조에서의 차이

CJS에서 A가 B를 require하고, B가 A를 require하면 문제가 생긴다.
// 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를 반환한다. 결과적으로 valueAundefined가 된다.
ESM은 다르다. 링킹 단계에서 메모리 주소를 먼저 연결해두기 때문에, 순환이 있어도 주소 연결 자체는 성공한다. 값이 채워지는 건 평가 단계에서 일어나고, 이때는 이미 양쪽이 서로를 참조하는 구조가 완성되어 있다.

왜 지금 ESM으로 넘어가는가

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

Tree Shaking이 가능해진다

CJS에서는 require()가 런타임에 실행되기 때문에, 빌드 타임에 어떤 코드가 실제로 사용되는지 알 수 없다.
// CJS — 번들러 입장에서는 결국 _ 전체가 필요
const _ = require('lodash')
_.debounce(fn, 300)
ESM에서는 파싱 타임에 의존성 그래프가 완성되므로, 번들러가 어떤 export가 실제로 사용되는지 정확하게 파악할 수 있다.
// ESM — 번들러가 debounce만 사용한다는 걸 빌드 타임에 확정
import { debounce } from 'lodash-es'
debounce(fn, 300)
사용되지 않는 코드는 번들에서 제거된다. 이게 Tree Shaking이다.
lodash (CJS)    → ~70KB (전체 포함)
lodash-es (ESM) → ~2KB (debounce만 사용 시)

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

CJS의 require()는 Node.js 런타임이 주입하는 함수다. 브라우저는 이 함수를 모른다. CJS 코드를 브라우저에서 실행하려면 반드시 Webpack 같은 번들러로 변환해야 했다.
ESM은 JavaScript 표준이다. 브라우저가 직접 지원한다.
<script type="module" src="app.js"></script>
브라우저가 app.js를 받으면, import 선언을 읽고 필요한 파일들을 직접 네트워크로 요청한다. 번들러 없이 모듈 시스템이 동작한다. 이게 프론트엔드 라이브러리로 대입하자면 Vite가 개발 환경에서 번들링 없이 빠르게 동작하는 이유다.
Webpack 개발 서버:
  코드 변경 → 전체(또는 청크) 다시 번들링 → 번들 파일 전달

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

Top-level await를 쓸 수 있다

ESM의 모듈 로딩은 비동기다. 그래서 모듈 최상단에서 await를 쓸 수 있다.
// ESM — 모듈 최상단에서 await 사용 가능
const config = await fetch('/api/config').then(r => r.json())
const db = await connectDatabase()

export { config, db }
CJS에서 require()는 동기 함수다. 비동기 작업이 완료될 때까지 기다릴 방법이 없다.
// 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가 코드를 실행하지 않고도 의존성 구조를 정확히 파악할 수 있다.
// CJS — 타입 추론이 어려운 패턴들
const moduleName = getModuleName()
const mod = require(moduleName)    // ← 어떤 타입인지 알 수 없음

const { utils } = require('./utils')
utils.doSomething()                // ← utils의 타입을 런타임 전에 알기 어려움
// 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는 새 규칙을 만들었다.
.mjs  → 항상 ESM
.cjs  → 항상 CJS
.js   → package.json의 "type" 필드에 따라 결정
         "type": "module"    → ESM
         "type": "commonjs"  → CJS (기본값, 생략 시)
__dirname, __filename 부재
CJS에서 당연하게 쓰던 변수들이 ESM에는 없다. Node.js가 모듈 래퍼로 주입해주던 것들이라서다.
// 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을 동시에 지원할 때 발생하는 문제다. 과도기 상황에서 많은 라이브러리가 둘 다 지원하려다 이 문제를 만났다.
{
  "exports": {
    "import":  "./dist/index.mjs",    // ← ESM 환경에서 로드
    "require": "./dist/index.cjs"     // ← CJS 환경에서 로드
  }
}
문제는 같은 프로젝트 안에서 ESM과 CJS 환경이 섞이면, 같은 패키지가 두 번 로드될 수 있다는 것이다.
앱 (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으로 처리된다.
{
  "type": "module"
}
TypeScript를 쓴다면 tsconfig.json도 맞춰준다.
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler"    // ← Vite, webpack 등 번들러 환경
    // "moduleResolution": "NodeNext" ← Node.js ESM 환경 (확장자 명시 필요)
  }
}
기존 CJS 프로젝트로 프로덕션 환경을 구동하는 경우
의존하는 패키지 중 ESM only로 전환된 게 있다면 마이그레이션 압박이 생기지만, 그렇지 않다면 서두를 필요는 없다. 점진적으로 전환할 때는 .mjs 확장자로 파일을 하나씩 바꾸는 방식이 안전하다.
라이브러리를 배포하는 경우
듀얼 패키지 지원이 현실적인 선택이다. tsup으로 CJS와 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"
    }
  }
}
CJS에서 ESM으로의 전환은 결국 모듈 시스템의 설계 철학이 바뀌는 것이다. 동기에서 비동기로, 동적에서 정적으로, 복사에서 참조로. 지금 쓰는 주요 패키지들이 어떤 방식으로 동작하는지 package.jsonexports 필드를 직접 파악해보는 연습도 필요하다는 생각이 든다 ^,^ ..