- Published on
Node.js ESM/CJS 충돌 ERR_REQUIRE_ESM 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 프로젝트를 운영하다 보면 어느 날 갑자기 Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported가 터집니다. 보통은 의존성 업데이트(특히 마이너/패치) 이후에 발생하고, 원인은 단순합니다. CommonJS(CJS) 런타임에서 require()로 ESM 전용 모듈을 로드하려고 했기 때문입니다.
문제는 “그럼 ESM으로 바꾸면 되죠”가 항상 정답이 아니라는 점입니다. 레거시 코드, 테스트 러너, 배포 환경, 트랜스파일 파이프라인이 얽혀 있으면 점진적 마이그레이션이 필요합니다. 이 글에서는 ERR_REQUIRE_ESM을 재현하고, 원인을 정확히 진단한 뒤, 프로덕션에서 가장 많이 쓰는 해결 패턴을 정리합니다.
또한 ESM 전환 과정에서 자주 같이 터지는 require is not defined 이슈는 별도 글로 정리해두었습니다: Node.js ESM에서 require is not defined 오류 해결
ERR_REQUIRE_ESM 에러가 나는 정확한 조건
대부분 아래 조합에서 발생합니다.
- 내 코드가 CJS:
package.json에"type": "commonjs"이거나, 파일 확장자가*.cjs이거나, 혹은 번들 결과물이 CJS - 불러오려는 라이브러리가 ESM 전용: 패키지의
package.json에"type": "module"이거나,exports가 ESM 엔트리만 제공 - 그 라이브러리를
require('some-esm-lib')형태로 로드
Node.js는 CJS의 require()가 ESM 모듈을 직접 로드하는 것을 허용하지 않습니다(상호 운용은 가능하지만 방식이 다릅니다). 그래서 아래처럼 터집니다.
// index.cjs
const got = require('got');
// got v12+ 는 대표적인 ESM 전용 패키지
(async () => {
const res = await got('https://example.com');
console.log(res.statusCode);
})();
실행하면 보통 이런 메시지 패턴이 나옵니다.
Error [ERR_REQUIRE_ESM]: require() of ES Module ... from ... not supported.Instead change the require of ... to a dynamic import() which is available in all CommonJS modules.
핵심은 Node가 친절하게 힌트를 줍니다. CJS에서는 동적 import()로 우회하라는 것.
1분 진단 체크리스트
문제가 생겼을 때 가장 먼저 아래를 확인하면 원인 확정이 빠릅니다.
1) 내 프로젝트 모듈 타입 확인
package.json:
{
"type": "commonjs"
}
"type": "module"이면 기본이 ESM"type": "commonjs"이거나 없으면 기본이 CJS- 파일 확장자
*.mjs는 ESM,*.cjs는 CJS로 강제
2) 문제 라이브러리가 ESM 전용인지 확인
해당 패키지의 node_modules/패키지명/package.json을 봅니다.
"type": "module""exports"에"require"엔트리가 없고"import"만 있는 경우
예시(개념 예시):
{
"name": "some-lib",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js"
}
}
}
이런 형태면 CJS의 require('some-lib')는 실패할 확률이 매우 높습니다.
3) 실제로 어디서 require()가 호출되는지 추적
내 코드가 아니라 의존성이 의존성을 require하다가 터질 수도 있습니다.
- 스택 트레이스에서 “첫 번째로 내 코드가 등장하는 지점”을 찾기
- 그 파일이
*.cjs인지, 또는 번들 결과물인지 확인
해결 전략 1: CJS에서 ESM을 동적 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는
import()결과의default에 들어가는 경우가 많습니다. - named export라면 구조 분해로 바로 받습니다.
// index.cjs
async function main() {
const { execa } = await import('execa');
const { stdout } = await execa('node', ['-v']);
console.log(stdout);
}
main();
동적 import를 동기 API처럼 감싸기
기존 코드가 동기 require() 기반이라 호출부를 전부 async로 바꾸기 어렵다면, 로더를 한 번만 초기화하고 공유하는 방식이 실무에서 많이 쓰입니다.
// esm-loader.cjs
let _gotPromise;
function getGot() {
if (!_gotPromise) {
_gotPromise = import('got').then((m) => m.default);
}
return _gotPromise;
}
module.exports = { getGot };
// app.cjs
const { getGot } = require('./esm-loader.cjs');
async function handler(req, res) {
const got = await getGot();
const r = await got('https://example.com');
res.end(String(r.statusCode));
}
module.exports = { handler };
장점:
- 변경 범위를 최소화
- 초기화 비용을 1회로 제한
단점:
- 결국 호출부는
await를 필요로 함 - 테스트에서 모킹이 약간 까다로워질 수 있음
해결 전략 2: 내 프로젝트를 ESM으로 전환(근본 해결)
중장기적으로는 ESM으로 옮기는 것이 가장 깔끔합니다. 특히 최신 생태계(예: 일부 HTTP 클라이언트, 번들 도구, 유틸)가 ESM 우선으로 가고 있어, CJS 유지 비용이 점점 커집니다.
전환의 최소 단위
package.json에"type": "module"선언require()를import로 변경__dirname,__filename대체
예시:
{
"type": "module"
}
// index.js (ESM)
import got from 'got';
const res = await got('https://example.com');
console.log(res.statusCode);
__dirname 대체는 거의 필수
ESM에는 __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);
전환 시 require is not defined가 함께 터지기 쉬운데, 이 부분은 위에서 링크한 글을 같이 보면 전환 속도가 빨라집니다.
해결 전략 3: 듀얼 패키지(라이브러리 개발자 관점)
내가 만드는 패키지가 다른 프로젝트에서 사용되고, 사용자가 CJS/ESM이 섞여 있다면 듀얼 빌드가 가장 안전합니다.
핵심은 exports에 import와 require 엔트리를 동시에 제공하는 것입니다.
{
"name": "my-lib",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.cjs",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
빌드는 보통 tsup, rollup, esbuild 등으로 ESM/CJS를 동시에 뽑습니다.
tsup 예시:
{
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts"
}
}
이렇게 해두면 소비자 입장에서:
- ESM 프로젝트는
import로 - CJS 프로젝트는
require()로
각자 자연스럽게 로드됩니다.
해결 전략 4: 의존성 버전 고정 또는 CJS 호환 버전 사용
운영 중 긴급 장애라면 “코드 변경 없이” 복구해야 할 때가 있습니다. 이때는 다음이 가장 빠릅니다.
- 문제가 된 패키지를 CJS 지원 버전으로 다운그레이드
package-lock.json또는pnpm-lock.yaml을 커밋하고 배포
예시(개념):
npm i got@11
주의:
- 보안 패치가 포함된 최신 버전을 못 쓰는 trade-off가 생깁니다.
- 다른 의존성이 해당 패키지의 최신 API를 요구하면 연쇄 충돌이 날 수 있습니다.
그래서 이 방법은 단기 롤백으로 쓰고, 이후에는 ESM 전환 또는 동적 import로 정리하는 편이 안전합니다.
해결 전략 5: 트랜스파일/번들 단계에서 CJS로 흡수
서버 코드를 번들링해서 배포하는 구조(예: Lambda, Cloudflare Workers 일부 구성, 단일 파일 배포)라면 번들러가 ESM 의존성을 처리하도록 만드는 것도 방법입니다.
esbuild로 CJS 번들 예시:
npx esbuild src/index.js --bundle --platform=node --format=cjs --outfile=dist/index.cjs
이 방식은 “런타임에서 ESM/CJS 경계를 넘나드는 문제”를 빌드 타임으로 옮겨 해결합니다.
다만 다음을 점검해야 합니다.
- 번들러가 해당 패키지의 조건부 exports를 올바르게 해석하는지
- Node 내장 모듈 및 동적 require가 섞인 의존성에서 번들 안정성이 괜찮은지
실전에서 가장 자주 터지는 케이스 3가지
1) 테스트 러너(Jest)와 ESM 패키지
Jest 환경이 CJS 기반으로 구성되어 있으면, 테스트 코드에서 require()로 ESM을 불러오다가 터집니다.
대응:
- 테스트 파일만 ESM으로 실행되도록 설정하거나
- 테스트 대상 모듈 로딩을 동적
import()로 변경
2) ts-node / ts-jest와 모듈 설정 불일치
TypeScript에서 module, moduleResolution 값이 런타임(Node)과 어긋나면, 컴파일 결과는 ESM인데 실행은 CJS로 하다가 충돌합니다.
점검 포인트:
tsconfig.json의module이NodeNext또는ESNext인지- 실행 커맨드가 ESM을 지원하는 방식인지
3) 프레임워크/툴이 내부적으로 require를 고정 사용
예를 들어 특정 CLI 도구가 플러그인을 require()로만 로드한다면, 플러그인을 ESM으로만 제공하는 순간 사용자가 ERR_REQUIRE_ESM을 보게 됩니다.
이 경우 플러그인 측에서 듀얼 패키지로 제공하는 것이 가장 깔끔합니다.
권장 의사결정 가이드
- 빠른 복구가 목적: 의존성 다운그레이드 또는 동적
import() - 코드베이스가 성장 중이고 신기능/최신 패키지가 중요: 프로젝트 ESM 전환
- 라이브러리를 배포하고 소비자가 다양: 듀얼 패키지 exports 제공
- 배포가 번들 기반이고 런타임 단순화가 목표: 번들러로 CJS 단일 산출물
운영 관점에서는 “일단 동적 import()로 막고, 이후 ESM 전환을 계획”하는 조합이 가장 흔합니다.
마무리: ERR_REQUIRE_ESM은 버그가 아니라 경계 신호
ERR_REQUIRE_ESM은 Node가 모듈 시스템 경계를 엄격하게 지키면서 발생하는 정상적인 에러입니다. 중요한 건 어느 지점에서 CJS가 ESM을 require()했는지를 정확히 찾아내고, 상황에 맞는 해결책을 선택하는 것입니다.
- 단기: 동적
import()로 충돌 지점만 치료 - 중장기: ESM으로 정리해서 생태계 변화 비용을 줄이기
- 배포/라이브러리: exports 설계를 통해 소비자 환경을 흡수
ESM 전환 과정에서 동반되는 런타임 이슈(예: require is not defined)까지 함께 정리하고 싶다면 다음 글도 같이 보면 좋습니다: Node.js ESM에서 require is not defined 오류 해결