Node.js CJS/ESM 호환성 삽질

·2026년 1월 17일

Node 21에서 안 되던 코드가 Node 22에서 된 이유를 파헤쳐보았다.

문제의 시작

새로운 라이브러리를 설치하고 코드를 실행했다.

Error [ERR_REQUIRE_ESM]: require() of ES Module 
/node_modules/.pnpm/@lukas.j.han+mdoc@0.5.6/node_modules/@lukas.j.han/mdoc/dist/index.mjs 
not supported.

Instead change the require of index.mjs to a dynamic import() 
which is available in all CommonJS modules.

뭔가 ESM 관련 에러인 것 같아 막연하게 webpack 설정을 수정해 커밋한 뒤 그대로 사수님께 말씀드렸다.

나: "이 라이브러리 설치하니까 에러가 나서, 호환이 안 되는 것 같아서 webpack 설정을 추가해서 해결했어요."
사수님: "저는 그런 설정 없이도 됐는데요?"

너무 의아한 나머지 새로 들어온 동료분께도 부탁드려 확인해봤다.

나: "혹시 이거 실행해보실 수 있을까요?"
동료: "저도 됩니다!"

다들 webpack 설정 없이 잘 돌아가고 있었다.이러한 이유로 webpack 설정을 commit 해버리기엔 찝찝했다. 다른 사람들한테는 필요 없는 코드기 때문에 무언가 근본적인 원인이 있을 것만 같았다.


범인을 찾자

첫 번째 용의자: pnpm 버전

우리 팀은 pnpm을 사용하고 있었다. pnpm은 npm, yarn과 다르게 node_modules 구조가 다르다. 심볼릭 링크 기반이라 모듈 해석 방식에 영향을 줄 수 있다고 들었다.

# 내 버전
pnpm --version
# 7.x.x

# 사수님 버전  
pnpm --version
# 9.x.x

달랐다. 혹시 이게 문제일까 싶어 pnpm버전을 업그레이드하고, node_modules를 삭제한 후 다시 설치해봤다.

여전히 에러가 났다. 알고보니 pnpm 캐시는 store 라는 곳에 따로 남아 있을 수가 있다고 해서 여기까지 의심을 했다.pnpm은 전역 store에 패키지를 저장하고 심볼릭 링크로 연결하는 구조다.

# pnpm store 경로 확인
pnpm store path
# /Users/tree/Library/pnpm/store/v3

# store 캐시 정리
pnpm store prune

# 아예 store 삭제 
rm -rf $(pnpm store path)

# 다시 설치
pnpm install

여전히 에러가 났기 때문에 pnpm은 범인이 아닌 것 같았다.

두 번째 용의자: Node.js 버전

혹시나 해서 확인해보니 노드 버전도 달랐다.


해결

webpack 설정은 일단 빼두고 Node 버전을 올려봤다. 그리고 다시 실행을 했는데 제대로 실행이 됐다. 버전 하나 올렸을 뿐인데 에러가 사라진게 신기하다고 생각했다. 그리고 도대체 둘이 어떤 차이가 있는 건지 궁금해졌다.


왜 됐을까?

에러 메시지를 다시 읽어보자.

Error [ERR_REQUIRE_ESM]: require() of ES Module ... index.mjs not supported.

핵심 키워드들:

  • require() - CommonJS의 모듈 불러오기 방식
  • ES Module - ESM, JavaScript 표준 모듈 시스템
  • .mjs - ESM 파일의 확장자

에러가 말하고 있는 건 이거다:

"CJS 방식(require)으로 ESM 파일(.mjs)을 불러오려고 하는데 그건 안돼..."

실제로 Node.js 21 공식 문서에는 이렇게 적혀있었다.

"Due to the synchronous nature of require(), it is not possible to use it to load ECMAScript module files. Use import() instead."

"require()의 동기적 특성으로 인해, ECMAScript 모듈 파일을 로드하는 데 사용할 수 없습니다. 대신 import()를 사용하세요."

이걸 이해하려면 먼저 JavaScript 모듈 시스템의 역사를 알아야 한다.


