Published on

Node.js ESM+CJS 혼용 시 ERR_REQUIRE_ESM 해결법

Authors

서버 런타임을 Node.js로 올리다 보면, 어느 날 갑자기 아래와 같은 에러를 마주칠 때가 있습니다.

Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported.
Instead change the require of ... to a dynamic import() which is available in all CommonJS modules.

이 에러는 “ESM 패키지를 CommonJS 방식(require)으로 로드하려 했다”는 뜻이지만, 실제 현장에서는 원인이 훨씬 복잡하게 얽힙니다. 예를 들어:

  • 내 앱은 CJS인데, 의존성 중 하나가 ESM-only로 바뀜
  • 내 앱은 ESM인데, 빌드 산출물이 CJS로 떨어짐(tsconfig/module 설정)
  • 테스트 러너(Jest), 번들러(tsup/webpack), 실행기(ts-node/tsx)마다 모듈 해석 규칙이 다름
  • type: module, .mjs/.cjs, exports 조건부 엔트리 등 Node의 모듈 해석 레이어가 겹침

이 글에서는 ERR_REQUIRE_ESM이 왜 발생하는지, 그리고 프로젝트를 ESM으로 전환할지 / CJS에 남을지에 따라 가장 안전한 해결책을 제시합니다. (ESM 환경에서 자주 같이 터지는 ERR_MODULE_NOT_FOUND 계열 이슈는 별도로 정리해 둔 글도 참고하세요: Node.js ESM+TS에서 ERR_MODULE_NOT_FOUND 해결법)

ERR_REQUIRE_ESM이 발생하는 핵심 원리

Node.js에는 크게 두 가지 모듈 시스템이 공존합니다.

  • CommonJS(CJS): require(), module.exports
  • ES Modules(ESM): import, export

문제는 CJS는 ESM을 require()로 직접 로드할 수 없다는 점입니다. 반대로 ESM에서 CJS를 import하는 것은 Node가 어느 정도 호환 레이어를 제공하지만, 그 또한 default export/네임드 export 차이로 자주 헷갈립니다.

ERR_REQUIRE_ESM의 전형적인 트리거는 다음과 같습니다.

  1. 어떤 패키지가 ESM-only로 배포됨
  2. 내 코드(또는 내 의존성)가 여전히 require('that-package')를 실행함
  3. Node가 “그 파일은 ES Module인데 require로 못 불러”라고 중단

여기서 “그 파일이 ESM인지”는 단순히 확장자만 보고 결정되지 않습니다. Node는 아래 규칙을 조합해서 판단합니다.

  • .mjs는 ESM, .cjs는 CJS
  • .js는 **가장 가까운 package.json의 type**에 따라 ESM/CJS 결정
  • 패키지의 package.jsonexports가 있으면 조건부 엔트리(import/require)로 분기 가능

즉, 내 파일이 .js인데도 ESM으로 해석될 수 있고, 반대로 .js가 CJS로 해석될 수도 있습니다.

1) 내 앱이 CJS인데 ESM 패키지를 require한 경우

가장 흔한 케이스입니다. 예:

// index.js (CJS)
const chalk = require('chalk');

chalk v5+는 대표적인 ESM-only 패키지라서 위 코드는 ERR_REQUIRE_ESM을 유발합니다.

해결 A: dynamic import()로 우회 (가장 빠른 응급처치)

Node는 CJS 안에서도 import()(동적 import)는 허용합니다.

// index.cjs
(async () => {
  const { default: chalk } = await import('chalk');
  console.log(chalk.green('ok'));
})();

포인트:

  • ESM 패키지를 import하면 보통 default export로 들어오는 경우가 많아 default 디스트럭처링이 필요할 수 있습니다.
  • 최상위에서 바로 쓰고 싶으면 IIFE로 감싸거나, 함수 내부에서 로드합니다.

해결 B: ESM/CJS 듀얼 엔트리를 제공하는 패키지인지 확인

일부 패키지는 exportsrequireimport를 분리 제공합니다.

