Published on

Node.js ESM·CJS 혼용 시 ERR_REQUIRE_ESM 해결

Authors

서버 코드를 Node.js로 운영하다 보면, 내 코드는 CJS인데 의존성 하나가 ESM으로 바뀌거나(또는 반대) 해서 갑자기 ERR_REQUIRE_ESM이 터지는 순간이 옵니다. 특히 라이브러리가 메이저 업데이트로 ESM 전환을 했거나, package.json"type" 변경이 들어간 경우가 대표적입니다.

이 글에서는 “ESM과 CJS를 섞을 때 왜 깨지는지”를 구조적으로 설명하고, 프로젝트 전체를 갈아엎지 않고도 단계적으로 해결하는 패턴을 정리합니다. 더 깊게 Node.js 20+ 관점에서 ERR_REQUIRE_ESM을 체계적으로 정리한 글은 Node.js 20+ ESM에서 ERR_REQUIRE_ESM 완전정복도 함께 참고하면 좋습니다.

1) 에러 메시지부터 정확히 읽기

가장 흔한 형태는 아래입니다.

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.

이 메시지가 의미하는 바는 단순합니다.

  • 지금 실행 중인 파일은 CJS(require) 방식이다.
  • 그런데 불러오려는 대상은 ESM 전용이다.
  • CJS에서 ESM을 정적으로 require()로는 못 부른다.
  • 대신 CJS에서는 import()(동적 import)로 우회해야 한다.

여기서 중요한 포인트는 “Node.js는 ESM과 CJS를 동시에 지원하지만, 상호 호출 규칙은 비대칭”이라는 점입니다.

  • ESM import는 CJS를 불러올 수 있다(대부분 가능)
  • CJS require는 ESM을 불러올 수 없다(원칙적으로 불가)

2) ESM인지 CJS인지 판별하는 체크리스트

문제 해결의 80%는 “지금 무엇이 ESM이고 무엇이 CJS인지”를 확정하는 데서 끝납니다.

2-1) 내 코드가 ESM인지 CJS인지

다음 중 하나라도 해당하면 ESM일 가능성이 큽니다.

  • package.json"type": "module"
  • 파일 확장자가 .mjs
  • import ... from ...를 사용하고 있고, Node가 이를 네이티브로 실행

반대로 다음이면 CJS일 가능성이 큽니다.

  • package.json"type": "commonjs" 또는 type 미지정(기본은 CJS)
  • 파일 확장자가 .cjs
  • const x = require('x') 패턴

2-2) 의존성이 ESM인지 CJS인지

의존성은 다음을 보면 힌트가 나옵니다.

  • 해당 패키지의 package.json"type": "module"
  • exports 필드가 ESM 엔트리만 노출
  • 메인 파일이 .mjs 또는 ESM 문법

특히 최근 패키지들은 exports로 진입점을 강하게 통제합니다. 이 경우 예전처럼 “깊은 경로로 require 해서 우회”가 막혀 있을 수 있습니다.

3) 가장 현실적인 해결: CJS에서 ESM을 import()로 호출

프로젝트가 CJS 기반인데, 특정 라이브러리만 ESM이라면 가장 안전한 해법은 “해당 라이브러리만 동적 import로 격리”하는 것입니다.

3-1) 기본 패턴

// index.cjs (또는 type 미지정 환경의 .js)
async function main() {
  const { default: ky } = await import('ky');
  const res = await ky.get('https://example.com').text();
  console.log(res);
}

main().catch(console.error);

핵심은 아래입니다.

  • CJS 파일에서도 import()는 사용 가능
  • ESM 패키지를 가져오면 보통 default에 기본 export가 들어올 수 있음

3-2) 성능과 구조: 매번 import 하지 말고 캐싱

핫 패스에서 매번 import()를 호출하면 의도치 않게 코드가 지저분해집니다. 보통은 모듈 스코프에 캐시를 둡니다.

// esm-client.cjs
let cached;

async function getEsmClient() {
  if (!cached) {
    cached = import('some-esm-only-lib');
  }
  return cached;
}

module.exports = { getEsmClient };
// app.cjs
const { getEsmClient } = require('./esm-client.cjs');

async function handler() {
  const lib = await getEsmClient();
  return lib.doSomething();
}

module.exports = { handler };

이렇게 하면 ESM 의존성은 “한 번만 로드”되고, 나머지 코드는 CJS로 유지됩니다.

4) 반대 상황: ESM에서 CJS를 불러올 때의 함정

ESM 코드에서 CJS를 가져오는 건 대체로 됩니다. 다만 default export 매핑 때문에 헷갈리기 쉽습니다.

// app.mjs
import pkg from 'some-cjs-lib';

// CJS의 module.exports가 default처럼 들어오는 경우가 많음
console.log(pkg);

또는 Node 내장 createRequire를 써서 “ESM에서 require”를 만들 수도 있습니다.

// app.mjs
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);

const cjsOnly = require('some-cjs-only-lib');
console.log(cjsOnly);

이 패턴은 “ESM 프로젝트인데 특정 CJS 패키지가 ESM에서 import 시 이상하게 동작”할 때 유용합니다.

5) 근본 해결 1: 프로젝트를 ESM으로 정리(점진적 마이그레이션)

혼용이 반복된다면, 장기적으로는 프로젝트를 ESM으로 정리하는 편이 운영 비용을 줄입니다.

5-1) 최소 변경

  • package.json"type": "module" 추가
  • 기존 CJS 파일은 확장자를 .cjs로 바꿔서 유지
  • 신규 코드는 .js(ESM)로 작성

예시:

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

파일 구성:

  • src/index.js는 ESM
  • src/legacy.cjs는 기존 CJS 유지
// src/index.js (ESM)
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);