JavaScript 모듈의 두 세계: CJS vs ESM

태초에 모듈은 없었다

1995년, JavaScript가 처음 만들어졌을 때는 모듈 시스템이 없었다. JS는 브라우저에서 간단한 스크립트를 실행하는 용도였기 때문이다.

<script src="a.js"></script>
<script src="b.js"></script>
<!-- 모든 변수가 전역 공간에서 충돌 -->

CommonJS의 등장 (2009년)

Node.js가 나오면서 서버에서 JavaScript를 쓰게 됐다. 서버 애플리케이션은 복잡하기 때문에 모듈 시스템이 필요했다.

// CommonJS (CJS) - Node.js의 선택
// math.js
const add = (a, b) => a + b;
module.exports = { add };

// main.js
const { add } = require('./math.js');
console.log(add(1, 2));

특징:

  • require()동기적(synchronous)으로 실행된다
  • 파일을 읽고 → 실행하고 → 결과를 반환
  • 서버에서는 파일이 로컬에 있으니까 빠르게 동작

ESM의 등장 (2015년)

브라우저에서도 모듈을 쓰고 싶어졌다. 하지만 브라우저는 네트워크로 파일을 가져와야 해 동기적 로딩은 불가능하다. 그래서 JavaScript 표준으로 ESM(ECMAScript Modules)이 만들어졌다.

// ESM - JavaScript 공식 표준
// math.js
export const add = (a, b) => a + b;

// main.js
import { add } from './math.js';
console.log(add(1, 2));

특징:

  • import/export정적(static) 분석이 가능하다
  • 파일 최상단에서만 사용 가능
  • 비동기 로딩 지원
  • Tree-shaking 가능 (안 쓰는 코드 제거)

두 시스템의 근본적 차이

CommonJSESM
문법require() / module.exportsimport / export
로딩동기비동기
분석 시점런타임파싱 타임 (정적)
파일 확장자.js, .cjs.js, .mjs
thisexports 객체undefined
__dirname있음없음

문제는 이 두 시스템이 호환되지 않는다는 것이다.

// CJS에서 ESM 불러오기
const esmModule = require('./esm-file.mjs');  
// Error: require() of ES Module not supported

// ESM에서 CJS 불러오기
import cjsModule from './cjs-file.cjs';
// 대부분 동작함 (default export로 처리)

Node.js는 어떻게 CJS/ESM을 구분할까?

Node.js가 파일을 실행할 때, 먼저 "이 파일이 CJS인가 ESM인가?"를 판단해야 한다.

판단 규칙

1. 파일 확장자 확인
   - .mjs → ESM
   - .cjs → CommonJS
   - .js  → package.json 확인

2. package.json의 "type" 필드
   - "type": "module"    → .js를 ESM으로
   - "type": "commonjs"  → .js를 CJS로
   - 없음                → .js를 CJS로 (기본값)

라이브러리의 이중 지원

그러면 라이브러리는 어떻게 CJS와 ESM 사용자를 모두 지원할까?

{
  "name": "cool-library",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}
  • exports.import: ESM으로 import할 때 사용할 파일
  • exports.require: CJS로 require할 때 사용할 파일
  • main: 오래된 Node.js를 위한 fallback
  • module: 번들러(Webpack 등)를 위한 필드

핵심: Node.js가 exports 필드를 보고 상황에 맞는 파일을 선택해준다.


그래서. . . 왜 Node 21에서는 안 되고 22에서는 됐을까?

내가 사용한 라이브러리의 상황

에러 경로를 다시 보자:

/node_modules/.pnpm/@lukas.j.han+mdoc@0.5.6/.../dist/index.mjs

이 라이브러리는 .mjs 파일, 즉 ESM으로만 배포되고 있었다. 그리고 내 프로젝트는 CJS 기반이었다. require()로 이 라이브러리를 불러오려고 했던 것이다.

// 내 프로젝트 (CJS)
const mdoc = require('@lukas.j.han/mdoc');  
// → Node.js가 index.mjs를 require()로 불러오려 함
// → ERR_REQUIRE_ESM

