Published on

Node.js ESM·CJS 혼용 ERR_REQUIRE_ESM 해결 가이드

Authors

서버 사이드 Node.js 프로젝트를 운영하다 보면 어느 순간부터 Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported 를 마주치게 됩니다. 원인은 단순합니다. CommonJS(CJS) 방식의 require() 로 ESM 전용 모듈을 로드하려고 했기 때문입니다.

하지만 실제 현장에서는 단순히 import 로 바꾸는 것으로 끝나지 않습니다. 번들러, 테스트 러너, 트랜스파일러, 배포 환경(Node 버전), 그리고 의존성 트리(간접 의존성)까지 얽혀서 “어디서부터 ESM으로 옮겨야 하는지”가 문제입니다.

이 글에서는 ERR_REQUIRE_ESM이 나는 전형적인 케이스를 재현하고, **가장 안전한 해결 순서(우회 import() → 경계 모듈 분리 → 점진적 ESM 전환 → 완전 전환)**로 정리합니다.

ERR_REQUIRE_ESM이 터지는 구조

Node.js에는 크게 두 모듈 시스템이 공존합니다.

  • CJS: require() / module.exports
  • ESM: import / export

문제는 CJS는 ESM을 require()로 직접 로드할 수 없다는 점입니다. 반대로 ESM은 CJS를 import로 로드할 수는 있지만, default export 매핑 등에서 주의가 필요합니다.

대표 에러 메시지 패턴

다음과 같은 형태가 가장 흔합니다.

  • Error [ERR_REQUIRE_ESM]: require() of ES Module ... from ... not supported.
  • Instead change the require of ... to a dynamic import()

에러 메시지가 친절하게 “동적 import로 바꿔라”라고 말해주지만, 실제로는 어디에서 require가 호출되는지부터 찾아야 합니다.

1단계: 에러를 재현하고, 호출 지점을 정확히 찾기

재현 예시(CJS에서 ESM 전용 패키지 require)

package.json 이 CJS(기본값)이고, 다음처럼 작성했다고 가정합니다.

// index.js (CJS)
const chalk = require('chalk');

console.log(chalk.green('hello'));

chalk 처럼 ESM 전용으로 전환된 패키지를 require() 하면 ERR_REQUIRE_ESM이 발생할 수 있습니다(패키지 버전에 따라 다름).

어디서 require가 호출되는지 추적 팁

  • 에러 스택의 최상단 파일이 “내 코드”가 아닐 수 있습니다(간접 의존성).
  • 먼저 node -p "process.versions.node" 로 Node 버전을 확인합니다.
  • 그 다음, 문제 패키지의 node_modules/패키지/package.json 을 열어 "type": "module" 여부와 exports 구성을 봅니다.

2단계(가장 안전한 응급처치): CJS에서 import()로 ESM 로드

Node는 CJS에서도 동적 import() 를 지원합니다. 즉, 프로젝트 전체를 ESM으로 갈아엎지 않고도 “문제 모듈만” 우회 로드할 수 있습니다.

// index.js (CJS)
async function main() {
  const chalk = (await import('chalk')).default;
  console.log(chalk.green('hello'));
}

main().catch((e) => {
  console.error(e);
  process.exitCode = 1;
});

포인트

  • await import('chalk') 는 모듈 네임스페이스 객체를 반환합니다.
  • ESM 패키지의 default export는 보통 .default 로 접근합니다(패키지에 따라 다름).
  • 최상위 await 는 CJS에서 불가하므로 async function 으로 감쌉니다.

이 방식은 빠르게 장애를 끄는 데 최적입니다. 다만, 호출부가 동기 코드였는데 비동기 코드로 바뀌는 순간 파급이 생길 수 있습니다.

3단계: “경계 모듈”로 ESM·CJS 혼용을 관리하기

대규모 코드베이스에서 가장 흔한 실수는, 여기저기서 import() 를 남발해 모듈 로딩 방식이 뒤섞이는 것입니다. 대신 경계(boundary) 파일을 하나 두고, ESM 패키지 접근을 중앙화하세요.

예: CJS 프로젝트에서 ESM 전용 라이브러리를 래핑

// lib/chalk-wrapper.cjs
let _chalkPromise;

function getChalk() {
  if (!_chalkPromise) {
    _chalkPromise = import('chalk').then((m) => m.default ?? m);
  }
  return _chalkPromise;
}

module.exports = { getChalk };

사용부:

// app.cjs
const { getChalk } = require('./lib/chalk-wrapper.cjs');

async function run() {
  const chalk = await getChalk();
  console.log(chalk.blue('wrapped import'));
}

run();

장점

  • ESM 로딩을 한 곳에서만 관리
  • 캐싱으로 반복 import() 비용 감소
  • 나중에 프로젝트를 ESM으로 전환할 때 수정 범위 최소화

4단계: 프로젝트 자체를 ESM으로 전환하는 정석

응급처치로는 충분하지 않고, 앞으로도 ESM 의존성이 늘어날 게 확실하다면 프로젝트를 ESM으로 전환하는 게 장기적으로 낫습니다.

옵션 A: package.json"type": "module" 추가

{
  "name": "my-app",
  "type": "module",
  "scripts": {
    "start": "node src/index.js"
  }
}

이후부터 .js 는 기본적으로 ESM으로 해석됩니다.

// src/index.js (ESM)
import chalk from 'chalk';

console.log(chalk.green('esm project'));

주의점

  • CJS 파일이 필요하면 확장자를 .cjs 로 분리해야 합니다.
  • __dirname, __filename 이 ESM에는 없으므로 대체 코드가 필요합니다.
