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() which is available in all CommonJS modules.

대부분의 경우 **“내 코드는 CommonJS(CJS)인데, 의존성 중 하나가 ESM 전용으로 바뀌었다”**가 핵심 원인입니다. 문제는 단순히 requireimport로 바꾸는 수준이 아니라, 패키지 경계(내 코드/의존성/빌드 산출물/테스트 런너) 전체에서 모듈 시스템이 엮여 있기 때문에 재현도 어렵고 해결도 헷갈립니다.

이 글은 ERR_REQUIRE_ESM이 왜 발생하는지, 그리고 CJS/ESM 혼용 상태에서 가장 안전하게 해결하는 방법을 우선순위대로 정리합니다. (Node 18~22 기준)

> Node 22에서 require가 막혀서 ESM 전환이 필요하다면, 더 큰 관점의 마이그레이션은 아래 글도 함께 보세요: Node 22에서 require가 안 될 때 ESM 전환법

1) ERR_REQUIRE_ESM이 나는 구조를 먼저 이해하기

Node.js는 크게 두 모듈 시스템을 지원합니다.

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

문제는 CJS에서 ESM을 다음처럼 불러오려 할 때 터집니다.

// index.cjs
const got = require('got'); // got v12+는 ESM 전용

이때 Node는 “ESM은 require()로 로드할 수 없다”고 판단하고 ERR_REQUIRE_ESM을 던집니다.

ESM 전용 패키지는 어떻게 구분되나?

의존성 패키지의 package.json을 보면 힌트가 있습니다.

  • "type": "module" 이면 기본이 ESM
  • exports 필드가 ESM 엔트리만 제공하는 경우
  • .mjs 확장자

예:

{
  "name": "some-lib",
  "type": "module",
  "exports": {
    ".": "./dist/index.js"
  }
}

이런 패키지를 CJS에서 require('some-lib') 하면 거의 확정으로 터집니다.

2) 가장 현실적인 1차 처방: CJS에서 동적 import() 사용

Node는 CJS 파일에서도 import()(동적 import) 는 허용합니다. 그래서 CJS를 유지해야 하는 상황(레거시 코드, Jest 설정, 특정 런타임 제약 등)에서는 이게 가장 빠른 해결책입니다.

예: CJS에서 ESM 패키지 로드

// 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: ... }로 받는 경우가 많습니다.
  • named export면 구조분해로 받으면 됩니다.
const { execa } = await import('execa');

“동기 require 자리에 async가 들어와서 구조가 깨져요”

이게 동적 import의 가장 큰 단점입니다. 기존 코드가 동기 초기화를 전제로 작성됐다면, 아래 중 하나로 우회합니다.

  • 초기화 시점만 async로 바꾸고 나머지는 그대로 유지
  • ESM을 감싸는 래퍼 모듈을 만들어 호출부 변경 최소화

아래에서 래퍼 패턴을 다룹니다.

3) 호출부 변경을 줄이는 래퍼(Bridge) 모듈 패턴

CJS 코드가 많고, 여기저기서 require('esm-lib')를 쓰고 있다면 호출부를 전부 async로 바꾸기 어렵습니다. 이때는 “한 군데에서만 async로 받아서 캐시해두는” 래퍼를 둡니다.

예: ESM 라이브러리를 CJS에서 싱글톤처럼 쓰기

// esm-bridge.cjs
let _clientPromise;

function getClient() {
  if (!_clientPromise) {
    _clientPromise = import('got').then(m => m.default);
  }
  return _clientPromise;
}

module.exports = { getClient };

사용처:

// service.cjs
const { getClient } = require('./esm-bridge.cjs');

async function fetchSomething(url) {
  const got = await getClient();
  const res = await got(url);
  return res.body;
}

module.exports = { fetchSomething };

이렇게 하면 ESM 로딩은 1회로 제한되고, 변경 범위도 작아집니다.

4) 정공법: 프로젝트를 ESM으로 전환(또는 경계 분리)

장기적으로는 “내 프로젝트도 ESM”으로 가는 게 가장 깔끔합니다. 특히 최신 생태계(번들러, TS, 일부 라이브러리)는 ESM 중심으로 이동 중이라 혼용 비용이 점점 커집니다.

4-1) package.json에서 ESM 선언

{
  "type": "module"
}

그리고 파일 내보내기/가져오기를 ESM으로 맞춥니다.

// index.js (ESM)
import got from 'got';
export function run() {}

4-2) CJS가 꼭 필요하면 확장자로 경계 만들기

ESM 프로젝트에서도 특정 파일만 CJS로 유지할 수 있습니다.

  • ESM 기본: .js (type=module)
  • CJS로 강제: .cjs

예:

src/
  index.js        // ESM
  legacy.cjs      // CJS

반대로 CJS 프로젝트에서 ESM 파일만 쓰고 싶으면 .mjs로 분리할 수 있습니다.

5) TypeScript를 쓰는 경우: tsconfig와 출력 포맷이 핵심

TS 프로젝트에서 ERR_REQUIRE_ESM이 나는 흔한 시나리오:

  • 소스는 import를 쓰는데
  • tsc 출력이 module: commonjs라서
  • 런타임에서 require() 형태로 바뀌며
  • ESM 전용 의존성을 require 하다가 폭발

5-1) NodeNext로 맞추기(권장)

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "esModuleInterop": true
  }
}
  • NodeNext는 Node의 ESM 규칙(확장자/exports)을 더 정확히 따릅니다.
  • ESM/CJS 혼용 프로젝트에서 “컴파일은 되는데 런타임이 깨지는” 문제를 줄여줍니다.

