- 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()...
이 에러는 단순히 “require를 import로 바꾸세요” 수준에서 끝나지 않습니다. 실제 현장에서는 패키지의 exports 조건부 분기, tsconfig/moduleResolution, 번들러/트랜스파일러가 만든 산출물 형식, package.json의 type, Jest/ts-node 실행 방식이 얽히면서 같은 증상이 여러 형태로 나타납니다.
이 글에서는 ERR_REQUIRE_ESM이 왜 생기는지, 어디에서 충돌이 시작되는지, 그리고 가장 덜 위험한 순서로 해결책을 적용하는 방법을 단계별로 정리합니다.
ERR_REQUIRE_ESM이 발생하는 정확한 이유
Node.js에는 크게 두 모듈 시스템이 공존합니다.
- CommonJS(CJS):
require(),module.exports - ECMAScript Modules(ESM):
import,export
문제는 CJS의 require()는 ESM을 정적으로 로드할 수 없다는 점입니다. 즉,
- 내 코드가 CJS인데(
require) - 의존성 패키지가 ESM만 제공하거나(또는 exports가 ESM으로 분기되거나)
- Node가 그 패키지를 ESM으로 해석하면
ERR_REQUIRE_ESM이 납니다.
Node가 “이 파일은 ESM이다”라고 판단하는 기준
다음 중 하나라도 만족하면 Node는 해당 파일을 ESM으로 취급합니다.
- 파일 확장자가
.mjs - 가장 가까운
package.json에"type": "module"이 있고 파일 확장자가.js - 패키지의
exports가 ESM 엔트리로 분기됨
특히 3번이 실무에서 가장 흔한 함정입니다.
{
"name": "some-lib",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
겉으로는 CJS도 제공하는 것처럼 보이지만, 내 런타임/번들러/테스트 러너가 어떤 조건으로 해석하느냐에 따라 의도치 않게 import 쪽으로 분기될 수 있습니다.
먼저 해야 할 진단: “내 앱은 ESM인가 CJS인가”
1) package.json의 type 확인
{
"type": "module"
}
- 있으면
.js는 기본적으로 ESM - 없으면
.js는 기본적으로 CJS
2) 현재 실행 엔트리의 확장자 확인
- ESM:
.mjs또는type: module+.js - CJS:
.cjs또는type없음 +.js
3) 에러 메시지에서 “누가 require했는지” 역추적
스택 트레이스에서 보통 이런 형태가 보입니다.
at Object.<anonymous> (/app/dist/index.js:12:15)
이 파일이 CJS 산출물인지, 혹은 ESM 산출물인데 누군가 require로 읽는지가 핵심입니다.
해결 전략의 큰 그림 (권장 우선순위)
- 가능하면 프로젝트를 ESM으로 통일한다 (장기적으로 가장 깔끔)
- 당장 어렵다면 CJS에서 ESM을 dynamic import로 우회한다 (단기 봉합)
- 라이브러리/툴 체인 문제라면 exports 분기/트랜스파일 설정을 조정한다
- 최후의 수단으로 의존성 버전 고정/대체 패키지 사용을 고려한다
아래에서 케이스별로 구체적인 처방을 보겠습니다.
케이스 A: CJS 코드에서 ESM 패키지를 require하고 있다
가장 흔한 패턴입니다.
// index.js (CJS)
const chalk = require('chalk'); // chalk v5+는 ESM
해결 1) dynamic import()로 로드 (CJS 유지)
CJS 파일에서도 import()는 사용할 수 있습니다.
// index.cjs
async function main() {
const chalk = (await import('chalk')).default;
console.log(chalk.green('ok'));
}
main().catch(console.error);
- 장점: 프로젝트 전체를 바꾸지 않아도 됨
- 단점: 비동기 흐름이 생김(초기화 코드가 길면 구조 변경 필요)
해결 2) 해당 패키지의 CJS 호환 버전으로 다운그레이드
예: chalk
- chalk@4: CJS
- chalk@5: ESM
npm i chalk@4
- 장점: 코드 변경 최소
- 단점: 장기적으로 기술부채(업데이트/보안 패치 지연)
케이스 B: 프로젝트를 ESM으로 전환하고 싶다 (권장)
ESM으로 통일하면 “ESM 패키지를 CJS에서 억지로 부르는” 문제가 사라집니다.
1) package.json 설정
{
"type": "module"
}
2) require/module.exports를 import/export로 변경
// before (CJS)
const express = require('express');
module.exports = { start };
// after (ESM)
import express from 'express';
export function start() {}
3) __dirname, __filename 대체
ESM에는 __dirname이 없습니다.
import { fileURLToPath } from 'url';
import path from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
4) ESM에서 CJS 패키지 불러오기
대부분의 CJS는 ESM에서 잘 로드됩니다.
import pkg from 'some-cjs-package';
단, named export처럼 쓰면 안 되고 default import 형태로 받는 것이 안전합니다(패키지 구현에 따라 다름).
케이스 C: TypeScript + NodeNext 설정에서 터지는 ERR_REQUIRE_ESM
TS 프로젝트는 tsconfig가 모듈 해석을 결정하면서 충돌을 키우는 경우가 많습니다.
권장 tsconfig (Node 18+ 기준)
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true
}
}
핵심은 module/moduleResolution을 NodeNext로 맞춰 Node의 ESM 규칙을 그대로 따라가게 하는 것입니다.
산출물 확장자 전략: .cjs/.mjs로 명시하기
혼합 환경에서는 확장자로 의도를 고정하는 게 안전합니다.
- CJS로 내보내야 하면:
index.cjs - ESM로 내보내야 하면:
index.mjs
빌드 단계에서 파일명을 바꾸는 스크립트를 추가하는 방식도 자주 씁니다.
{
"scripts": {
"build": "tsc && node scripts/rename-to-cjs.js"
}
}
케이스 D: Jest/ts-node/webpack이 ESM을 잘못 다뤄서 발생
런타임은 Node인데, 실제로는 테스트 러너/실행기가 require 기반으로 코드를 로딩하면서 ERR_REQUIRE_ESM이 납니다.
Jest에서의 대표 처방
- Jest는 기본적으로 CJS 친화적입니다.
- ESM 프로젝트라면
node --experimental-vm-modules또는 Jest ESM 설정이 필요할 수 있습니다.
간단히는 테스트 대상 모듈을 CJS로 유지하거나, 테스트 환경만 별도 tsconfig를 두는 전략이 흔합니다.
{
"scripts": {
"test": "NODE_OPTIONS=--experimental-vm-modules jest"
}
}
(프로젝트/버전에 따라 설정이 달라질 수 있으니, 에러가 나는 지점을 기준으로 “누가 require했는지”를 먼저 확인하세요.)
라이브러리 제작자 관점: ESM/CJS 듀얼 패키지의 안전한 exports
내가 라이브러리를 만들고 있고 사용자에게 선택권을 주고 싶다면, 다음 패턴이 실무에서 가장 안전합니다.
{
"name": "my-lib",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
exports에import/require를 모두 제공- 타입 선언(
types)도 exports에 포함 type: module을 쓰더라도 CJS 엔트리는.cjs로 분리
이렇게 하면 소비자 쪽에서 ERR_REQUIRE_ESM을 맞을 확률이 크게 줄어듭니다.
실전 체크리스트: 가장 빠르게 원인 좁히기
- 에러 난 파일이 내 코드인지(node_modules인지) 확인
- 내 엔트리 파일이 CJS인지 ESM인지 확정 (
type, 확장자) - 문제 패키지가 ESM-only인지 확인(릴리즈 노트/패키지.json)
exports가 조건부 분기인지 확인- 테스트/빌드 도구가 require로 로드하고 있는지 확인(Jest, ts-node 등)
- 단기: dynamic import로 봉합
- 중기: 프로젝트를 ESM으로 통일, TS는 NodeNext로 정렬
운영에서 자주 겪는 “배포 후에만 터지는” 패턴
로컬에서는 괜찮은데 CI/서버에서만 터진다면 보통 아래가 원인입니다.
- Node 버전 차이(로컬 20, 서버 16 등)
- 번들링/트랜스파일 산출물의 모듈 형식이 환경에 따라 달라짐
npm installvsnpm ci에 따른 lockfile/의존성 해석 차이
이런 종류의 환경 차이 문제는 네트워크/인프라 이슈처럼 “증상은 단순하지만 원인은 교차”하는 경우가 많습니다. 비슷한 맥락으로 운영에서 원인 분리를 체계화하는 접근은 다음 글도 참고할 만합니다.
결론: 가장 좋은 해법은 “혼합을 줄이는 것”
ERR_REQUIRE_ESM은 단일 코드 수정으로 끝나는 문제가 아니라, 프로젝트가 ESM/CJS 혼합 상태일 때 구조적으로 반복됩니다.
- 지금 당장 급하면: CJS에서
import()로 우회 - 조금 여유가 있으면(권장): 프로젝트를 ESM으로 통일하고 TS/툴링을 Node 규칙에 맞춰 정렬
- 라이브러리라면: exports에 import/require를 명확히 분리하고
.cjs/.mjs로 의도를 고정
이 순서대로 정리하면, 같은 에러가 재발하는 빈도를 크게 줄일 수 있습니다.