Published on

Node.js 20+ ESM에서 ERR_REQUIRE_ESM 완전정복

Authors

서버 사이드 Node.js를 Node 20+로 올리고, 프로젝트를 ESM(ECMAScript Modules)로 전환하는 순간 가장 흔하게 맞닥뜨리는 에러가 ERR_REQUIRE_ESM입니다. 에러 메시지는 친절해 보이지만, 실제 원인은 패키지 타입(type), 파일 확장자(.js/.mjs/.cjs), exports 조건부 매핑, 트랜스파일 결과물, 테스트 러너/번들러의 모듈 로딩 방식이 얽히면서 생각보다 복잡해집니다.

이 글은 “왜 이 에러가 뜨는지”를 정확히 이해하고, Node.js 20+ 기준으로 재현 → 진단 → 해결 패턴을 체계적으로 정리합니다. 특히 라이브러리 소비자(앱) 입장과 라이브러리 제작자(패키지) 입장을 나눠서, 팀에서 바로 적용 가능한 형태로 작성했습니다.

> 참고: 본문에서 다루는 원칙은 런타임 장애를 빠르게 좁혀가는 방식과도 닮아 있습니다. 운영 환경에서 원인 분리를 빠르게 하는 접근은 Kubernetes API 413 Request Entity Too Large 해결 같은 글에서도 동일한 사고방식으로 적용됩니다.

ERR_REQUIRE_ESM이 의미하는 것

ERR_REQUIRE_ESMCommonJS의 require()로 ESM 모듈을 로드하려 했을 때 Node가 던지는 런타임 에러입니다.

대표적인 상황은 아래 둘 중 하나입니다.

  1. 내 코드가 CJS인데 ESM 전용 패키지를 require('some-esm-only')로 불러옴
  2. 내 코드가 ESM인데 빌드 산출물/테스트 환경/도구가 내부적으로 require()로 로딩함(예: Jest 설정, ts-node 설정, 오래된 CLI 프레임워크)

에러 메시지 예시는 대략 이런 형태입니다.

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.

핵심은 “ESM은 require()로 못 불러온다”입니다. 그렇다면 무엇이 ESM인지/누가 require()를 호출했는지를 찾아야 합니다.

Node.js에서 ESM/CJS를 결정하는 규칙(우선순위)

Node 20+에서 파일이 ESM인지 CJS인지는 다음 규칙으로 결정됩니다.

  1. 확장자
    • .mjs → 무조건 ESM
    • .cjs → 무조건 CJS
  2. 확장자가 .js일 때
    • 가장 가까운 package.json"type" 값으로 결정
      • "type": "module".js는 ESM
      • "type": "commonjs" 또는 미지정 → .js는 CJS

이 규칙을 모르고 “그냥 import 쓰면 ESM이지”라고 생각하면, 빌드 산출물이 .js로 떨어지는 순간(특히 TS) 모듈 타입이 뒤집히며 ERR_REQUIRE_ESM이 발생할 수 있습니다.

최소 재현: 왜 require가 터지는지

1) ESM 전용 모듈 만들기

mkdir esm-only && cd esm-only
npm init -y

package.json

{
  "name": "esm-only",
  "version": "1.0.0",
  "type": "module",
  "exports": "./index.js"
}

index.js

export const hello = () => "hello from ESM";

2) CJS에서 require로 불러오기

다른 폴더에서:

mkdir cjs-app && cd cjs-app
npm init -y
npm i ../esm-only

index.cjs

const { hello } = require("esm-only");
console.log(hello());

실행:

node index.cjs

여기서 ERR_REQUIRE_ESM이 발생합니다. 원인은 단순합니다. esm-onlytype: module이라 ESM인데, CJS의 require()로 로드하려 했기 때문입니다.

1차 진단 체크리스트(실무용)

에러를 보면 아래를 순서대로 확인하면 빠릅니다.

  1. 에러 스택에서 “누가 require()를 했는지” 확인
    • 내 코드인지, 도구(Jest/ts-node/CLI)인지
  2. 문제 모듈이 ESM인지 확인
    • 해당 패키지의 package.json에서 type, exports, main 확인
  3. 내 엔트리포인트가 CJS인지 ESM인지 확인
    • 확장자(.cjs/.mjs)와 루트 package.jsontype
  4. 빌드 결과물이 어떤 확장자로 떨어지는지 확인
    • TS 컴파일 후 .js가 ESM인지 CJS인지 뒤집히는 경우 많음

