Published on

Node.js ERR_REQUIRE_ESM - ESM/CJS 충돌 해결법

Authors

서버를 올리자마자 Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported가 터지면, 대부분은 “내 코드는 CJS인데 의존성(또는 트랜스파일 결과)이 ESM으로 바뀌었다”는 신호입니다. 이 에러는 단순히 requireimport로 바꾸는 수준에서 끝나지 않고, package.jsontype, 번들러 출력 포맷, TypeScript 컴파일 옵션, 테스트 러너 설정까지 연쇄적으로 영향을 줍니다.

이 글에서는 ERR_REQUIRE_ESM의 정확한 발생 조건을 정리하고, 가장 덜 위험한 해결 순서(프로젝트를 ESM으로 전환, CJS 유지 시 우회, 라이브러리/도구별 대응)로 실전 해결책을 제공합니다.

ERR_REQUIRE_ESM이 발생하는 이유

Node.js는 모듈 시스템을 크게 두 가지로 다룹니다.

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

문제는 CJS에서 ESM을 require()로 로드하려고 할 때입니다. ESM은 로딩/실행 모델이 다르기 때문에 Node는 다음을 허용하지 않습니다.

  • CJS 파일에서 require('some-esm-only-package')
  • type: module 패키지의 ESM 엔트리를 CJS 방식으로 강제 로드

대표적인 트리거는 아래와 같습니다.

  1. 의존성이 메이저 업데이트로 ESM-only가 됨
  2. package.json"type": "module"이 추가되어 .js가 ESM으로 해석됨
  3. TypeScript/번들러가 ESM 출력물을 만들었는데 실행은 CJS로 함
  4. 테스트 러너(Jest 등)가 CJS 환경인데 코드/의존성이 ESM

먼저 확인할 것: 지금 내 프로젝트는 ESM인가 CJS인가

1) package.jsontype

  • "type": "module"이면 .js는 기본적으로 ESM
  • "type": "commonjs"이거나 type이 없으면 .js는 기본적으로 CJS
{
  "name": "my-app",
  "type": "commonjs"
}

2) 파일 확장자 규칙

  • ESM을 명시: .mjs
  • CJS를 명시: .cjs

type이 무엇이든, 확장자를 명시하면 해석이 고정됩니다. 충돌을 빠르게 봉합할 때 .cjs/.mjs는 매우 유용합니다.

3) 에러 메시지에서 힌트를 읽기

에러에는 보통 다음 정보가 들어 있습니다.

  • 어떤 파일이 require()를 호출했는지
  • 어떤 패키지가 ESM으로 판정되었는지

즉, “내 코드가 문제인지, 의존성이 ESM-only인지”를 먼저 분리해야 합니다.

해결 전략 1: 프로젝트를 ESM으로 전환(권장)

장기적으로 가장 깔끔한 해결은 애플리케이션을 ESM으로 통일하는 것입니다. 특히 최신 생태계(예: 일부 유틸 패키지, 프론트/풀스택 툴체인)는 ESM을 기본으로 가는 경우가 많습니다.

1) package.json을 ESM으로

{
  "type": "module"
}

2) requireimport로 변경

// before (CJS)
const chalk = require('chalk');

// after (ESM)
import chalk from 'chalk';

3) ESM에서 __dirname 대체

ESM에는 __filename, __dirname이 기본 제공되지 않습니다. 다음 패턴을 사용합니다.

import { fileURLToPath } from 'node:url';
import path from 'node:path';

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

console.log(__dirname);

4) TypeScript를 쓰는 경우(ESM 출력 맞추기)

Node 런타임 ESM과 TypeScript는 설정 조합이 중요합니다.

{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022",
    "outDir": "dist"
  }
}
  • modulemoduleResolutionNodeNext로 맞추면 Node의 ESM 규칙(확장자/exports)을 더 정확히 따릅니다.

타입 구성이 복잡해질 때는 satisfies를 활용해 설정 객체를 안전하게 유지하는 것도 도움이 됩니다. 관련해서는 TS 5.6 satisfies로 타입 유지하며 검증하는 법도 참고할 만합니다.

해결 전략 2: CJS를 유지하면서 ESM 의존성만 우회 로드

프로젝트 전체를 ESM으로 바꾸기 어렵다면, CJS는 유지하되 ESM 패키지를 동적 import로 로드하는 방식이 현실적인 타협점입니다.

1) CJS에서 import() 사용

Node의 CJS에서도 import()는 사용할 수 있습니다(비동기).

// 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로 받아야 할 수 있음
  • import()는 비동기이므로 초기화 흐름을 바꿔야 함

2) ESM-only 패키지의 CJS 대체 엔트리 확인

일부 패키지는 exports에서 CJS/ESM을 모두 제공합니다. 이때는 패키지 문서대로 CJS 엔트리를 지정할 수 있습니다.

예: exportsrequire 조건을 제공하는 경우, 단순 require('pkg')가 동작해야 정상입니다. 그런데도 에러가 나면 다음을 의심하세요.

  • 번들러가 exports 조건을 무시하고 ESM 엔트리를 고정해버림
  • 트랜스파일 결과가 ESM인데 실행은 CJS