Node.js 22에서 바뀐 것

Node.js 22.0.0 릴리즈 노트를 보자:

Node.js 공식 릴리즈 노트 (v22.0.0)

"This release adds require() support for synchronous ESM graphs under the flag --experimental-require-module."

"이번 릴리즈는 --experimental-require-module 플래그 하에 동기적 ESM 그래프에 대한 require() 지원을 추가합니다."

그리고 Node.js 22.12.0 (LTS) 에서 더 큰 변화가 있었다:

Node.js 공식 릴리즈 노트 (v22.12.0)

"With this feature enabled, Node.js will no longer throw ERR_REQUIRE_ESM if require() is used to load a ES module."

"이 기능이 활성화되면, Node.js는 더 이상 require()로 ES 모듈을 로드할 때 ERR_REQUIRE_ESM 에러를 던지지 않습니다."

핵심: Node.js 22.12.0부터는 --experimental-require-module 플래그 없이도 기본적으로 require()로 ESM을 불러올 수 있게 됐다!

사실 Node.js 팀은 22.0.0 릴리즈 때부터 이 방향을 예고했었다:

"We intend to eventually enable require(esm) by default in the future, without the flag."

"향후 플래그 없이 require(esm)을 기본적으로 활성화할 계획입니다."

// Node 21
const mdoc = require('@lukas.j.han/mdoc');
// ERR_REQUIRE_ESM

// Node 22
const mdoc = require('@lukas.j.han/mdoc');  
// 동작함! (ESM을 CJS에서 불러올 수 있게 됨)

조건

단, 모든 ESM을 require()로 불러올 수 있는 건 아니다:

  1. ESM 파일이 Top-level await를 사용하지 않아야 함
  2. ESM 파일이 완전히 동기적으로 평가 가능해야 함

내가 사용한 라이브러리가 이 조건을 만족했기 때문에 Node 22에서 동작한 것이다.


Node.js 버전별 ESM 지원 현황

Node.js 버전ESM 지원 상태
12.x실험적 (플래그 필요)
14.x안정화 시작
16.x대부분 안정화
18.x완전 안정화
20.x추가 기능들
22.xrequire()로 ESM 로드 지원

앞으로 비슷한 에러를 만나면

1. 에러 메시지 키워드 확인

ERR_REQUIRE_ESM     → CJS에서 ESM을 require()로 불러오려 함
ERR_MODULE_NOT_FOUND → 모듈 해석 실패
Cannot use import   → CJS 파일에서 import 문법 사용

2. 라이브러리의 package.json 확인

cat node_modules/문제의-라이브러리/package.json

확인할 것:

  • "type": "module"이면 ESM
  • "exports": require 조건이 있는지
  • "main": CJS fallback이 있는지

3. 내 프로젝트의 모듈 시스템 확인

cat package.json | grep '"type"'
  • "type": "module" → ESM 프로젝트
  • 없거나 "type": "commonjs" → CJS 프로젝트

4. Node.js 버전 확인

node --version

ESM 관련 이슈면 최신 LTS 버전(현재 22.x)으로 업그레이드 고려.

5. 해결 옵션들

상황해결책
CJS 프로젝트 + ESM 라이브러리Node 22+로 업그레이드 또는 dynamic import 사용
ESM 프로젝트 + CJS 라이브러리대부분 그냥 동작함
혼란스러움프로젝트를 ESM으로 전환 검토

Dynamic import 사용 예시:

// CJS에서 ESM 불러오기 (모든 Node 버전에서 동작)
async function main() {
  const mdoc = await import('@lukas.j.han/mdoc');
  // 사용...
}
main();

마무리

JavaScript 생태계가 CJS에서 ESM으로 전환되는 과도기에 있어서 이런 호환성 문제는 앞으로도 종종 마주칠 것 같다. 그리고 이번 삽질을 통해 사람들이 왜 Docker를 도입하는지 조금 알 것 같았다. "내 컴퓨터에선 되는데요"를 원천 차단하려면 결국 개발 환경 자체를 통일해야 하니까.

profile
My Island

0개의 댓글