- Published on
Node.js ERR_REQUIRE_ESM - ESM/CJS 충돌 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버를 올리자마자 Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported가 터지면, 대부분은 “내 코드는 CJS인데 의존성(또는 트랜스파일 결과)이 ESM으로 바뀌었다”는 신호입니다. 이 에러는 단순히 require를 import로 바꾸는 수준에서 끝나지 않고, package.json의 type, 번들러 출력 포맷, TypeScript 컴파일 옵션, 테스트 러너 설정까지 연쇄적으로 영향을 줍니다.
이 글에서는 ERR_REQUIRE_ESM의 정확한 발생 조건을 정리하고, 가장 덜 위험한 해결 순서(프로젝트를 ESM으로 전환, CJS 유지 시 우회, 라이브러리/도구별 대응)로 실전 해결책을 제공합니다.
ERR_REQUIRE_ESM이 발생하는 이유
Node.js는 모듈 시스템을 크게 두 가지로 다룹니다.
- CommonJS(CJS):
require()와module.exports - ECMAScript Modules(ESM):
import/export
문제는 CJS에서 ESM을 require()로 로드하려고 할 때입니다. ESM은 로딩/실행 모델이 다르기 때문에 Node는 다음을 허용하지 않습니다.
- CJS 파일에서
require('some-esm-only-package') type: module패키지의 ESM 엔트리를 CJS 방식으로 강제 로드
대표적인 트리거는 아래와 같습니다.
- 의존성이 메이저 업데이트로 ESM-only가 됨
package.json에"type": "module"이 추가되어.js가 ESM으로 해석됨- TypeScript/번들러가 ESM 출력물을 만들었는데 실행은 CJS로 함
- 테스트 러너(Jest 등)가 CJS 환경인데 코드/의존성이 ESM
먼저 확인할 것: 지금 내 프로젝트는 ESM인가 CJS인가
1) package.json의 type
"type": "module"이면.js는 기본적으로 ESM"type": "commonjs"이거나type이 없으면.js는 기본적으로 CJS
{
"name": "my-app",
"type": "commonjs"
}
2) 파일 확장자 규칙
- ESM을 명시:
.mjs - CJS를 명시:
.cjs
type이 무엇이든, 확장자를 명시하면 해석이 고정됩니다. 충돌을 빠르게 봉합할 때 .cjs/.mjs는 매우 유용합니다.
3) 에러 메시지에서 힌트를 읽기
에러에는 보통 다음 정보가 들어 있습니다.
- 어떤 파일이
require()를 호출했는지 - 어떤 패키지가 ESM으로 판정되었는지
즉, “내 코드가 문제인지, 의존성이 ESM-only인지”를 먼저 분리해야 합니다.
해결 전략 1: 프로젝트를 ESM으로 전환(권장)
장기적으로 가장 깔끔한 해결은 애플리케이션을 ESM으로 통일하는 것입니다. 특히 최신 생태계(예: 일부 유틸 패키지, 프론트/풀스택 툴체인)는 ESM을 기본으로 가는 경우가 많습니다.
1) package.json을 ESM으로
{
"type": "module"
}
2) require를 import로 변경
// before (CJS)
const chalk = require('chalk');
// after (ESM)
import chalk from 'chalk';
3) ESM에서 __dirname 대체
ESM에는 __filename, __dirname이 기본 제공되지 않습니다. 다음 패턴을 사용합니다.
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
console.log(__dirname);
4) TypeScript를 쓰는 경우(ESM 출력 맞추기)
Node 런타임 ESM과 TypeScript는 설정 조합이 중요합니다.
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"outDir": "dist"
}
}
module과moduleResolution을NodeNext로 맞추면 Node의 ESM 규칙(확장자/exports)을 더 정확히 따릅니다.
타입 구성이 복잡해질 때는 satisfies를 활용해 설정 객체를 안전하게 유지하는 것도 도움이 됩니다. 관련해서는 TS 5.6 satisfies로 타입 유지하며 검증하는 법도 참고할 만합니다.
해결 전략 2: CJS를 유지하면서 ESM 의존성만 우회 로드
프로젝트 전체를 ESM으로 바꾸기 어렵다면, CJS는 유지하되 ESM 패키지를 동적 import로 로드하는 방식이 현실적인 타협점입니다.
1) CJS에서 import() 사용
Node의 CJS에서도 import()는 사용할 수 있습니다(비동기).
// index.cjs
async function main() {
const { default: got } = await import('got');
const res = await got('https://example.com');
console.log(res.statusCode);
}
main().catch(console.error);
핵심 포인트:
- ESM 패키지가
default export를 제공하면default로 받아야 할 수 있음 import()는 비동기이므로 초기화 흐름을 바꿔야 함
2) ESM-only 패키지의 CJS 대체 엔트리 확인
일부 패키지는 exports에서 CJS/ESM을 모두 제공합니다. 이때는 패키지 문서대로 CJS 엔트리를 지정할 수 있습니다.
예: exports가 require 조건을 제공하는 경우, 단순 require('pkg')가 동작해야 정상입니다. 그런데도 에러가 나면 다음을 의심하세요.
- 번들러가
exports조건을 무시하고 ESM 엔트리를 고정해버림 - 트랜스파일 결과가 ESM인데 실행은 CJS
3) 최후의 봉합: 해당 파일만 .mjs로 분리
CJS 프로젝트에서 특정 파일만 ESM으로 만들고 싶다면:
- 호출하는 쪽은 CJS
- ESM 로직은
.mjs로 분리 - CJS에서
import()로.mjs를 로드
// esm-worker.mjs
export function work() {
return 'ok';
}
// index.cjs
async function main() {
const { work } = await import('./esm-worker.mjs');
console.log(work());
}
main();
해결 전략 3: 번들러/트랜스파일러 출력 포맷을 실행 환경과 일치시키기
ERR_REQUIRE_ESM은 “소스는 CJS인데 번들 결과가 ESM” 또는 그 반대에서도 자주 발생합니다.
1) tsc 출력과 Node 실행 방식 맞추기
- Node를 CJS로 실행한다면
tsc도 CJS로 출력
{
"compilerOptions": {
"module": "CommonJS",
"target": "ES2020",
"outDir": "dist"
}
}
- Node를 ESM으로 실행한다면
NodeNext계열로
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext"
}
}
2) package.json의 실행 엔트리 확인
배포/실행이 node dist/index.js인데, dist/index.js가 ESM으로 생성되면 CJS 컨텍스트에서 충돌이 납니다. 반대로 type: module인데 dist/index.js가 CJS면 exports is not defined 같은 반대 유형의 에러가 나기도 합니다.
해결 전략 4: Jest/테스트 환경에서의 ESM/CJS 충돌
테스트에서만 ERR_REQUIRE_ESM이 난다면, 앱 런타임보다 테스트 러너가 더 보수적(CJS) 이기 때문인 경우가 많습니다.
대응 방향은 2가지입니다.
- 테스트 러너를 ESM 지원 구성으로 전환
- 테스트 대상 모듈을 CJS로 내리거나(빌드 산출물) 동적 import로 분리
Jest는 설정 조합이 많아 프로젝트마다 답이 달라지므로, 우선순위는 다음이 안전합니다.
- 앱 빌드 산출물을 기준으로 테스트(런타임과 동일한 모듈 시스템)
- 불가하면 ESM-only 의존성 부분만
import()로 격리
자주 만나는 실제 케이스별 처방전
케이스 A: 오래된 Node 버전
ESM 지원은 Node 버전에 따라 차이가 큽니다. 가능하면 LTS(예: 18 이상, 가능하면 20 이상)로 올리는 게 비용 대비 효과가 큽니다.
- CI와 로컬 Node 버전이 다르면 “로컬에서는 되는데 배포에서만 실패”가 발생합니다.
케이스 B: 의존성 메이저 업데이트로 ESM-only 전환
대표 대응은 아래 중 하나입니다.
- 프로젝트를 ESM으로 전환(권장)
- 해당 의존성을 이전 메이저로 고정
- CJS 대체 패키지로 교체
- ESM 의존성 사용 지점을 동적 import로 격리
케이스 C: 모노레포에서 패키지별 type 혼재
워크스페이스에서 어떤 패키지는 type: module, 어떤 패키지는 CJS면, 내부 패키지 import에서 충돌이 쉽게 납니다.
- 패키지별로
main/exports를 명확히 분리(CJS와 ESM을 둘 다 제공) - 내부 소비자가 어떤 포맷을 쓰는지 통일
디버깅 체크리스트
문제를 빨리 좁히려면 아래 순서대로 확인하세요.
- 에러가 가리키는 “require를 호출한 파일”이 내 코드인지, 의존성인지
package.json의type과 실제 실행 파일 확장자(.js,.cjs,.mjs)- 빌드 산출물의 모듈 포맷(ESM인지 CJS인지)
- 문제가 된 패키지가 ESM-only인지(릴리즈 노트/exports 확인)
- 임시 봉합이 필요하면 CJS에서
import()로 격리
서비스 장애 대응 관점에서 보면, 이런 “환경/구성 불일치” 류 이슈는 증상은 단순하지만 원인 지점이 다양합니다. 운영에서 비슷한 패턴의 진단 접근이 필요하다면 K8s Pod CrashLoopBackOff 원인 7가지와 해결처럼 체크리스트 기반으로 좁혀가는 방식이 꽤 유효합니다.
결론: 가장 안전한 해결 순서
- 가능하면 프로젝트를 ESM으로 통일하고(
type: module),import기반으로 정리 - 전환이 어렵다면 CJS는 유지하되 ESM-only 의존성만
import()로 격리 - 빌드 도구/TypeScript 설정이 런타임과 일치하는지(ESM 출력물을 CJS로 실행하고 있지 않은지) 반드시 확인
ERR_REQUIRE_ESM은 “한 줄 수정”으로 끝나는 문제가 아니라, 모듈 경계가 어긋났다는 신호입니다. 한 번 정리해두면 이후 의존성 업데이트나 런타임 업그레이드에서도 같은 유형의 장애를 크게 줄일 수 있습니다.