// __dirname 대체 (ESM)
import { fileURLToPath } from 'node:url';
import path from 'node:path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

옵션 B: 확장자 기반 혼용(.mjs.cjs)

레거시가 많다면 "type" 을 바꾸지 않고, ESM 파일만 .mjs 로 운영하는 방식도 있습니다.

  • CJS: .cjs 또는 기본 .js
  • ESM: .mjs

이 방식은 전환 비용이 낮지만, 파일 확장자가 늘어나고 규칙이 복잡해질 수 있습니다.

5단계: TypeScript 환경에서 ERR_REQUIRE_ESM이 나는 케이스

TypeScript는 컴파일 타깃과 런타임 Node 모듈 해석이 어긋나면 ERR_REQUIRE_ESM이 쉽게 발생합니다.

흔한 문제 조합

  • tsconfig.json 에서 "module": "commonjs"
  • 런타임에서 ESM 전용 패키지를 로드

점진적 권장 설정(예시)

Node 18+ 기준에서 ESM으로 가려면 아래 조합이 무난합니다.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true
  }
}

그리고 package.json"type": "module" 을 두고, 엔트리를 node dist/index.js 로 실행합니다.

테스트 러너/빌드 도구도 함께 점검

Jest, ts-jest, Mocha, ts-node, nodemon 같은 도구가 CJS 가정으로 동작하는 경우가 많습니다. ERR_REQUIRE_ESM이 “테스트에서만” 난다면 도구 설정이 원인일 확률이 큽니다.

6단계: 의존성 트리에서 간접 의존성이 ESM일 때

내 코드는 CJS인데, 어떤 라이브러리가 내부에서 ESM-only 패키지를 require() 하는 경우도 있습니다. 이때는 내 코드만 고쳐서는 해결되지 않습니다.

해결 전략

  1. 문제 패키지 업그레이드: 라이브러리 최신 버전에서 ESM 대응이 들어갔을 수 있습니다.
  2. 문제 패키지 다운그레이드: ESM 전환 이전 메이저 버전으로 내립니다.
  3. resolutions 또는 overrides로 버전 고정: 모노레포나 pnpm/yarn에서 유용합니다.
  4. 대체 패키지로 교체: 장기적으로 유지보수 안전.

예: npm overrides

{
  "overrides": {
    "some-dep": "1.9.3"
  }
}

운영 관점 체크리스트(배포 장애 예방)

ERR_REQUIRE_ESM은 로컬에서는 안 나고, 컨테이너/서버에서만 나는 경우가 있습니다. 보통 아래가 원인입니다.

  • 배포 환경 Node 버전이 낮음(예: 로컬 20, 서버 16)
  • lockfile이 달라져 의존성 버전이 달라짐
  • npm cinpm install 차이로 트리가 달라짐

컨테이너나 쿠버네티스에서 재현이 어려울 때는, 장애 대응 관점에서 로그/환경을 체계적으로 확인하는 습관이 중요합니다. 이 흐름은 애플리케이션 레벨뿐 아니라 인프라 레벨 트러블슈팅에도 그대로 적용됩니다. 예를 들어 Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅 글의 점검 순서(로그 확보, 재현 환경 고정, 원인 분리)는 Node 런타임 에러에도 유효합니다.

실전 결론: 어떤 선택이 가장 좋은가

  • 당장 장애를 꺼야 한다: CJS에서 import() 로 우회(2단계)
  • 혼용이 계속될 것 같다: 경계 모듈로 중앙화(3단계)
  • ESM 의존성이 계속 늘고, 신규 개발 중심: 프로젝트를 ESM으로 전환(4단계)
  • 테스트/빌드에서만 터진다: TS/Jest/ts-node 설정을 NodeNext 기준으로 정렬(5단계)
  • 간접 의존성에서 터진다: 업그레이드/다운그레이드/overrides로 트리 고정(6단계)

추가로, 프론트엔드/풀스택에서 런타임과 빌드 환경 불일치가 원인이 되는 사례는 흔합니다. Next.js에서도 비슷한 형태로 “환경/렌더링 경계가 어긋나서” 문제가 발생하곤 하는데, 그런 유형의 디버깅 관점은 Next.js 14 RSC에서 hydration mismatch 해결법 에서도 참고할 만합니다.

부록: 자주 쓰는 패턴 모음

ESM에서 CJS 불러오기

// esm-file.js
import cjsPkg from 'cjs-package';
// 또는
import * as cjsPkgNS from 'cjs-package';

패키지에 따라 default 매핑이 달라질 수 있으니, 실제 export 형태를 확인하고 맞추는 게 안전합니다.

CJS에서 ESM 불러오기(동적 import)

// cjs-file.cjs
async function load() {
  const mod = await import('esm-only-package');
  return mod.default ?? mod;
}

module.exports = { load };

엔트리만 ESM으로 두고 내부는 CJS 유지

레거시가 많을 때 유용합니다.

  • 엔트리: ESM
  • 내부: .cjs 로 유지
// index.js (ESM)
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const legacy = require('./legacy.cjs');

legacy.run();

이 방식은 “전체 전환”이 어려운 팀에서 현실적인 절충안이 됩니다.


ERR_REQUIRE_ESM은 단순한 에러처럼 보이지만, 실제로는 프로젝트의 모듈 경계와 런타임/빌드 체인의 일관성을 요구하는 신호입니다. 위의 단계대로 접근하면, 지금 당장 문제를 끄는 것부터 장기적으로 ESM 전환까지 안전하게 진행할 수 있습니다.