CommonJS(CJS), ECMAScript Modules(ESM)

우혁·2025년 6월 19일
46
post-thumbnail

CommonJS(CJS)란?

주로 Node.js에서 사용되는 전통적인 모듈 시스템이다.

문법

require() 로 모듈을 불러오고, module.exports 로 내보낸다.

// add.js
module.exports.add = (x, y) => x + y;

// index.js
const { add } = require("./add");
add(1, 2);
  • 동기적 로딩: 모듈을 불러올 때 코드가 즉시 실행된다.
  • 동적 로딩 가능: 경로나 내보내는 객체를 런타임에 동적으로 바꿀 수 있다.
    • require 를 코드 어디서든(조건문, 함수 내부 등) 동적으로 사용할 수 있다.
  • 빌드 타임 분석 어려움: 동적 로딩 때문에 코드 최적화(Tree-shaking)가 어렵다.

💡 Tree-shaking이란?
번들링 과정에서 사용되지 않는 코드(Dead Code)를 자동으로 제거하여 최종 번들 파일의 크기를 줄이는 최적화 기법이다.

이름 그대로 “나무를 흔들어서(Shaking the tree) 불필요한 가지(Dead Code)를 떨어뜨린다”는 의미이다.

  • 정적 분석: 번들러(Webpack, Rollup 등)는 소스 코드를 정적으로 분석하여, 실제로 사용되지 않는 함수, 변수, 모듈 등을 식별한다.

  • 코드 제거: 사용되지 않는 부분은 최종 번들에서 제외되어 배포 파일이 더 가볍고 빠르게 로드된다.

ECMAScript Modules(ESM) 구조: ESM은 import / export 가 정적으로 선언되어 있어, 번들러가 코드 의존성을 미리 파악하고 최적화하기 쉽다.

반면 CJS는 동적으로 모듈을 불러오기 때문에 Tree-shaking이 어렵다.


ECMAScript Modules(ESM)이란?

최신 JavaScript 표준에서 도입된 모듈 시스템이다.

문법

import , export 키워드를 사용한다.

// add.js
export const add = (x, y) => x + y;

// index.js
import { add } from "./add.js";
add(1, 2);
  • 비동기적 로딩: 모듈을 불러올 때 비동기로 처리할 수 있고, Top-level await도 지원한다.
  • 정적 구조: import / export 는 반드시 파일의 최상위에 위치해야 하며, 동적으로 변경할 수 없다.
    • ESM에서 동적 import는 import() 함수를 통해 별도로 처리해야 한다.(비동기적으로 동작)
  • 빌드 타임 최적화 유리: 정적 구조 덕분에 코드 최적화(Tree-shaking) 등 최적화가 쉽다.

💡 Top-level await이란?
ES2022(ES13)부터 도입된 기능으로, JavaScript 모듈의 최상위 레벨(함수 내부가 아닌 파일의 맨 위)에서 바로 await 키워드를 사용할 수 있게 해준다.

기존에는 반드시 async 함수 내부에서만 await 를 사용할 수 있었지만, 이제는 모듈 스코프에서도 비동기 작업을 간단하게 처리할 수 있다.

  • 기존 방식:
const fn = async() => {
	// async 함수 내부에서만 await 사용 가능
	const data = await fetchData();
}
  • Top-level await 사용:
// 모듈 스코프에서 바로 await 사용
const data = await fetchData();

특징

  • 모듈 스코프에서만 사용 가능: Top-level await은 ESM에서만 사용할 수 있다. CJS에서는 사용할 수 없다.
  • 비동기 작업 대기: 해당 모듈의 비동기 작업이 끝날 때까지, 이 모듈을 불러오는 다른 모듈의 실행도 함께 대기한다.
  • 실행 순서 보장: 의존하는 모듈들의 비동기 작업이 모두 끝난 후에 실행되므로, 모듈 간의 실행 순서가 보장된다.

CJS와 ESM의 차이점

구분CommonJS(CJS)ECMASCript Modules(ESM)
불러오기requireimport
내보내기module.exportsexport
로딩 방식동기비동기
동적 로딩가능별도의 동적 import() 함수 사용
최적화(Tree-shaking)어려움쉬움
Top-level awaitXO
호환성Node.js 코드최신 JS/브라우저 표준

상호 호환성

  • ESM이 CJS를 import 하는 경우:
    • ESM에서는 import 문법으로 CJS 모듈을 문제없이 불러올 수 있다.
    • ESM에서 CJS를 import하면, CJS의 module.exports 가 ESM의 default 로 매핑되어 동작한다.
  • CJS가 ESM을 require 하는 경우:
    • Node.js v21 이하에서는 CJS의 require 로 ESM을 불러올 수 없었고 반드시 동적 import() 를 사용해야 했다.
    • ESM은 비동기적으로 로드되지만, CJS는 동기적으로 require 하기 때문에 구조적으로 호환이 어렵다.

💡 Node.js v22부터 --experimental-require-module 플래그를 사용하면 CJS에서 require 로 ESM을 불러오는 것이 일부 조건에서 가능해졌다.(최신 버전에서는 플래그가 필요 없을 수 있다)

  • ESM 모듈이 Top-level await이 없는 경우에 한해 CJS의 require로 불러올 수 있다.
  • ESM 모듈임을 명확히 표시해야 한다.(.mjs 확장자 사용 / package.json의 type 필드 module 설정)