{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

이런 경우라면 내 코드가 CJS여도 require('pkg')가 가능해야 정상입니다. 그런데도 ERR_REQUIRE_ESM이 난다면:

  • 번들러가 exports 조건을 무시하고 ESM 엔트리를 잡았거나
  • 서브패스 import(pkg/something)로 들어가 ESM 파일을 직접 찍었거나
  • 패키지 버전이 바뀌며 require 엔트리가 제거되었을 수 있습니다.

해결 C: 구버전(=CJS 지원 버전)으로 고정

운영 환경에서 급하게 안정화가 필요하다면, ESM-only로 전환되기 전 버전으로 고정하는 것도 현실적입니다.

npm i chalk@4

다만 이는 “미봉책”입니다. 장기적으로는 ESM 전환 또는 대체 패키지 검토가 필요합니다.

2) 내 앱을 ESM으로 전환하는 정석 (장기적 해결)

프로젝트가 앞으로도 Node 최신 생태계를 따라가야 한다면, 애플리케이션 자체를 ESM으로 전환하는 게 결국 가장 비용이 적을 때가 많습니다.

전환 체크리스트

1) package.json에 type 설정

{
  "type": "module"
}

이제 .js는 기본이 ESM으로 해석됩니다.

2) require/module.exports 제거

// before (CJS)
const fs = require('node:fs');
module.exports = { read };

// after (ESM)
import fs from 'node:fs';
export function read() {}

3) __dirname, __filename 대체

ESM에는 __dirname이 없습니다.

import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

4) 확장자/exports/경로 해석 이슈

ESM은 상대경로 import에서 확장자를 요구하는 경우가 많습니다(특히 TS 빌드 산출물에서).

// ESM에서는 이런 형태가 안전
import { foo } from './foo.js';

이 부분에서 ERR_MODULE_NOT_FOUND가 같이 터지기 쉬우니, 앞서 언급한 글(Node.js ESM+TS에서 ERR_MODULE_NOT_FOUND 해결법)의 “확장자/exports/tsconfig” 섹션을 같이 보면 시행착오가 줄어듭니다.

3) TypeScript 빌드가 CJS로 떨어져서 발생하는 경우

소스는 ESM처럼 작성했는데, 빌드 결과가 CJS가 되면 런타임에서 ESM 패키지를 require하게 되는 상황이 생깁니다.

흔한 tsconfig 조합

문제 예: module이 CommonJS

{
  "compilerOptions": {
    "module": "CommonJS",
    "target": "ES2020",
    "outDir": "dist"
  }
}

이렇게 빌드하면 importrequire()로 변환될 수 있고, 그 결과 ESM-only 의존성에서 ERR_REQUIRE_ESM이 납니다.

해결 예: NodeNext/Node16 사용

{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022",
    "outDir": "dist",
    "verbatimModuleSyntax": true
  }
}

그리고 package.jsontype: module을 두거나, 출력 파일을 .mjs로 관리하는 식으로 런타임까지 ESM 형태가 유지되게 맞춥니다.

tsup/rollup 같은 번들러를 쓰는 경우

번들러는 기본 출력 포맷이 CJS인 경우가 많습니다. 예를 들어 tsup:

{
  "scripts": {
    "build": "tsup src/index.ts --format esm --dts"
  }
}

라이브러리를 배포한다면 --format esm,cjs로 듀얼 출력 후 exports를 구성하는 전략도 고려할 수 있습니다.

4) 테스트 환경(Jest)에서만 터지는 ERR_REQUIRE_ESM

앱 실행은 되는데 테스트만 깨지는 케이스도 많습니다. Jest는 전통적으로 CJS 기반 생태계가 강했고, ESM 지원이 점진적으로 강화되는 중이라 설정 불일치가 자주 납니다.

증상

  • 런타임: Node ESM으로 정상
  • 테스트: Jest가 CJS로 테스트 파일/의존성을 로드하다 ERR_REQUIRE_ESM

