Published on

Node.js ESM/CJS 충돌로 ERR_REQUIRE_ESM 해결하기

Authors

서버를 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으로 취급합니다.

  1. 파일 확장자가 .mjs
  2. 가장 가까운 package.json"type": "module"이 있고 파일 확장자가 .js
  3. 패키지의 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로 읽는지가 핵심입니다.

해결 전략의 큰 그림 (권장 우선순위)

  1. 가능하면 프로젝트를 ESM으로 통일한다 (장기적으로 가장 깔끔)
  2. 당장 어렵다면 CJS에서 ESM을 dynamic import로 우회한다 (단기 봉합)
  3. 라이브러리/툴 체인 문제라면 exports 분기/트랜스파일 설정을 조정한다
  4. 최후의 수단으로 의존성 버전 고정/대체 패키지 사용을 고려한다

아래에서 케이스별로 구체적인 처방을 보겠습니다.

케이스 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/moduleResolutionNodeNext로 맞춰 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"
    }
  }
}
  • exportsimport/require를 모두 제공
  • 타입 선언(types)도 exports에 포함
  • type: module을 쓰더라도 CJS 엔트리는 .cjs로 분리

이렇게 하면 소비자 쪽에서 ERR_REQUIRE_ESM을 맞을 확률이 크게 줄어듭니다.

실전 체크리스트: 가장 빠르게 원인 좁히기

  1. 에러 난 파일이 내 코드인지(node_modules인지) 확인
  2. 내 엔트리 파일이 CJS인지 ESM인지 확정 (type, 확장자)
  3. 문제 패키지가 ESM-only인지 확인(릴리즈 노트/패키지.json)
  4. exports가 조건부 분기인지 확인
  5. 테스트/빌드 도구가 require로 로드하고 있는지 확인(Jest, ts-node 등)
  6. 단기: dynamic import로 봉합
  7. 중기: 프로젝트를 ESM으로 통일, TS는 NodeNext로 정렬

운영에서 자주 겪는 “배포 후에만 터지는” 패턴

로컬에서는 괜찮은데 CI/서버에서만 터진다면 보통 아래가 원인입니다.

  • Node 버전 차이(로컬 20, 서버 16 등)
  • 번들링/트랜스파일 산출물의 모듈 형식이 환경에 따라 달라짐
  • npm install vs npm ci에 따른 lockfile/의존성 해석 차이

이런 종류의 환경 차이 문제는 네트워크/인프라 이슈처럼 “증상은 단순하지만 원인은 교차”하는 경우가 많습니다. 비슷한 맥락으로 운영에서 원인 분리를 체계화하는 접근은 다음 글도 참고할 만합니다.

결론: 가장 좋은 해법은 “혼합을 줄이는 것”

ERR_REQUIRE_ESM은 단일 코드 수정으로 끝나는 문제가 아니라, 프로젝트가 ESM/CJS 혼합 상태일 때 구조적으로 반복됩니다.

  • 지금 당장 급하면: CJS에서 import()로 우회
  • 조금 여유가 있으면(권장): 프로젝트를 ESM으로 통일하고 TS/툴링을 Node 규칙에 맞춰 정렬
  • 라이브러리라면: exports에 import/require를 명확히 분리하고 .cjs/.mjs로 의도를 고정

이 순서대로 정리하면, 같은 에러가 재발하는 빈도를 크게 줄일 수 있습니다.