ESM ↔ CJS 상호 호환성 정리하기

  • ESM ➔ CJS:
    • 정상 동작(ESM에서 CJS import는 항상 동작)
  • CJS ➔ ESM:
    • Node.js v21 이하: 에러 발생(require 불가, 동적 import() 만 가능)
    • Node.js v22 이상:
      • --experimental-require-module 플래그(또는 최신 버전에서는 플래그 없이)
        • ESM 모듈에 Top-level await이 없고, ESM 모듈임을 명확히 표시한다면 CJS의 require로 ESM import 가능

두 모듈 시스템 모두 지원해야 하는 이유

  • 서버 사이드 렌더링(SSR) 등 Node.js 환경에서는 여전히 CJS가 많이 쓰인다.
  • 브라우저 및 최신 환경에서는 ESM이 표준이다.
  • 라이브러리/패키지 배포 시 다양한 환경 지원을 위해 두 시스템을 모두 제공하는 것이 좋다.

CJS/ESM 구분 방법 및 확장자

package.json의 type 필드로 기본 모듈 시스템을 지정한다.

  • "type": "commonjs" : .js 확장자의 파일을 CJS로 해석한다.
  • "type": "module" : .js 확장자의 파일을 ESM으로 해석한다.
  • type 필드가 생략된 경우: .js 확장자의 파일을 CJS로 해석한다.

확장자 규칙

  • .cjs : 항상 CJS로 해석한다.
  • .mjs : 항상 ESM으로 해석한다.

TypeScript도 .cts(CJS), .mts (ESM) 확장자를 사용하며, tsconfig의 moduleResolution 옵션에 따라 동작한다.

💡 tsconfig의 moduleResolution 옵션 역할
moduleResolution 옵션은 TypeScript가 import/export 구문에서 모듈을 어떻게 찾고 해석할지 결정한다.

  • node16 / nodenext: Node.js의 최신 모듈 해석 방식을 따른다.
    • .cts / .mts 확장자가 항상 각각 CJS/ESM으로 해석된다.
    • .ts / .js 파일은 package.json의 type 필드에 따라 CJS/ESM이 결정된다.

exports 필드로 CJS/ESM 동시 지원

package.json의 exports 필드를 활용해 환경에 따라 다른 엔트리 파일을 지정할 수 있다.

"exports": {
  ".": {
    // conditional exports
    "require": "./dist/index.cjs",
    "import": "./esm/index.mjs"
  }
}
  • CJS 환경에서는 require 경로, ESM 환경에서는 import 경로를 사용한다.
  • subpath exports도 지원하여, 패키지 내부 구조를 유연하게 감출 수 있다.

🚨 주의 사항

  • exports 필드는 모두 . 으로 시작하는 상대 경로로 작성되어야 한다.
    • 절대 경로를 사용하면 패키지 외부의 파일이 노출될 위험이 있어 보안과 일관성을 위해 상대 경로를 사용한다.
  • conditional exports를 사용할 때 패키지가 따르는 모듈 시스템(package.json의 type 필드)에 따라 올바른 JS 확장자를 사용해야 한다.(타입 정의도 동일하다.)
    • package.json의 type 필드가 module 인 경우(ESM): CJS 파일/ 타입 정의는 각각 .cjs / .d.cts 확장자를 제공해야 정상 동작한다.
    • 반대로 type 필드가 commonjs 인 경우(CJS): ESM 파일/ 타입 정의는 각각 .mjs / .d.mts 를 사용해야 한다.

정리하기

CommonJS(CJS)

  • Node.js의 기본 모듈 시스템
  • require / module.exports 사용
  • 동기적 로딩
  • 동적 로딩 가능(Tree-shaking 어려움)

ECMAScript Modules(ESM)

  • 최신 JS 표준 모듈 시스템
  • import / export 사용
  • 비동기적 로딩
  • 정적 구조(Tree-shaking 유리)
  • Top-level await 지원

ESM ↔ CJS 상호 호환성

  • ESM ➔ CJS import: 항상 정상 동작(default 로 매핑)
  • CJS ➔ ESM require: Node.js v22 이상, top-level await 없는 ESM에 한해 지원(실험적 플래그 사용, 최신 버전에서는 플래그를 사용하지 않아도 됨)

확장자 / exports 규칙

  • .js 는 package.json의 type 필드에 따라 CJS/ESM 결정
  • .cjs (CJS), .mjs (ESM)는 항상 명확
  • 타입 정의도 .d.cts (CJS), .d.mts (ESM)로 구분
  • exports 필드는 반드시 . 으로 시작하는 상대 경로만 사용

참고 자료

https://toss.tech/article/commonjs-esm-exports-field

profile
🏁

12개의 댓글

comment-user-thumbnail
2025년 6월 20일

우혁님 너무 오랜만에 글 쓰시는거 아닌가요???ㅋㅋㅋㅋㅋ

3개의 답글
comment-user-thumbnail
2025년 6월 21일

정리 갓.. 이 글을 본 이후에 CJS랑 ESM 구분 못 하면 내가 이상한 거지 🤯

1개의 답글
comment-user-thumbnail
2025년 6월 26일

우와 신기하네요..

답글 달기
comment-user-thumbnail
2025년 6월 26일

글이 맛있네요 우혁님!🤡

1개의 답글
comment-user-thumbnail
2025년 6월 26일

import "tsdown";
딸깍 모두 지원 완료

1개의 답글
comment-user-thumbnail
2025년 6월 30일

우와 신기하네요..

답글 달기