- Published on
Node.js ESM+CJS 혼용 시 ERR_REQUIRE_ESM 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 런타임을 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의 전형적인 트리거는 다음과 같습니다.
- 어떤 패키지가 ESM-only로 배포됨
- 내 코드(또는 내 의존성)가 여전히
require('that-package')를 실행함 - Node가 “그 파일은 ES Module인데 require로 못 불러”라고 중단
여기서 “그 파일이 ESM인지”는 단순히 확장자만 보고 결정되지 않습니다. Node는 아래 규칙을 조합해서 판단합니다.
.mjs는 ESM,.cjs는 CJS.js는 **가장 가까운 package.json의type**에 따라 ESM/CJS 결정- 패키지의
package.json에exports가 있으면 조건부 엔트리(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 듀얼 엔트리를 제공하는 패키지인지 확인
일부 패키지는 exports로 require와 import를 분리 제공합니다.
{
"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"
}
}
이렇게 빌드하면 import가 require()로 변환될 수 있고, 그 결과 ESM-only 의존성에서 ERR_REQUIRE_ESM이 납니다.
해결 예: NodeNext/Node16 사용
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"outDir": "dist",
"verbatimModuleSyntax": true
}
}
그리고 package.json에 type: 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.json의type- 엔트리 파일 확장자
.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가 함께 등장한다면, 아래 글이 이어지는 디버깅에 도움이 됩니다.