- Published on
Node.js ESM·CJS 혼용 시 ERR_REQUIRE_ESM 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드를 Node.js로 운영하다 보면, 내 코드는 CJS인데 의존성 하나가 ESM으로 바뀌거나(또는 반대) 해서 갑자기 ERR_REQUIRE_ESM이 터지는 순간이 옵니다. 특히 라이브러리가 메이저 업데이트로 ESM 전환을 했거나, package.json의 "type" 변경이 들어간 경우가 대표적입니다.
이 글에서는 “ESM과 CJS를 섞을 때 왜 깨지는지”를 구조적으로 설명하고, 프로젝트 전체를 갈아엎지 않고도 단계적으로 해결하는 패턴을 정리합니다. 더 깊게 Node.js 20+ 관점에서 ERR_REQUIRE_ESM을 체계적으로 정리한 글은 Node.js 20+ ESM에서 ERR_REQUIRE_ESM 완전정복도 함께 참고하면 좋습니다.
1) 에러 메시지부터 정확히 읽기
가장 흔한 형태는 아래입니다.
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.
이 메시지가 의미하는 바는 단순합니다.
- 지금 실행 중인 파일은 CJS(
require) 방식이다. - 그런데 불러오려는 대상은 ESM 전용이다.
- CJS에서 ESM을 정적으로
require()로는 못 부른다. - 대신 CJS에서는
import()(동적 import)로 우회해야 한다.
여기서 중요한 포인트는 “Node.js는 ESM과 CJS를 동시에 지원하지만, 상호 호출 규칙은 비대칭”이라는 점입니다.
- ESM
import는 CJS를 불러올 수 있다(대부분 가능) - CJS
require는 ESM을 불러올 수 없다(원칙적으로 불가)
2) ESM인지 CJS인지 판별하는 체크리스트
문제 해결의 80%는 “지금 무엇이 ESM이고 무엇이 CJS인지”를 확정하는 데서 끝납니다.
2-1) 내 코드가 ESM인지 CJS인지
다음 중 하나라도 해당하면 ESM일 가능성이 큽니다.
package.json에"type": "module"- 파일 확장자가
.mjs import ... from ...를 사용하고 있고, Node가 이를 네이티브로 실행
반대로 다음이면 CJS일 가능성이 큽니다.
package.json에"type": "commonjs"또는type미지정(기본은 CJS)- 파일 확장자가
.cjs const x = require('x')패턴
2-2) 의존성이 ESM인지 CJS인지
의존성은 다음을 보면 힌트가 나옵니다.
- 해당 패키지의
package.json에"type": "module" exports필드가 ESM 엔트리만 노출- 메인 파일이
.mjs또는 ESM 문법
특히 최근 패키지들은 exports로 진입점을 강하게 통제합니다. 이 경우 예전처럼 “깊은 경로로 require 해서 우회”가 막혀 있을 수 있습니다.
3) 가장 현실적인 해결: CJS에서 ESM을 import()로 호출
프로젝트가 CJS 기반인데, 특정 라이브러리만 ESM이라면 가장 안전한 해법은 “해당 라이브러리만 동적 import로 격리”하는 것입니다.
3-1) 기본 패턴
// index.cjs (또는 type 미지정 환경의 .js)
async function main() {
const { default: ky } = await import('ky');
const res = await ky.get('https://example.com').text();
console.log(res);
}
main().catch(console.error);
핵심은 아래입니다.
- CJS 파일에서도
import()는 사용 가능 - ESM 패키지를 가져오면 보통
default에 기본 export가 들어올 수 있음
3-2) 성능과 구조: 매번 import 하지 말고 캐싱
핫 패스에서 매번 import()를 호출하면 의도치 않게 코드가 지저분해집니다. 보통은 모듈 스코프에 캐시를 둡니다.
// esm-client.cjs
let cached;
async function getEsmClient() {
if (!cached) {
cached = import('some-esm-only-lib');
}
return cached;
}
module.exports = { getEsmClient };
// app.cjs
const { getEsmClient } = require('./esm-client.cjs');
async function handler() {
const lib = await getEsmClient();
return lib.doSomething();
}
module.exports = { handler };
이렇게 하면 ESM 의존성은 “한 번만 로드”되고, 나머지 코드는 CJS로 유지됩니다.
4) 반대 상황: ESM에서 CJS를 불러올 때의 함정
ESM 코드에서 CJS를 가져오는 건 대체로 됩니다. 다만 default export 매핑 때문에 헷갈리기 쉽습니다.
// app.mjs
import pkg from 'some-cjs-lib';
// CJS의 module.exports가 default처럼 들어오는 경우가 많음
console.log(pkg);
또는 Node 내장 createRequire를 써서 “ESM에서 require”를 만들 수도 있습니다.
// app.mjs
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const cjsOnly = require('some-cjs-only-lib');
console.log(cjsOnly);
이 패턴은 “ESM 프로젝트인데 특정 CJS 패키지가 ESM에서 import 시 이상하게 동작”할 때 유용합니다.
5) 근본 해결 1: 프로젝트를 ESM으로 정리(점진적 마이그레이션)
혼용이 반복된다면, 장기적으로는 프로젝트를 ESM으로 정리하는 편이 운영 비용을 줄입니다.
5-1) 최소 변경
package.json에"type": "module"추가- 기존 CJS 파일은 확장자를
.cjs로 바꿔서 유지 - 신규 코드는
.js(ESM)로 작성
예시:
{
"name": "my-app",
"type": "module",
"scripts": {
"start": "node src/index.js"
}
}
파일 구성:
src/index.js는 ESMsrc/legacy.cjs는 기존 CJS 유지
// src/index.js (ESM)
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const legacy = require('./legacy.cjs');
console.log(legacy);
이 방식은 “전체를 한 번에 바꾸지 않고” 섞어 가는 정석입니다.
5-2) 자주 터지는 포인트: __dirname, __filename
ESM에는 기본적으로 __dirname이 없습니다. 아래처럼 대체합니다.
// esm-file.js
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
console.log(__dirname);
이걸 해결하지 못하면, ESM 전환 중에 경로 관련 버그가 연쇄적으로 발생합니다.
6) 근본 해결 2: 의존성 버전/엔트리 선택(가능할 때만)
모든 패키지가 “ESM만 제공”하는 것은 아닙니다. 일부는 듀얼 패키지로 CJS 엔트리를 유지합니다.
6-1) 패키지가 CJS 엔트리를 제공한다면
exports에 require 조건이 있는지 확인합니다(패키지 내부 package.json). 예를 들어 아래처럼 되어 있으면 CJS에서 그대로 require('pkg')가 됩니다.
{
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
이 경우 ERR_REQUIRE_ESM이 난다면, 대개는 다음 중 하나입니다.
- 내가 “깊은 경로”를
require('pkg/dist/index.js')같은 방식으로 직접 찔렀다 - 번들러/트랜스파일 결과물이 엔트리 선택을 망가뜨렸다
해결은 “공식 엔트리만 import 또는 require”로 정리하는 것입니다.
6-2) 메이저 업그레이드로 ESM 전환된 라이브러리
운영 중인 서비스에서 급하면 “해당 패키지를 CJS 지원 버전으로 핀 고정”도 현실적인 선택입니다.
{
"dependencies": {
"some-lib": "2.9.4"
}
}
다만 이는 임시 처방입니다. 장기적으로는 보안 패치/호환성 이슈가 누적될 수 있으니, 동적 import 브리징이나 ESM 전환 계획을 같이 잡는 게 좋습니다.
7) TypeScript를 쓰면 더 자주 꼬이는 지점
TypeScript는 “컴파일 결과가 CJS인지 ESM인지”가 런타임과 직결됩니다.
7-1) CJS로 컴파일하면서 ESM 의존성을 require 하는 상황
tsconfig.json에서 module이 CommonJS면 컴파일 결과는 require()가 됩니다. 이때 ESM 전용 패키지를 정적으로 import 하면 런타임에서 ERR_REQUIRE_ESM이 날 수 있습니다.
현실적인 대응은 두 가지입니다.
- 출력 모듈을 ESM으로 바꾸기
- 특정 ESM 의존성만
import()로 분리
예: 특정 패키지만 동적 import로 분리하는 TS 코드
// esmOnlyClient.ts
export async function getClient() {
const mod = await import('some-esm-only-lib');
return mod;
}
컴파일 결과가 CJS여도 import()는 남아서 동작하는 경우가 많습니다(설정/타겟에 따라 다름).
7-2) NodeNext 계열로 맞추기
Node의 ESM 규칙을 가장 덜 놀라게 따라가려면 module과 moduleResolution을 NodeNext 계열로 두는 선택지가 있습니다.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist"
}
}
이 구성이 항상 정답은 아니지만, “Node에서 그대로 돌릴 코드”라면 혼용 문제를 줄이는 데 도움이 됩니다.
8) 운영 환경에서의 체크: 컨테이너/서버리스에서 더 잘 터지는 이유
로컬에서는 우연히 되는데 배포하면 깨지는 케이스가 있습니다.
- Node 버전 차이로 ESM 해석 규칙이 달라짐
- 번들/트랜스파일 산출물이 달라짐
- 런타임 플래그/실행 방식이 달라짐
서버리스나 컨테이너에서 초기화 지연과 함께 모듈 로딩 문제가 겹치면 장애로 보이기도 합니다. 예를 들어 콜드스타트가 큰 환경에서는 초기 import 전략(정적 import vs 지연 import)이 지연/타임아웃과 맞물릴 수 있는데, 이런 운영 관점은 GCP Cloud Run 504와 콜드스타트 지연 해결 가이드 같은 글의 접근법도 함께 참고할 만합니다.
9) 실전 처방전: 상황별로 이렇게 고르면 된다
9-1) CJS 프로젝트, ESM 라이브러리 1~2개만 필요
- 해당 라이브러리만
await import('lib')로 감싸서 격리 - 모듈 캐싱으로 성능/가독성 확보
9-2) 신규 프로젝트 또는 혼용이 계속 늘어나는 중
"type": "module"로 ESM을 기본값으로- 레거시만
.cjs로 남기고 점진 이관
9-3) TypeScript 기반이고 런타임이 Node 직행
module을NodeNext로 검토- 출력이 CJS라면 ESM 의존성은 동적 import로 분리
9-4) 급한 장애 대응
- 의존성 버전 핀 고정으로 일단 복구
- 이후 ESM 브리징 또는 ESM 전환으로 근본 해결
10) 마무리: ERR_REQUIRE_ESM은 “규칙 위반”의 결과다
ERR_REQUIRE_ESM은 대개 Node가 까다로워서가 아니라, “CJS의 require()로 ESM을 정적으로 가져오려는 규칙 위반”에서 시작합니다. 해결은 생각보다 단순하게 두 갈래입니다.
- CJS에서 ESM은
import()로 호출한다 - 장기적으로는 프로젝트를 ESM으로 정리하고 레거시는
.cjs로 격리한다
혼용을 피할 수 없다면, 경계면을 명확히(브리징 모듈 하나로 통제) 해두는 것이 운영과 디버깅을 크게 편하게 만듭니다.