운영 장애처럼 “원인 후보를 좁혀가며 재현 가능한 단위로 쪼개는 것”이 중요합니다. (레이트리밋/쿼터 문제를 원인별로 분리하는 접근은 OpenAI Responses API 429 쿼터·레이트리밋 대응 같은 글에서도 유사합니다.)

해결 전략 A: CJS에서 ESM을 써야 한다면(동적 import 브리지)

Node는 CJS 파일에서도 **동적 import()**를 지원합니다. 가장 현실적인 “즉시 해결책”은 require()import()로 바꾸는 것입니다.

// index.cjs
(async () => {
  const { hello } = await import("esm-only");
  console.log(hello());
})();

언제 이 방법이 좋은가?

  • 레거시 코드가 CJS인데, 특정 ESM 전용 패키지 하나만 써야 할 때
  • 마이그레이션 중 과도기(혼합 모듈)에서 빠르게 unblock 해야 할 때

주의점

  • 동적 import는 비동기입니다. 초기화 순서/로딩 타이밍에 민감한 코드라면 구조를 조금 바꿔야 합니다.
  • top-level에서 바로 쓰고 싶다면 IIFE(즉시 실행 async 함수) 패턴이 흔합니다.

해결 전략 B: 프로젝트를 ESM으로 전환(근본 해결)

앱이 Node 20+를 전제로 하고 있고, ESM 전용 패키지를 계속 사용할 계획이라면 프로젝트 자체를 ESM으로 전환하는 편이 장기적으로 덜 고통스럽습니다.

1) package.json에 type: module

{
  "type": "module"
}

2) require → import로 변경

// before (CJS)
const fs = require("node:fs");

// after (ESM)
import fs from "node:fs";

Node 내장 모듈은 node: 프리픽스를 붙이는 습관이 충돌을 줄여줍니다.

3) __dirname, __filename 대체

ESM에서는 __dirname/__filename이 없습니다.

import { fileURLToPath } from "node:url";
import { dirname } from "node:path";

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

해결 전략 C: 라이브러리 제작자라면(dual package: ESM+CJS 동시 제공)

ERR_REQUIRE_ESM을 “사용자에게” 발생시키지 않으려면, 패키지 제작 단계에서 CJS와 ESM을 둘 다 제공하는 것이 가장 안전합니다.

권장: exports 조건부 매핑

package.json

{
  "name": "my-lib",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  }
}
  • ESM 소비자는 import 경로(dist/index.js)를 받음
  • CJS 소비자는 require 경로(dist/index.cjs)를 받음
  • 타입스크립트 소비자는 types를 받음

빌드 산출물 예시

  • dist/index.js (ESM)
  • dist/index.cjs (CJS)
  • dist/index.d.ts

TS로 듀얼 빌드를 하려면 보통 설정을 2개로 나눕니다.

tsconfig.esm.json

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "ESNext",
    "outDir": "dist",
    "declaration": true
  }
}

tsconfig.cjs.json

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "CommonJS",
    "outDir": "dist-cjs",
    "declaration": false
  }
}

그리고 CJS 결과물을 .cjs로 맞추는 후처리(리네임) 또는 빌드 도구(tsup, rollup, esbuild)를 사용합니다. 실무에서는 tsup이 단순합니다.

tsup.config.ts

import { defineConfig } from "tsup";

export default defineConfig({
  entry: ["src/index.ts"],
  format: ["esm", "cjs"],
  dts: true,
  splitting: false,
  sourcemap: true,
  clean: true
});

이렇게 하면 소비자는 모듈 시스템을 의식하지 않고도 설치해서 쓸 수 있고, ERR_REQUIRE_ESM으로 이탈할 확률이 크게 줄어듭니다.

해결 전략 D: 테스트/도구가 require로 로딩하는 경우