const legacy = require('./legacy.cjs');
console.log(legacy);

이 방식은 “전체를 한 번에 바꾸지 않고” 섞어 가는 정석입니다.

5-2) 자주 터지는 포인트: __dirname, __filename

ESM에는 기본적으로 __dirname이 없습니다. 아래처럼 대체합니다.

// esm-file.js
import { fileURLToPath } from 'node:url';
import path from 'node:path';

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

console.log(__dirname);

이걸 해결하지 못하면, ESM 전환 중에 경로 관련 버그가 연쇄적으로 발생합니다.

6) 근본 해결 2: 의존성 버전/엔트리 선택(가능할 때만)

모든 패키지가 “ESM만 제공”하는 것은 아닙니다. 일부는 듀얼 패키지로 CJS 엔트리를 유지합니다.

6-1) 패키지가 CJS 엔트리를 제공한다면

exportsrequire 조건이 있는지 확인합니다(패키지 내부 package.json). 예를 들어 아래처럼 되어 있으면 CJS에서 그대로 require('pkg')가 됩니다.

{
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  }
}

이 경우 ERR_REQUIRE_ESM이 난다면, 대개는 다음 중 하나입니다.

  • 내가 “깊은 경로”를 require('pkg/dist/index.js') 같은 방식으로 직접 찔렀다
  • 번들러/트랜스파일 결과물이 엔트리 선택을 망가뜨렸다

해결은 “공식 엔트리만 import 또는 require”로 정리하는 것입니다.

6-2) 메이저 업그레이드로 ESM 전환된 라이브러리

운영 중인 서비스에서 급하면 “해당 패키지를 CJS 지원 버전으로 핀 고정”도 현실적인 선택입니다.

{
  "dependencies": {
    "some-lib": "2.9.4"
  }
}

다만 이는 임시 처방입니다. 장기적으로는 보안 패치/호환성 이슈가 누적될 수 있으니, 동적 import 브리징이나 ESM 전환 계획을 같이 잡는 게 좋습니다.

7) TypeScript를 쓰면 더 자주 꼬이는 지점

TypeScript는 “컴파일 결과가 CJS인지 ESM인지”가 런타임과 직결됩니다.

7-1) CJS로 컴파일하면서 ESM 의존성을 require 하는 상황

tsconfig.json에서 moduleCommonJS면 컴파일 결과는 require()가 됩니다. 이때 ESM 전용 패키지를 정적으로 import 하면 런타임에서 ERR_REQUIRE_ESM이 날 수 있습니다.

현실적인 대응은 두 가지입니다.

  • 출력 모듈을 ESM으로 바꾸기
  • 특정 ESM 의존성만 import()로 분리

예: 특정 패키지만 동적 import로 분리하는 TS 코드

// esmOnlyClient.ts
export async function getClient() {
  const mod = await import('some-esm-only-lib');
  return mod;
}

컴파일 결과가 CJS여도 import()는 남아서 동작하는 경우가 많습니다(설정/타겟에 따라 다름).

7-2) NodeNext 계열로 맞추기

Node의 ESM 규칙을 가장 덜 놀라게 따라가려면 modulemoduleResolution을 NodeNext 계열로 두는 선택지가 있습니다.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist"
  }
}

이 구성이 항상 정답은 아니지만, “Node에서 그대로 돌릴 코드”라면 혼용 문제를 줄이는 데 도움이 됩니다.

8) 운영 환경에서의 체크: 컨테이너/서버리스에서 더 잘 터지는 이유

로컬에서는 우연히 되는데 배포하면 깨지는 케이스가 있습니다.

  • Node 버전 차이로 ESM 해석 규칙이 달라짐
  • 번들/트랜스파일 산출물이 달라짐
  • 런타임 플래그/실행 방식이 달라짐

서버리스나 컨테이너에서 초기화 지연과 함께 모듈 로딩 문제가 겹치면 장애로 보이기도 합니다. 예를 들어 콜드스타트가 큰 환경에서는 초기 import 전략(정적 import vs 지연 import)이 지연/타임아웃과 맞물릴 수 있는데, 이런 운영 관점은 GCP Cloud Run 504와 콜드스타트 지연 해결 가이드 같은 글의 접근법도 함께 참고할 만합니다.

9) 실전 처방전: 상황별로 이렇게 고르면 된다

9-1) CJS 프로젝트, ESM 라이브러리 1~2개만 필요

  • 해당 라이브러리만 await import('lib')로 감싸서 격리
  • 모듈 캐싱으로 성능/가독성 확보

9-2) 신규 프로젝트 또는 혼용이 계속 늘어나는 중

  • "type": "module"로 ESM을 기본값으로
  • 레거시만 .cjs로 남기고 점진 이관

9-3) TypeScript 기반이고 런타임이 Node 직행

  • moduleNodeNext로 검토
  • 출력이 CJS라면 ESM 의존성은 동적 import로 분리

9-4) 급한 장애 대응

  • 의존성 버전 핀 고정으로 일단 복구
  • 이후 ESM 브리징 또는 ESM 전환으로 근본 해결

10) 마무리: ERR_REQUIRE_ESM은 “규칙 위반”의 결과다

ERR_REQUIRE_ESM은 대개 Node가 까다로워서가 아니라, “CJS의 require()로 ESM을 정적으로 가져오려는 규칙 위반”에서 시작합니다. 해결은 생각보다 단순하게 두 갈래입니다.

  • CJS에서 ESM은 import()로 호출한다
  • 장기적으로는 프로젝트를 ESM으로 정리하고 레거시는 .cjs로 격리한다

혼용을 피할 수 없다면, 경계면을 명확히(브리징 모듈 하나로 통제) 해두는 것이 운영과 디버깅을 크게 편하게 만듭니다.