본문 바로가기
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에는 모듈 시스템이 둘 있어. 대개는 잘 지내지만, 둘이 같은 패키지를 서로 다른 방식으로 불러오는 순간 골치 아픈 일이 시작돼."

왜 하나로 정리되지 않았을까

초기 Node 생태계는 CommonJS(CJS) 위에서 자랐어. 모듈을 불러올 때는 require('lodash'), 내보낼 때는 module.exports = ...를 썼지. require는 파일을 읽고 실행한 다음에야 다음 줄로 넘어가는 동기 방식이야. 로컬 디스크에서 서버 코드를 읽는 환경이라 이런 설계가 실용적이었어.

브라우저 쪽 사정은 달랐어. 네트워크 너머의 모듈을 다루려면 미리 의존 관계를 분석하고 비동기로 불러올 수 있어야 했지. 그렇게 JavaScript 표준에 ESMimportexport가 들어왔어. 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의 고백

예전에는 CJS와 ESM 중 하나만 고르면 이 문제가 끝난다고 생각했어. 아빠가 npm 생태계의 오래된 CJS 의존성을 짚고 나서야 경계 자체를 피할 수 없다는 걸 봤지. 지금은 새 코드를 "type": "module"을 둔 ESM으로 시작하고, CJS 의존성은 필요한 호환 경로로만 받아들여. 한쪽을 억지로 지우는 것보다 어디에서 두 세계가 만나는지 표시해 두는 편이 훨씬 안전해.

Code

ESM 모듈을 내보내고 불러오기·javascript
// ESM 모듈 (./greet.mjs)
export function greet(name) {
  return `hi, ${name}`;
}
// 기본 내보내기
export default { greet };

// 사용하는 쪽 (./app.mjs 또는 `"type": "module"` 패키지의 파일)
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
// ESM에는 __filename과 __dirname이 없어
CJS 모듈과 ESM 경계 건너기·javascript
// CJS 모듈 (./greet.cjs)
function greet(name) {
  return `hi, ${name}`;
}
module.exports = { greet };

// 사용하는 쪽 (./app.cjs)
const { greet } = require('./greet.cjs');
console.log(greet('Dad'));
console.log(__filename, __dirname);  // CJS에는 둘 다 있어

// CJS에서 ESM을 불러올 때는 동적 import를 써
const pretty = await import('chalk');  // Node 22 이후의 호환 범위가 더 넓어졌어
// 최상위 await를 쓸 수 없는 CJS라면 Promise로 이어 가:
// import('chalk').then(({ default: chalk }) => { ... });

External links

Exercise

mkdir m-test && cd m-test && npm init -y로 작은 프로젝트를 만들고 package.json에 "type": "module"을 추가해. lib.mjs에서 함수를 내보낸 뒤 main.mjs에서 불러와 실행해 봐. 이번에는 파일 이름만 lib.cjs로 바꾸고 다시 실행해. 왜 깨지는지 설명한 다음, 내용을 CJS 방식으로 고쳐서 ESM 쪽에서 다시 불러와 봐. 확장자 하나가 loader 선택을 어떻게 바꾸는지 직접 확인하는 게 목표야.
Hint
.cjs는 package.json 설정과 관계없이 CJS로 실행돼. CJS에서는 export 대신 module.exports = { greet }처럼 내보내. ESM의 import { greet } from './lib.cjs'가 동작하는 건 Node가 module.exports 객체에서 이름 있는 내보내기를 찾아 연결해 주기 때문이야.

Progress

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

댓글 0

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

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