대응 방향

  • Jest를 ESM 모드로 설정하거나
  • babel-jest/ts-jest 조합에서 ESM 트랜스폼을 명시하거나
  • 테스트에서만 해당 ESM 패키지를 mock 처리

예: 테스트 코드에서 dynamic import로 회피(응급처치)

test('esm package', async () => {
  const { default: pkg } = await import('esm-only-pkg');
  expect(pkg).toBeDefined();
});

근본적으로는 테스트 러너/트랜스파일러/Node 실행 옵션을 한 방향(ESM 또는 CJS)으로 정렬하는 것이 중요합니다.

5) 라이브러리 작성자 관점: ESM+CJS 혼용을 “안전하게” 제공하기

사내 패키지나 오픈소스 라이브러리를 만든다면, 소비자 프로젝트가 CJS/ESM 어느 쪽이든 깨지지 않도록 배포 구조를 잡아야 합니다.

권장: 듀얼 빌드 + exports 조건부 엔트리

디렉토리 구조 예:

dist/
  index.mjs
  index.cjs
  index.d.ts

package.json 예:

{
  "name": "my-lib",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

주의점:

  • type: module을 켜면 .js는 ESM이 됩니다. CJS 파일은 반드시 .cjs로 분리하세요.
  • exports를 쓰면 소비자가 임의의 내부 파일 경로로 접근하기 어려워집니다. 대신 공개 API를 명확히 해야 합니다.

6) 빠르게 원인 파악하는 디버깅 루틴

ERR_REQUIRE_ESM은 “어디서 require했는지”를 찾는 게 절반입니다.

1) 스택 트레이스에서 첫 번째 require 지점 확인

에러 로그에 나오는 파일이 내 코드가 아니라면, “내 의존성 중 누가 ESM-only를 require했는지”를 추적해야 합니다.

2) 문제 패키지의 package.json 확인

node -p "require('fs').readFileSync(require.resolve('chalk/package.json'),'utf8')"

여기서 type, exports를 확인합니다.

3) 내 프로젝트의 모듈 타입 확인

  • package.jsontype
  • 엔트리 파일 확장자 .js/.mjs/.cjs
  • TS/번들러 출력 포맷

이 3가지만 정리해도 “왜 require가 튀어나왔는지”가 대부분 설명됩니다.

7) 실전 해결 시나리오 3가지 (추천 선택지)

시나리오 A: 서버 앱(백엔드)이고 새 프로젝트다 → ESM로 정렬

  • type: module
  • TS는 module: NodeNext
  • 런타임/테스트/빌드 모두 ESM 기준으로 통일

장기적으로 의존성 업데이트가 편해집니다.

시나리오 B: 레거시 CJS 앱이고 바꿀 여력이 없다 → dynamic import + 버전 고정

  • 당장 깨지는 패키지만 await import()로 로드
  • 필요하면 ESM-only 전환 전 버전으로 고정

단, 의존성 업데이트 때마다 같은 문제가 재발할 수 있습니다.

시나리오 C: 라이브러리(패키지) 배포가 목적이다 → 듀얼 빌드 + exports

  • 소비자 환경을 예측하기 어렵기 때문에 CJS/ESM 둘 다 제공
  • exports로 진입점을 통제

마무리

ERR_REQUIRE_ESM은 단순히 “require를 import로 바꾸세요”로 끝나지 않습니다. Node의 모듈 해석 규칙(type/확장자/exports) + TS/번들러 산출물 + 테스트 러너 설정이 한 번이라도 어긋나면, 런타임 어딘가에서 CJS가 ESM을 require하는 순간 폭발합니다.

정리하면:

  • 응급처치: CJS에서 ESM은 await import()로 로드
  • 근본해결(앱): ESM으로 전환하고 툴체인을 ESM 기준으로 통일
  • 근본해결(라이브러리): ESM/CJS 듀얼 출력 + exports 조건부 엔트리

ESM으로 전환하는 과정에서 경로/확장자 문제로 ERR_MODULE_NOT_FOUND가 함께 등장한다면, 아래 글이 이어지는 디버깅에 도움이 됩니다.