3) 최후의 봉합: 해당 파일만 .mjs로 분리

CJS 프로젝트에서 특정 파일만 ESM으로 만들고 싶다면:

  • 호출하는 쪽은 CJS
  • ESM 로직은 .mjs로 분리
  • CJS에서 import().mjs를 로드
// esm-worker.mjs
export function work() {
  return 'ok';
}
// index.cjs
async function main() {
  const { work } = await import('./esm-worker.mjs');
  console.log(work());
}

main();

해결 전략 3: 번들러/트랜스파일러 출력 포맷을 실행 환경과 일치시키기

ERR_REQUIRE_ESM은 “소스는 CJS인데 번들 결과가 ESM” 또는 그 반대에서도 자주 발생합니다.

1) tsc 출력과 Node 실행 방식 맞추기

  • Node를 CJS로 실행한다면 tsc도 CJS로 출력
{
  "compilerOptions": {
    "module": "CommonJS",
    "target": "ES2020",
    "outDir": "dist"
  }
}
  • Node를 ESM으로 실행한다면 NodeNext 계열로
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  }
}

2) package.json의 실행 엔트리 확인

배포/실행이 node dist/index.js인데, dist/index.js가 ESM으로 생성되면 CJS 컨텍스트에서 충돌이 납니다. 반대로 type: module인데 dist/index.js가 CJS면 exports is not defined 같은 반대 유형의 에러가 나기도 합니다.

해결 전략 4: Jest/테스트 환경에서의 ESM/CJS 충돌

테스트에서만 ERR_REQUIRE_ESM이 난다면, 앱 런타임보다 테스트 러너가 더 보수적(CJS) 이기 때문인 경우가 많습니다.

대응 방향은 2가지입니다.

  • 테스트 러너를 ESM 지원 구성으로 전환
  • 테스트 대상 모듈을 CJS로 내리거나(빌드 산출물) 동적 import로 분리

Jest는 설정 조합이 많아 프로젝트마다 답이 달라지므로, 우선순위는 다음이 안전합니다.

  1. 앱 빌드 산출물을 기준으로 테스트(런타임과 동일한 모듈 시스템)
  2. 불가하면 ESM-only 의존성 부분만 import()로 격리

자주 만나는 실제 케이스별 처방전

케이스 A: 오래된 Node 버전

ESM 지원은 Node 버전에 따라 차이가 큽니다. 가능하면 LTS(예: 18 이상, 가능하면 20 이상)로 올리는 게 비용 대비 효과가 큽니다.

  • CI와 로컬 Node 버전이 다르면 “로컬에서는 되는데 배포에서만 실패”가 발생합니다.

케이스 B: 의존성 메이저 업데이트로 ESM-only 전환

대표 대응은 아래 중 하나입니다.

  • 프로젝트를 ESM으로 전환(권장)
  • 해당 의존성을 이전 메이저로 고정
  • CJS 대체 패키지로 교체
  • ESM 의존성 사용 지점을 동적 import로 격리

케이스 C: 모노레포에서 패키지별 type 혼재

워크스페이스에서 어떤 패키지는 type: module, 어떤 패키지는 CJS면, 내부 패키지 import에서 충돌이 쉽게 납니다.

  • 패키지별로 main/exports를 명확히 분리(CJS와 ESM을 둘 다 제공)
  • 내부 소비자가 어떤 포맷을 쓰는지 통일

디버깅 체크리스트

문제를 빨리 좁히려면 아래 순서대로 확인하세요.

  1. 에러가 가리키는 “require를 호출한 파일”이 내 코드인지, 의존성인지
  2. package.jsontype과 실제 실행 파일 확장자(.js, .cjs, .mjs)
  3. 빌드 산출물의 모듈 포맷(ESM인지 CJS인지)
  4. 문제가 된 패키지가 ESM-only인지(릴리즈 노트/exports 확인)
  5. 임시 봉합이 필요하면 CJS에서 import()로 격리

서비스 장애 대응 관점에서 보면, 이런 “환경/구성 불일치” 류 이슈는 증상은 단순하지만 원인 지점이 다양합니다. 운영에서 비슷한 패턴의 진단 접근이 필요하다면 K8s Pod CrashLoopBackOff 원인 7가지와 해결처럼 체크리스트 기반으로 좁혀가는 방식이 꽤 유효합니다.

결론: 가장 안전한 해결 순서

  • 가능하면 프로젝트를 ESM으로 통일하고(type: module), import 기반으로 정리
  • 전환이 어렵다면 CJS는 유지하되 ESM-only 의존성만 import()로 격리
  • 빌드 도구/TypeScript 설정이 런타임과 일치하는지(ESM 출력물을 CJS로 실행하고 있지 않은지) 반드시 확인

ERR_REQUIRE_ESM은 “한 줄 수정”으로 끝나는 문제가 아니라, 모듈 경계가 어긋났다는 신호입니다. 한 번 정리해두면 이후 의존성 업데이트나 런타임 업그레이드에서도 같은 유형의 장애를 크게 줄일 수 있습니다.