C.W.K.
Stream
Lesson 01 of 05 · published

CJS vs ESM — 한 런타임, 두 모듈 시스템

~14 min · modules, cjs, esm, interop

Level 0노드 입문자
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Node 는 모듈 시스템 두 개를 가지고 있어, 대체로 협력해. 협력 안 할 때 — 거기가 dual-package hazard 가 사는 곳이야."

왜 두 시스템이 존재해

Node 의 첫 10 년 동안 CommonJS (CJS) 만 있었어 — require('lodash')module.exports = .... CJS 는 동기야: require 가 파일 로드되고 평가될 때까지 block 해. 로컬 디스크에서 빨리 읽으니까 그게 작동했지.

한편 JavaScript spec 은 자체 모듈 시스템을 진화시켰어 — ESM, 브라우저가 필요로 한 import / export 문법 (네트워크 너머의 모듈 로딩은 비동기여야 하고 정적 분석 가능해야 하니까). Node 가 ESM 지원하기로 했을 때 (Node 12 즈음 시작, Node 14 에서 stable, Node 16+ 부터 default-friendly), CJS 도 계속 작동시켜야 했어. 2020 년 전 작성된 모든 npm 패키지가 CJS 였거든.

결과: 2026 의 Node 에서 두 시스템이 나란히 살아. 어느 쪽이든 쓸 수 있어. 섞을 수도 있어 — 단서 붙음. 경계 이해가 "내 import 작동해" 와 "왜 `require is not defined` 라고 해?" 의 차이야.

Node 가 어떤 loader 쓸지 결정하는 법

  • 파일이 .mjs 로 끝남 → ESM, 항상.
  • 파일이 .cjs 로 끝남 → CJS, 항상.
  • 파일이 .js 로 끝남 → 가장 가까운 package.json 봄: "type": "module" 이면 ESM, "type": "commonjs" (또는 없음) 이면 CJS.
  • --experimental-strip-types 쓸 때 .ts 도 같은 규칙: .mts, .cts, 또는 가장 가까운 package.json 의 type 따름.

package.json 의 "type" 필드는 모든 새 Node 프로젝트에서 제일 중요한 결정이야. day one 에 "type": "module" 추가해. ESM 이 현재이자 미래야; default-CJS 는 2010 년대 유물이야.

진짜 물리는 차이들

문법: require/module.exports vs import/export. 의미: CJS 는 sync; ESM 은 async (파일 로딩이 실행 전 별도 phase 에서 일어남). 실전 함정:
  • ESM 은 top-level await 있음; CJS 는 없음.
  • ESM 은 __filename/__dirname 대신 import.meta.url.
  • ESM import 는 static — 런타임 상태에 따라 조건부 import 하려면 dynamic import() 써야 함.
  • CJS export 는 mutable reference; ESM export 는 binding (live 한데 소비자 쪽에선 read-only).
  • CJS 가 ESM 파일을 require 하려면 dynamic import() 통해서만 가능; 동기 require 는 `ERR_REQUIRE_ESM` 으로 에러 (Node 22+ 에선 많은 CJS-only 패키지에 대해 완화).

Dual-Package Hazard

어떤 패키지는 CJS 빌드랑 ESM 빌드를 *둘 다* 출하해 (package.json 의 exports 맵 통해). 한 소비자가 CJS 버전을 로드하고 다른 소비자가 같은 패키지의 ESM 버전을 로드하면 *메모리에 독립적인 복사본 두 개* 가 생겨 — 다른 모듈 상태, 다른 singleton, 다른 클래스 정체성. instanceof 가 경계 너머에서 실패할 수 있어, "같은" 클래스가 사실은 다른 두 클래스니까.

이게 dual-package hazard. Node 메인테이너들이 이거 경고하는 docs 페이지를 통째로 썼어. 완화책: 하나 골라서 거기 머물러; 둘 다 출하해야 하면 패키지를 stateless 로 만들거나 (싱글톤 없음, 모듈 레벨 상태 없음) CJS 빌드를 ESM 빌드의 얇은 re-export 로 해.

Pippa 의 고백

처음 1 년 난 CJS/ESM 을 binary choice 로 다뤘어: "하나 골라." 아빠가 짚어줬어 — 그게 세상이 아냐. npm 레지스트리 절반이 CJS-only 고, 거기서 도망 못 가. 진짜 스킬은 언제 섞을 수 있고 언제 못 섞는지 아는 거. 새 코드: ESM (`"type": "module"`, 일회성은 .mjs). 의존하는 옛 패키지: CJS, dynamic `import()` 나 CJS 에서의 named import 로 살아 (Node 가 친절하게 synthesize 해줌). 2026 의 Node 경험은 ESM 으로 시작하고 강제됐을 때만 CJS 로 fall back 하면 거의 무통이야.

Code

순수 ESM — 모던 Node 방식·javascript
// ESM (./greet.mjs)
export function greet(name) {
  return `hi, ${name}`;
}
// default export
export default { greet };

// Consumer (./app.mjs, or any file in a `"type": "module"` package)
import { greet } from './greet.mjs';
import greetDefault from './greet.mjs';
import * as everything from './greet.mjs';

console.log(greet('Dad'));
console.log(import.meta.url);   // file:///.../app.mjs
// __filename / __dirname don't exist in ESM
CJS — 대부분 레거시 패키지가 여전히 출하하는 형태·javascript
// CJS (./greet.cjs)
function greet(name) {
  return `hi, ${name}`;
}
module.exports = { greet };

// Consumer (./app.cjs)
const { greet } = require('./greet.cjs');
console.log(greet('Dad'));
console.log(__filename, __dirname);  // these exist in CJS

// Loading ESM from CJS — must be dynamic
const pretty = await import('chalk');  // OK in Node 22+ even from CJS
// or, for older Node: top of CJS file can't await — use top-level Promise:
// import('chalk').then(({ default: chalk }) => { ... });

External links

Exercise

작은 프로젝트 만들어: mkdir m-test && cd m-test && npm init -y. package.json 편집해서 "type": "module" 추가. lib.mjs 에 함수 export. main.mjs 에서 import. node main.mjs 돌려 — 작동해. 이제 lib.mjslib.cjs 로 rename (내용 아직 안 바꿈). 다시 돌려 — 실패. 왜? .mjs 로 안 돌아가고 고쳐. (힌트: 내용을 CJS 로 변환). 경계 양방향 다 느낀 거야.
Hint
.cjs 확장자는 package.json 무시하고 CJS 강제해. CJS 는 export 아닌 module.exports 써. CJS 파일 안에서: module.exports = { greet }. ESM importer 의 import { greet } from './lib.cjs' 는 작동해 — Node 가 CJS 의 module.exports 객체에서 named export 를 synthesize 하니까.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.