"Node에는 모듈 시스템이 둘 있어. 대개는 잘 지내지만, 둘이 같은 패키지를 서로 다른 방식으로 불러오는 순간 골치 아픈 일이 시작돼."
왜 하나로 정리되지 않았을까
초기 Node 생태계는 CommonJS(CJS) 위에서 자랐어. 모듈을 불러올 때는 require('lodash'), 내보낼 때는 module.exports = ...를 썼지. require는 파일을 읽고 실행한 다음에야 다음 줄로 넘어가는 동기 방식이야. 로컬 디스크에서 서버 코드를 읽는 환경이라 이런 설계가 실용적이었어.
브라우저 쪽 사정은 달랐어. 네트워크 너머의 모듈을 다루려면 미리 의존 관계를 분석하고 비동기로 불러올 수 있어야 했지. 그렇게 JavaScript 표준에 ESM의 import와 export가 들어왔어. Node도 Node 12 무렵부터 ESM을 받아들여 Node 14에서 안정화했고, Node 16 이후에는 새 프로젝트에서 쓰기 편한 수준까지 다듬었어.
그렇다고 CJS를 없앨 수는 없었어. 이미 수많은 npm 패키지가 CJS로 배포돼 있었거든. 그래서 지금 Node에는 두 시스템이 함께 살아. 둘 중 하나만 써도 되고 섞어도 되지만, 경계를 모르면 require is not defined 같은 오류가 느닷없이 튀어나와.
파일을 보고 모듈 방식을 알아내는 규칙
.mjs파일은 언제나 ESM이야..cjs파일은 언제나 CJS야..js파일은 가장 가까운package.json을 따라."type": "module"이면 ESM이고,"type": "commonjs"이거나 type 필드가 없으면 CJS야.- Node가 직접 실행하는 TypeScript도 같은 규칙을 써.
.mts는 ESM,.cts는 CJS이고,.ts는 가까운package.json의 type 필드를 따라가.
새 프로젝트라면 첫날부터 package.json에 "type": "module"을 적어 두는 편이 좋아. 기본값에 기대면 파일만 봐서는 어떤 규칙이 적용되는지 알기 어렵고, 새 코드를 굳이 예전 CJS 기본값에 묶어 둘 이유도 거의 없어.
문법보다 중요한 실행 차이
CJS와 ESM의 차이는 철자만이 아니야. CJS는 require로 모듈을 동기 평가하고, ESM은 실행 전에 별도 연결 단계에서 모듈 그래프를 준비해.
- ESM에서는 최상위
await를 쓸 수 있지만 CJS에서는 쓸 수 없어. - ESM에는
__filename과__dirname이 없어. 파일 위치가 필요하면import.meta.url에서 구해. - 정적
import는 파일을 읽을 때 의존 관계가 정해져. 실행 중에 조건을 보고 불러오려면 동적import()를 써야 해. - CJS는 내보낸 객체를 바꿀 수 있는 참조로 다뤄. ESM은 살아 있는 binding을 내보내지만, 가져온 쪽에서는 그 binding을 다시 대입할 수 없어.
- CJS에서 ESM을 불러올 때는 동적
import()가 가장 넓게 통하는 경로야. 동기require는 오랫동안ERR_REQUIRE_ESM을 냈고, Node 22 이후에는 동기 평가할 수 있는 ESM에 한해 호환 범위가 넓어졌어.
패키지가 CJS와 ESM을 둘 다 내놓으면
패키지 하나가 package.json의 exports 맵을 통해 CJS 빌드와 ESM 빌드를 따로 제공할 수 있어. 문제는 같은 프로세스에서 어떤 코드는 CJS 진입점을, 다른 코드는 ESM 진입점을 불렀을 때야. Node가 두 진입점을 독립된 모듈로 평가하면 메모리에 패키지 복사본이 둘 생겨. 모듈 상태와 singleton이 갈리고, 같은 이름의 클래스끼리도 instanceof가 실패할 수 있어.
이게 dual-package hazard야. 가능하면 프로젝트 전체에서 한 진입 방식으로 통일해. 패키지 작성자가 두 형식을 모두 제공해야 한다면 모듈 수준의 상태를 없애거나, 한쪽 빌드가 다른 쪽의 얇은 연결층이 되게 만들어서 상태가 둘로 갈리지 않게 해야 해.
Pippa의 고백
"type": "module"을 둔 ESM으로 시작하고, CJS 의존성은 필요한 호환 경로로만 받아들여. 한쪽을 억지로 지우는 것보다 어디에서 두 세계가 만나는지 표시해 두는 편이 훨씬 안전해.