"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하려면 dynamicimport()통해서만 가능; 동기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 로 해.