- 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.
대부분의 경우 **“내 코드는 CommonJS(CJS)인데, 의존성 중 하나가 ESM 전용으로 바뀌었다”**가 핵심 원인입니다. 문제는 단순히 require를 import로 바꾸는 수준이 아니라, 패키지 경계(내 코드/의존성/빌드 산출물/테스트 런너) 전체에서 모듈 시스템이 엮여 있기 때문에 재현도 어렵고 해결도 헷갈립니다.
이 글은 ERR_REQUIRE_ESM이 왜 발생하는지, 그리고 CJS/ESM 혼용 상태에서 가장 안전하게 해결하는 방법을 우선순위대로 정리합니다. (Node 18~22 기준)
> Node 22에서 require가 막혀서 ESM 전환이 필요하다면, 더 큰 관점의 마이그레이션은 아래 글도 함께 보세요: Node 22에서 require가 안 될 때 ESM 전환법
1) ERR_REQUIRE_ESM이 나는 구조를 먼저 이해하기
Node.js는 크게 두 모듈 시스템을 지원합니다.
- CommonJS(CJS):
require(),module.exports - ESM:
import,export
문제는 CJS에서 ESM을 다음처럼 불러오려 할 때 터집니다.
// index.cjs
const got = require('got'); // got v12+는 ESM 전용
이때 Node는 “ESM은 require()로 로드할 수 없다”고 판단하고 ERR_REQUIRE_ESM을 던집니다.
ESM 전용 패키지는 어떻게 구분되나?
의존성 패키지의 package.json을 보면 힌트가 있습니다.
"type": "module"이면 기본이 ESMexports필드가 ESM 엔트리만 제공하는 경우.mjs확장자
예:
{
"name": "some-lib",
"type": "module",
"exports": {
".": "./dist/index.js"
}
}
이런 패키지를 CJS에서 require('some-lib') 하면 거의 확정으로 터집니다.
2) 가장 현실적인 1차 처방: CJS에서 동적 import() 사용
Node는 CJS 파일에서도 import()(동적 import) 는 허용합니다. 그래서 CJS를 유지해야 하는 상황(레거시 코드, Jest 설정, 특정 런타임 제약 등)에서는 이게 가장 빠른 해결책입니다.
예: CJS에서 ESM 패키지 로드
// 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: ... }로 받는 경우가 많습니다. - named export면 구조분해로 받으면 됩니다.
const { execa } = await import('execa');
“동기 require 자리에 async가 들어와서 구조가 깨져요”
이게 동적 import의 가장 큰 단점입니다. 기존 코드가 동기 초기화를 전제로 작성됐다면, 아래 중 하나로 우회합니다.
- 초기화 시점만 async로 바꾸고 나머지는 그대로 유지
- ESM을 감싸는 래퍼 모듈을 만들어 호출부 변경 최소화
아래에서 래퍼 패턴을 다룹니다.
3) 호출부 변경을 줄이는 래퍼(Bridge) 모듈 패턴
CJS 코드가 많고, 여기저기서 require('esm-lib')를 쓰고 있다면 호출부를 전부 async로 바꾸기 어렵습니다. 이때는 “한 군데에서만 async로 받아서 캐시해두는” 래퍼를 둡니다.
예: ESM 라이브러리를 CJS에서 싱글톤처럼 쓰기
// esm-bridge.cjs
let _clientPromise;
function getClient() {
if (!_clientPromise) {
_clientPromise = import('got').then(m => m.default);
}
return _clientPromise;
}
module.exports = { getClient };
사용처:
// service.cjs
const { getClient } = require('./esm-bridge.cjs');
async function fetchSomething(url) {
const got = await getClient();
const res = await got(url);
return res.body;
}
module.exports = { fetchSomething };
이렇게 하면 ESM 로딩은 1회로 제한되고, 변경 범위도 작아집니다.
4) 정공법: 프로젝트를 ESM으로 전환(또는 경계 분리)
장기적으로는 “내 프로젝트도 ESM”으로 가는 게 가장 깔끔합니다. 특히 최신 생태계(번들러, TS, 일부 라이브러리)는 ESM 중심으로 이동 중이라 혼용 비용이 점점 커집니다.
4-1) package.json에서 ESM 선언
{
"type": "module"
}
그리고 파일 내보내기/가져오기를 ESM으로 맞춥니다.
// index.js (ESM)
import got from 'got';
export function run() {}
4-2) CJS가 꼭 필요하면 확장자로 경계 만들기
ESM 프로젝트에서도 특정 파일만 CJS로 유지할 수 있습니다.
- ESM 기본:
.js(type=module) - CJS로 강제:
.cjs
예:
src/
index.js // ESM
legacy.cjs // CJS
반대로 CJS 프로젝트에서 ESM 파일만 쓰고 싶으면 .mjs로 분리할 수 있습니다.
5) TypeScript를 쓰는 경우: tsconfig와 출력 포맷이 핵심
TS 프로젝트에서 ERR_REQUIRE_ESM이 나는 흔한 시나리오:
- 소스는
import를 쓰는데 tsc출력이module: commonjs라서- 런타임에서
require()형태로 바뀌며 - ESM 전용 의존성을 require 하다가 폭발
5-1) NodeNext로 맞추기(권장)
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"esModuleInterop": true
}
}
NodeNext는 Node의 ESM 규칙(확장자/exports)을 더 정확히 따릅니다.- ESM/CJS 혼용 프로젝트에서 “컴파일은 되는데 런타임이 깨지는” 문제를 줄여줍니다.
5-2) 패키지 타입과 출력 확장자 정합성
"type": "module"이면 dist의.js는 ESM으로 해석됩니다.- CJS 출력이 필요하면
.cjs로 내거나type을 조정해야 합니다.
실무에서는 아래 중 하나로 정리합니다.
- 완전 ESM:
type: module+module: NodeNext+ dist.js - 완전 CJS:
type제거(또는commonjs) +module: commonjs+ ESM 의존성은 동적 import/대체 패키지 - 혼용: 엔트리/설정 파일만
.cjs로 두고 앱 코드는 ESM
6) 자주 밟는 지뢰: Jest/ts-jest, ESLint 설정 파일, config 로더
ERR_REQUIRE_ESM은 앱 코드뿐 아니라 도구 체인에서도 터집니다.
jest.config.js가 CJS인데 ESM 설정/플러그인을require함eslint.config.js(flat config)에서 모듈 타입 불일치webpack.config.js,rollup.config.js등
해결 패턴
- 설정 파일을
.cjs로 고정하거나 - 반대로 프로젝트가 ESM이면 설정도 ESM으로 바꾸기
예: Jest 설정을 CJS로 고정
// jest.config.cjs
module.exports = {
testEnvironment: 'node'
};
예: ESM 프로젝트에서 Jest를 쓴다면, Jest의 ESM 지원 옵션/실행 플래그를 함께 맞춰야 합니다(버전별로 접근이 다름). 이 구간은 “코드 수정”보다 “툴 버전/옵션 정합성”이 더 중요합니다.
7) 의존성 버전 전략: “ESM 전용으로 바뀐 순간”을 관리하기
많이 발생하는 케이스는 다음입니다.
npm install또는 CI에서 lockfile이 갱신됨- 특정 라이브러리가 메이저 업그레이드되며 ESM 전용이 됨
- CJS 코드가 깨짐
대응 체크리스트
- 문제 패키지 확인: 스택트레이스에서 “require() of ES Module …”의 대상 경로 확인
- 패키지 릴리스 노트 확인: 메이저 변경에서 ESM 전용 전환이 흔함
- 단기 해결: 동적 import 또는 브리지 모듈
- 중기 해결: 프로젝트 ESM 전환 또는 대체 패키지 검토
- 재발 방지: lockfile 고정, Renovate/Dependabot에서 메이저 자동 머지 금지
8) 실전 예시: express(CJS) + ESM 전용 라이브러리 섞기
가령 기존 서버가 CJS로 구성되어 있고, 새로 도입한 라이브러리(예: got, node-fetch@3 등)가 ESM 전용이라고 가정해봅시다.
기존 코드(깨짐)
// app.cjs
const express = require('express');
const fetch = require('node-fetch'); // node-fetch@3는 ESM 전용
const app = express();
app.get('/', async (req, res) => {
const r = await fetch('https://example.com');
res.send(await r.text());
});
app.listen(3000);
수정 코드(동적 import)
// app.cjs
const express = require('express');
const app = express();
app.get('/', async (req, res) => {
const { default: fetch } = await import('node-fetch');
const r = await fetch('https://example.com');
res.send(await r.text());
});
app.listen(3000);
또는 fetch를 매 요청마다 import하는 게 싫다면 브리지로 캐싱합니다.
// fetch-bridge.cjs
let _fetch;
async function getFetch() {
if (!_fetch) {
_fetch = (await import('node-fetch')).default;
}
return _fetch;
}
module.exports = { getFetch };
// app.cjs
const express = require('express');
const { getFetch } = require('./fetch-bridge.cjs');
const app = express();
app.get('/', async (req, res) => {
const fetch = await getFetch();
const r = await fetch('https://example.com');
res.send(await r.text());
});
app.listen(3000);
9) 컨테이너/쿠버네티스에서 더 위험한 이유: “재시작 루프”로 번진다
로컬에서는 바로 에러가 보여도, 쿠버네티스에서는 앱이 즉시 죽으면서 CrashLoopBackOff로 이어져 원인 파악이 늦어질 수 있습니다. 특히 로그 수집/사이드카/프로브 설정에 따라 “로그가 안 남는 것처럼” 보이기도 합니다.
- 앱이 시작 직후 모듈 로딩에서 죽음 → readiness/liveness 통과 실패 → 재시작 반복
운영에서 이런 상황을 빨리 진단하는 방법은 아래 글이 도움이 됩니다.
10) 결론: 우선순위별 권장 해결책
- 가장 빠른 응급처치: CJS에서
await import()로 ESM 패키지를 로드 - 호출부 변경 최소화: 브리지(래퍼) 모듈로 import를 한 곳에 모으고 캐싱
- 장기적으로 안정적: 프로젝트를 ESM으로 전환하거나, CJS/ESM 경계를
.cjs/.mjs로 명확히 분리 - TS 사용 시:
module/moduleResolution: NodeNext로 런타임 규칙과 컴파일 규칙을 일치 - 재발 방지: lockfile 고정 + 메이저 업그레이드 자동 반영 방지 + 릴리스 노트 확인
ERR_REQUIRE_ESM은 단순 오류가 아니라 “생태계가 ESM으로 이동하는 과정에서 생기는 마찰”입니다. 한 번에 완전 전환이 어렵다면, 위의 동적 import → 브리지 → 점진적 ESM 전환 순서로 단계적으로 정리하는 것이 가장 안전합니다.