- Published on
Node 22에서 require ESM 에러 해결 6가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버를 Node 22로 올린 뒤 갑자기 터지는 대표적인 런타임 에러가 있습니다. 바로 require()로 ES Module을 불러오려 할 때 나는 오류입니다. 메시지는 환경에 따라 조금 다르지만 보통 다음 형태로 나타납니다.
Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supportedMust use import to load ES Module
이 글은 “왜 Node 22에서 더 자주 보이는가”를 짚고, 상황별로 바로 적용 가능한 해결책 6가지를 제시합니다. (정답은 하나가 아니라, 프로젝트의 모듈 시스템과 배포 방식에 따라 최선이 달라집니다.)
왜 Node 22에서 require ESM 문제가 더 잘 드러나나
핵심은 하나입니다. CommonJS인 코드가 require()로 불러오려는 대상이 ESM으로 선언되어 있다는 점입니다.
ESM으로 판별되는 대표 조건은 다음과 같습니다.
- 패키지의
package.json에"type": "module"이 있다 - 파일 확장자가
.mjs다 - 또는 패키지가
exports필드로 ESM 엔트리만 노출한다
Node 22 자체가 “갑자기 CJS를 못 쓰게” 만든 건 아니지만, 최근 생태계는 ESM-only 패키지가 늘었고, exports/조건부 익스포트가 보편화되면서 CJS에서 ESM을 require()로 끌어다 쓰는 순간 더 자주 충돌합니다.
에러 재현: CJS에서 ESM-only 패키지 require()
아래는 전형적인 패턴입니다.
// index.cjs (또는 type이 commonjs인 프로젝트)
const pkg = require('some-esm-only-package');
console.log(pkg);
some-esm-only-package가 ESM-only면 Node는 CJS require()로는 로드할 수 없다고 판단하고 ERR_REQUIRE_ESM을 던집니다.
해결 1) 프로젝트를 ESM으로 전환하고 import로 통일
가장 “정공법”입니다. 애초에 애플리케이션을 ESM으로 전환하면 ESM-only 의존성을 자연스럽게 사용할 수 있습니다.
적용 방법
package.json에 다음을 추가합니다.
{
"type": "module"
}
require()를import로 바꿉니다.
// index.js
import pkg from 'some-esm-only-package';
console.log(pkg);
- Node 내장 모듈도 ESM 문법으로 바꿉니다.
import fs from 'node:fs';
import path from 'node:path';
장단점
- 장점: 생태계 흐름과 맞고, ESM-only 패키지 대응이 가장 깔끔함
- 단점: 레거시 CJS 코드, 테스트/빌드 스크립트, 도구 체인(특히 오래된 설정 파일)까지 연쇄 수정이 필요할 수 있음
해결 2) CJS를 유지하되 import() 동적 로딩으로 우회
코드베이스가 CJS이고 당장 전체를 ESM으로 바꾸기 어렵다면, 동적 import() 가 가장 실용적인 타협안입니다. Node는 CJS 파일에서도 import() 표현식을 허용합니다.
// index.cjs
async function main() {
const mod = await import('some-esm-only-package');
// default export면 mod.default
console.log(mod.default ?? mod);
}
main().catch(console.error);
자주 겪는 함정: default export
ESM 모듈을 import()로 가져오면 네임스페이스 객체가 오므로 default가 있는지 확인해야 합니다.
export default ...면mod.defaultexport const x = ...면mod.x
언제 추천하나
- 앱은 CJS 유지, 특정 의존성만 ESM-only인 경우
- 초기화 시점에만 로드해도 되는 라이브러리(예: 설정 로더, 특정 플러그인)
해결 3) ESM 전용 라이브러리의 “CJS 엔트리”를 정확히 찍어서 로드
일부 패키지는 ESM-only처럼 보이지만 실제로는 조건부 익스포트로 CJS 엔트리를 제공합니다. 이때는 “루트 경로”로 require()하면 ESM이 잡히고, CJS 엔트리를 직접 지정하면 해결되는 경우가 있습니다.
예시(패키지마다 다릅니다):
// index.cjs
const pkg = require('some-package/cjs');
또는 exports가 아래처럼 구성된 패키지라면:
{
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
정상이라면 require('some-package')가 자동으로 require 조건을 타야 합니다. 하지만 번들러/트랜스파일 결과물, 경로 지정, 또는 하위 경로 import 방식 때문에 의도와 다르게 ESM 엔트리를 집는 경우가 있습니다.
체크 포인트
node -p "require('some-package/package.json').exports"로 확인- 문서에 “CJS entry”, “require entry”, “dual package” 언급이 있는지 확인
해결 4) 의존성 버전을 CJS 지원 버전으로 고정(또는 대체 패키지 사용)
현실적인 방법입니다. 최근 버전에서 ESM-only로 전환한 패키지가 많기 때문에, 마이너/메이저 업그레이드 후 갑자기 에러가 나기도 합니다.
대응 전략
- 마지막으로 CJS를 지원하던 버전으로 pin
- 또는 동일 기능의 CJS 호환 대체 패키지로 교체
예시(개념 예시):
{
"dependencies": {
"some-esm-only-package": "1.9.3"
}
}
주의
- 보안 패치가 최신에만 제공될 수 있음
- 트랜지티브 의존성(간접 의존성)에서 ESM-only가 들어오는 경우
overrides(npm)나resolutions(yarn/pnpm)로 조정이 필요할 수 있음
운영 환경에서 이런 “갑작스런 깨짐”은 CI 속도, 캐시, 락파일 변경과도 엮여 체감이 커집니다. 빌드/배포 파이프라인이 느려져 원인 파악이 늦어지는 경우도 많으니, 병목이 의심되면 Jenkins 빌드가 갑자기 느려질 때 원인 7가지 같은 체크리스트로 같이 점검해두면 좋습니다.
해결 5) 경계 레이어를 분리: ESM 래퍼 파일을 만들고 CJS에서 호출
CJS 코드가 많고, ESM-only 모듈을 여러 곳에서 쓰고 싶다면 “어댑터”를 두는 방식이 유지보수에 유리합니다.
구조 예시
esm-adapter.mjs는 ESM- 기존 CJS는 이 어댑터만
import()로 한 번 호출
// esm-adapter.mjs
import pkg from 'some-esm-only-package';
export function doSomething(input) {
return pkg.process(input);
}
// index.cjs
let adapterPromise;
function getAdapter() {
if (!adapterPromise) adapterPromise = import('./esm-adapter.mjs');
return adapterPromise;
}
async function main() {
const { doSomething } = await getAdapter();
console.log(doSomething('hello'));
}
main().catch(console.error);
장점
- ESM과 CJS의 혼재를 “한 지점”으로 가둠
- 나중에 전체 ESM 전환 시 수정 범위가 줄어듦
해결 6) 빌드 단계에서 출력 포맷을 조정: CJS로 트랜스파일하거나 번들링
배포/실행은 CJS로 유지해야 하는데, 소스나 일부 의존성 형태 때문에 계속 충돌한다면 빌드 산출물을 CJS로 통일하는 방식이 있습니다.
여기서의 핵심은 “런타임에서 ESM을 require()로 억지로 읽게 하지 말고”, 빌드 결과가 애초에 CJS로 떨어지게 만드는 것입니다.
TypeScript 예시
tsconfig.json을 CJS 출력으로:
{
"compilerOptions": {
"module": "CommonJS",
"target": "ES2022",
"outDir": "dist",
"esModuleInterop": true
}
}
단, 이 방식은 “내 코드”는 CJS로 떨어져도 의존성이 ESM-only면 여전히 문제가 남습니다. 이때는 번들러를 써서 ESM 의존성까지 함께 묶어 CJS 번들로 만드는 전략을 고려합니다.
esbuild 번들 예시
// build.mjs
import { build } from 'esbuild';
await build({
entryPoints: ['src/index.ts'],
bundle: true,
platform: 'node',
format: 'cjs',
outfile: 'dist/index.cjs',
target: ['node22']
});
이렇게 하면 런타임에서 ESM-only 의존성을 직접 로드하는 빈도가 줄어들고(또는 사라지고), 배포 단순화에 도움이 됩니다.
주의
- 번들링은 라이선스/소스맵/동적 require 처리 등 부수 이슈가 생길 수 있음
- 네이티브 애드온, 동적 로딩 패턴은 추가 설정이 필요할 수 있음
빠른 진단 체크리스트
아래를 순서대로 보면 원인 분류가 빨라집니다.
- 내 프로젝트가 CJS인가 ESM인가
package.json의type확인- 실행 파일 확장자
.cjs/.mjs확인
- 에러가 난 모듈이 ESM-only인가
- 해당 패키지의
package.json에서type,exports확인
- 어디서
require()가 호출되나
- 내 코드인지, 트랜지티브 의존성인지 구분
- 즉시 해결이 필요한가, 구조 개선이 가능한가
- 즉시: 해결 2, 4
- 구조 개선: 해결 1, 5, 6
운영에서 장애로 이어졌다면 “원인 모듈이 무엇인지”를 로그로 빠르게 좁히는 게 핵심입니다. 비슷한 맥락으로, 인증/키 캐시 문제도 런타임에서 갑자기 터지기 쉬운데, 그런 유형의 장애 대응 패턴은 Spring Security JWT 401 - 키 로테이션과 JWKS 캐시 글의 접근(원인 분해, 캐시/버전/롤링 고려)이 참고가 됩니다.
결론: “하나만 고르면” 무엇이 안전한가
- 장기적으로는 해결 1(ESM 전환) 이 가장 깔끔합니다.
- 레거시가 크면 해결 5(ESM 어댑터 레이어) 로 충돌 지점을 격리하는 게 비용 대비 효과가 좋습니다.
- 당장 프로덕션을 살려야 하면 해결 2(동적
import()), 해결 4(버전 고정) 이 빠릅니다.
마지막으로, Next.js 같은 프레임워크를 함께 쓰는 경우 서버/빌드 런타임의 모듈 로딩 이슈가 “캐시” 문제처럼 보이기도 합니다. 데이터가 안 바뀌거나, 특정 환경에서만 재현되는 증상이 동반된다면 Next.js 14 RSC 캐시로 데이터가 안 갱신될 때처럼 캐시 계층도 함께 점검해보는 것이 좋습니다.