5-2) 패키지 타입과 출력 확장자 정합성

  • "type": "module"이면 dist의 .js는 ESM으로 해석됩니다.
  • CJS 출력이 필요하면 .cjs로 내거나 type을 조정해야 합니다.

실무에서는 아래 중 하나로 정리합니다.

  • 완전 ESM: type: module + module: NodeNext + dist .js
  • 완전 CJS: type 제거(또는 commonjs) + module: commonjs + ESM 의존성은 동적 import/대체 패키지
  • 혼용: 엔트리/설정 파일만 .cjs로 두고 앱 코드는 ESM

6) 자주 밟는 지뢰: Jest/ts-jest, ESLint 설정 파일, config 로더

ERR_REQUIRE_ESM은 앱 코드뿐 아니라 도구 체인에서도 터집니다.

  • jest.config.js가 CJS인데 ESM 설정/플러그인을 require
  • eslint.config.js(flat config)에서 모듈 타입 불일치
  • webpack.config.js, rollup.config.js

해결 패턴

  • 설정 파일을 .cjs로 고정하거나
  • 반대로 프로젝트가 ESM이면 설정도 ESM으로 바꾸기

예: Jest 설정을 CJS로 고정

// jest.config.cjs
module.exports = {
  testEnvironment: 'node'
};

예: ESM 프로젝트에서 Jest를 쓴다면, Jest의 ESM 지원 옵션/실행 플래그를 함께 맞춰야 합니다(버전별로 접근이 다름). 이 구간은 “코드 수정”보다 “툴 버전/옵션 정합성”이 더 중요합니다.

7) 의존성 버전 전략: “ESM 전용으로 바뀐 순간”을 관리하기

많이 발생하는 케이스는 다음입니다.

  • npm install 또는 CI에서 lockfile이 갱신됨
  • 특정 라이브러리가 메이저 업그레이드되며 ESM 전용이 됨
  • CJS 코드가 깨짐

대응 체크리스트

  1. 문제 패키지 확인: 스택트레이스에서 “require() of ES Module …”의 대상 경로 확인
  2. 패키지 릴리스 노트 확인: 메이저 변경에서 ESM 전용 전환이 흔함
  3. 단기 해결: 동적 import 또는 브리지 모듈
  4. 중기 해결: 프로젝트 ESM 전환 또는 대체 패키지 검토
  5. 재발 방지: lockfile 고정, Renovate/Dependabot에서 메이저 자동 머지 금지

8) 실전 예시: express(CJS) + ESM 전용 라이브러리 섞기

가령 기존 서버가 CJS로 구성되어 있고, 새로 도입한 라이브러리(예: got, node-fetch@3 등)가 ESM 전용이라고 가정해봅시다.

기존 코드(깨짐)

// app.cjs
const express = require('express');
const fetch = require('node-fetch'); // node-fetch@3는 ESM 전용

const app = express();
app.get('/', async (req, res) => {
  const r = await fetch('https://example.com');
  res.send(await r.text());
});

app.listen(3000);

수정 코드(동적 import)

// app.cjs
const express = require('express');

const app = express();
app.get('/', async (req, res) => {
  const { default: fetch } = await import('node-fetch');
  const r = await fetch('https://example.com');
  res.send(await r.text());
});

app.listen(3000);

또는 fetch를 매 요청마다 import하는 게 싫다면 브리지로 캐싱합니다.

// fetch-bridge.cjs
let _fetch;
async function getFetch() {
  if (!_fetch) {
    _fetch = (await import('node-fetch')).default;
  }
  return _fetch;
}
module.exports = { getFetch };
// app.cjs
const express = require('express');
const { getFetch } = require('./fetch-bridge.cjs');

const app = express();
app.get('/', async (req, res) => {
  const fetch = await getFetch();
  const r = await fetch('https://example.com');
  res.send(await r.text());
});

app.listen(3000);

9) 컨테이너/쿠버네티스에서 더 위험한 이유: “재시작 루프”로 번진다

로컬에서는 바로 에러가 보여도, 쿠버네티스에서는 앱이 즉시 죽으면서 CrashLoopBackOff로 이어져 원인 파악이 늦어질 수 있습니다. 특히 로그 수집/사이드카/프로브 설정에 따라 “로그가 안 남는 것처럼” 보이기도 합니다.

  • 앱이 시작 직후 모듈 로딩에서 죽음 → readiness/liveness 통과 실패 → 재시작 반복

운영에서 이런 상황을 빨리 진단하는 방법은 아래 글이 도움이 됩니다.

10) 결론: 우선순위별 권장 해결책

  • 가장 빠른 응급처치: CJS에서 await import()로 ESM 패키지를 로드
  • 호출부 변경 최소화: 브리지(래퍼) 모듈로 import를 한 곳에 모으고 캐싱
  • 장기적으로 안정적: 프로젝트를 ESM으로 전환하거나, CJS/ESM 경계를 .cjs/.mjs로 명확히 분리
  • TS 사용 시: module/moduleResolution: NodeNext로 런타임 규칙과 컴파일 규칙을 일치
  • 재발 방지: lockfile 고정 + 메이저 업그레이드 자동 반영 방지 + 릴리스 노트 확인

ERR_REQUIRE_ESM은 단순 오류가 아니라 “생태계가 ESM으로 이동하는 과정에서 생기는 마찰”입니다. 한 번에 완전 전환이 어렵다면, 위의 동적 import → 브리지 → 점진적 ESM 전환 순서로 단계적으로 정리하는 것이 가장 안전합니다.