애플리케이션 코드는 ESM인데도 ERR_REQUIRE_ESM이 나는 흔한 이유는 “테스트 러너나 실행 도구가 CJS 기반”이기 때문입니다.

Jest

  • Jest는 역사적으로 CJS 친화적이었고, ESM 지원은 설정이 까다롭습니다.
  • Node 20+ ESM 프로젝트라면 Vitest로 옮기는 것이 마이그레이션 비용 대비 효과가 큰 편입니다.

ts-node

  • ESM 모드 실행은 플래그/로더 설정이 필요합니다.
  • 가능하면 개발 실행은 tsx(esbuild 기반) 같은 도구를 쓰면 스트레스가 줄어듭니다.

예: tsx로 ESM TS 실행

npm i -D tsx
npx tsx src/index.ts

자주 나오는 함정 6가지

1) “나는 import 썼는데 왜 require 에러가?”

내 코드가 아니라 의존성/도구가 require로 읽는 경우가 많습니다. 스택 트레이스에서 최초의 require 호출 지점을 찾으세요.

2) package.json의 type 변경 후, 기존 스크립트가 전부 깨짐

type: module을 켜면 .js의 의미가 바뀝니다. 레거시 스크립트는 .cjs로 명시적으로 바꾸는 것이 안전합니다.

3) TS 컴파일 결과가 .js인데, Node가 CJS로 해석

루트에 type이 없으면 .js는 CJS입니다. TS에서 module: ESNext로 빌드했더라도 런타임 해석이 뒤집힐 수 있습니다.

4) exports가 있는데 main만 보고 판단

Node 20+에서 많은 패키지는 exports가 우선입니다. main만 보고 “CJS겠네”라고 판단하면 틀립니다.

5) 동적 import로 바꿨는데 default/named export가 달라짐

CJS↔ESM 브리지에서 default가 끼어드는 경우가 있습니다.

const mod = await import("some-cjs");
// mod.default에 실제 exports가 들어있을 수 있음

패키지의 export 형태를 확인하고 매핑하세요.

6) 번들러가 알아서 해줄 거라는 기대

서버 런타임(Node)에서 직접 실행하는 코드라면, 번들러가 항상 해결해주지 않습니다. 특히 “노드에서 그대로 실행되는 dist”라면 모듈 타입/확장자/exports 설계가 우선입니다.

실전 처방전: 상황별 선택 가이드

  • 앱이 CJS이고, ESM 전용 패키지 1~2개만 필요 → 동적 import()로 브리지(전략 A)
  • 앱이 장기적으로 Node 20+ 고정, 신규 개발 중심 → 프로젝트 ESM 전환(전략 B)
  • 패키지를 배포하고 다양한 소비자를 지원해야 함exports로 dual package(전략 C)
  • ESM인데 테스트/도구에서 터짐 → 도구를 ESM 친화적으로 교체 또는 ESM 설정 강화(전략 D)

운영 환경에서 모듈 로딩 문제가 장애로 이어지면, 컨테이너 이미지/런타임/빌드 산출물의 “불변성”이 깨진 경우가 많습니다. 이런 류의 문제는 네트워크나 IAM만큼이나 재현이 중요합니다. (예: EKS에서 자격증명 문제를 원인별로 분리하는 방식은 EKS Pod에서 AWS SDK 자격증명 못찾음 해결 가이드처럼 단계화가 핵심입니다.)

결론: ERR_REQUIRE_ESM은 ‘모듈 경계’ 문제다

ERR_REQUIRE_ESM은 단순히 “import로 바꿔라”가 아니라, CJS와 ESM의 경계에서 누가 누구를 어떤 방식으로 로드하는지가 설계/도구/빌드 결과물까지 포함해 일관되지 않을 때 발생합니다.

가장 빠른 해결은 동적 import()지만, 장기적으로는 프로젝트/패키지 레벨에서 ESM을 기준으로 정리하고(type, 확장자, exports), 불가피한 CJS 소비자를 위해 require 엔트리까지 제공하는 것이 정답에 가깝습니다. 이 원칙만 잡아도 Node 20+ ESM 전환에서의 대부분의 ERR_REQUIRE_ESM은 예측 가능해지고, 재발도 크게 줄어듭니다.