Published on

Node.js ESM 전환 시 ERR_REQUIRE_ESM 해결 가이드

Authors

서버 사이드 Node.js 프로젝트를 ESM(ECMAScript Modules)로 전환하면 가장 먼저 마주치는 런타임 오류가 ERR_REQUIRE_ESM입니다. 대개는 “ESM 모듈을 CommonJS의 require()로 불러오지 마라”는 메시지인데, 실제 현장에서는 원인이 훨씬 다양합니다. 직접 작성한 코드에서 require()를 썼을 수도 있고, 트랜스파일 결과물이 CJS로 떨어졌을 수도 있으며, 테스트 러너/번들러/배포 환경이 ESM을 충분히 이해하지 못해 간접적으로 터지기도 합니다.

이 글에서는 ERR_REQUIRE_ESM이 발생하는 대표 패턴을 분류하고, 프로젝트 규모가 커져도 안전하게 전환할 수 있는 해결 전략을 코드와 함께 정리합니다.

> 장애를 “원인→재현→해결→재발 방지”로 정리하는 습관은 다른 영역에서도 동일하게 통합니다. 예를 들어 Docker 빌드가 갑자기 느려졌을 때 캐시 무효화 원인을 추적하는 방식은 ESM 전환 트러블슈팅에도 그대로 적용됩니다: Docker 빌드 캐시가 무효화되는 원인 7가지

ERR_REQUIRE_ESM이 의미하는 것

Node.js에서 모듈 시스템은 크게 두 가지입니다.

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

ERR_REQUIRE_ESM은 보통 아래 상황에서 발생합니다.

  • 현재 실행 컨텍스트가 CJS인데
  • require('some-esm-only-package')처럼 ESM 전용 패키지require()로 로드하려고 할 때

에러 예시는 다음과 같습니다.

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로 해석되고 있다”는 점입니다. 따라서 해결은 크게 두 갈래입니다.

  1. 호출 측을 ESM으로 바꾸거나
  2. CJS에서 ESM을 로드하는 우회(동적 import 등)를 적용

1) 프로젝트가 ESM으로 인식되는 기준: type/module, 확장자, tsconfig

Node가 파일을 ESM으로 해석하는 규칙을 먼저 고정해야 합니다.

package.json의 type

  • "type": "module"이면 .js가 ESM
  • "type": "commonjs"(또는 미설정)이면 .js가 CJS
{
  "name": "my-app",
  "type": "module",
  "scripts": {
    "start": "node src/index.js"
  }
}

확장자 규칙(.mjs/.cjs)

혼용이 필요한 과도기에는 확장자가 가장 명시적입니다.

  • .mjs는 항상 ESM
  • .cjs는 항상 CJS

예: ESM 프로젝트에서 특정 파일만 CJS로 남겨야 한다면 해당 파일을 .cjs로 바꾸는 게 가장 안전합니다.

TypeScript를 쓰는 경우(tsconfig)

TypeScript는 “소스는 ESM처럼 보이는데 빌드 결과는 CJS” 같은 불일치를 만들기 쉽습니다. ESM 전환에서는 아래 조합이 흔합니다.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true
  }
}
  • module: "NodeNext"는 TS가 Node의 ESM 규칙(확장자/패키지 type)을 더 정확히 따라가도록 합니다.

2) 가장 흔한 원인: ESM 전용 패키지를 require()로 불러옴

예를 들어 어떤 라이브러리가 ESM-only로 배포되면, 기존 CJS 코드에서 아래처럼 로드할 때 바로 터집니다.

// CJS
const chalk = require('chalk'); // ERR_REQUIRE_ESM (chalk v5+ 등)

해결 A: 호출 측을 ESM으로 전환

가장 깔끔한 해결입니다.

// ESM
import chalk from 'chalk';
console.log(chalk.green('ok'));

프로젝트 전체를 ESM으로 전환했다면 require()를 남겨두지 않는 것이 원칙입니다.

해결 B: CJS에서 동적 import()로 로드

당장 전체를 ESM으로 못 바꾸는 경우, Node가 안내하는 방법이 동적 import입니다.

// CJS
(async () => {
  const chalk = (await import('chalk')).default;
  console.log(chalk.green('ok'));
})();
  • CJS 파일에서도 import()는 동작합니다.
  • default export 여부 때문에 .default가 필요할 수 있습니다(패키지 형태에 따라 다름).

해결 C: ESM-only의 구버전(CJS 지원 버전) 고정

전환 비용이 너무 크면 임시로 버전을 내리는 것도 방법입니다.

npm i chalk@4

다만 이는 “기술 부채를 뒤로 미루는 선택”이라, 마이그레이션 계획(언제 ESM으로 갈지)을 함께 남겨두는 게 좋습니다.

3) 내가 ESM으로 옮겼는데도 ERR_REQUIRE_ESM이 나는 이유

여기서부터가 실전입니다. “코드는 import로 바꿨는데도” 에러가 날 수 있습니다.

(1) 빌드 결과물이 CJS로 나오는 경우

TypeScript/번들러 설정 때문에 dist/가 CJS로 떨어지면, 런타임은 CJS 컨텍스트가 되어 ESM-only 패키지를 require()로 읽게 됩니다.

확인 방법:

  • dist/index.js"use strict"exports.__esModule 같은 흔적이 보이면 CJS일 가능성이 큽니다.
  • require("...")가 남아있으면 거의 확정입니다.

대응:

  • TS는 module: NodeNext 또는 ESNext
  • 번들러는 output format을 esm으로

(2) 테스트 러너가 CJS로 실행하는 경우(Jest 등)

애플리케이션은 ESM인데 테스트만 CJS로 돌면, 테스트 코드에서 ESM-only 패키지를 require하다가 터집니다.

대응 방향:

  • Jest는 ESM 지원 설정이 필요하거나, 대체로 Vitest 같은 ESM 친화 러너로 옮기는 편이 단순합니다.

Vitest 예:

{
  "scripts": {
    "test": "vitest"
  }
}

(3) 설정 파일이 CJS(.js)로 남아 있는 경우

ESLint/Prettier/webpack/vite 설정 파일이 .js인데 프로젝트가 type: module이면 그 파일은 ESM으로 해석됩니다. 반대로 도구가 CJS만 기대하면 충돌이 납니다.

실무 팁:

  • 도구가 CJS 설정을 요구하면 *.config.cjs로 분리

예:

// eslint.config.cjs
module.exports = {
  root: true
};

4) ESM 전환 시 꼭 바꿔야 하는 코드 패턴들

__dirname, __filename 대체

ESM에는 __dirname이 없습니다.

// ESM
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';

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

JSON import

Node 버전에 따라 JSON import는 assertion이 필요합니다.

import config from './config.json' with { type: 'json' };

호환성 이슈가 있으면 fs로 읽거나, 빌드 도구로 처리하는 게 안전합니다.

확장자 명시

ESM에서는 상대 경로 import에 확장자를 요구하는 경우가 많습니다(특히 NodeNext 규칙).

// ESM
import { foo } from './foo.js';

TypeScript 소스에서는 ./foo.js로 써도 TS가 알아서 매핑해주는 구성이 일반적입니다(moduleResolution: NodeNext).

5) 점진적 마이그레이션 전략(대규모 코드베이스용)

한 번에 전환하려다 실패하는 이유는 “혼용 구간”을 통제하지 못해서입니다. 아래 전략이 안정적입니다.

전략 1: 패키지 경계부터 ESM으로

모노레포/멀티 패키지라면, 패키지 단위로 type: module을 적용하고 경계에서만 CJS↔ESM 브리지를 둡니다.

  • 내부 패키지: ESM
  • 레거시 패키지: CJS 유지
  • 경계: 동적 import 또는 .cjs/.mjs로 명시

전략 2: 엔트리포인트만 ESM으로, 내부는 단계적 정리

처음에는 실행 진입점만 ESM으로 만들고, 내부는 CJS를 허용하되 “새 코드는 ESM” 규칙을 둡니다.

  • src/index.mjs (또는 type: module + src/index.js)
  • 내부의 레거시 CJS는 .cjs로 명시

전략 3: CI에서 혼용 감지

ESM 전환은 “런타임에서만” 터지는 경우가 많으니, CI에서 조기 감지하는 게 중요합니다.

예: dist 산출물에 require(가 남아있으면 실패시키기(간단하지만 효과적)

grep -R "require(\"" dist && echo "CJS require found" && exit 1 || true

CI/런너 환경에서 권한/실행 문제가 함께 발생하면 증상이 섞여 보일 수 있습니다. Node 실행 자체가 꼬인 상황(예: Docker-in-Docker 권한 문제)도 함께 점검하는 편이 좋습니다: GitLab CI Runner에서 Docker 권한 오류 해결 가이드

6) 자주 묻는 케이스별 처방전

케이스 A: type: module 켰더니 기존 코드가 다 깨짐

  • 우선 진입점만 .mjs로 바꾸고 type은 유지/미유지 선택
  • 레거시 파일은 .cjs로 명시
  • 가장 먼저 __dirname, 확장자 import, JSON import를 정리

케이스 B: 특정 라이브러리 하나 때문에만 ERR_REQUIRE_ESM

  • 가능하면 import로 전환
  • 불가하면 동적 import로 우회
  • 최후에는 구버전 고정(마이그레이션 티켓 생성)

케이스 C: TypeScript인데 런타임에서만 터짐

  • tsconfigmodule/moduleResolutionNodeNext
  • dist가 ESM으로 나오도록 확인
  • 실행은 node dist/index.js(package.json type과 일치해야 함)

TypeScript 런타임 오류는 ESM 전환과 겹치면 원인 파악이 더 어려워집니다. 데코레이터/트랜스파일 산출물 차이로 런타임이 달라지는 케이스도 같이 점검해보면 좋습니다: TS 5.6 데코레이터 적용 시 런타임 오류 해결

7) 최소 재현 예제로 보는 해결(실전 템플릿)

아래는 “CJS에서 ESM-only 패키지를 require하다가 터지는” 상황을 최소 재현하고 고치는 예시입니다.

재현

mkdir esm-demo && cd esm-demo
npm init -y
npm i chalk@5
node -e "const chalk=require('chalk'); console.log(chalk.green('x'))"

해결 1: ESM으로 전환

npm pkg set type=module
node -e "import chalk from 'chalk'; console.log(chalk.green('x'))"

해결 2: CJS 유지 + 동적 import

npm pkg delete type
node -e "(async()=>{const chalk=(await import('chalk')).default; console.log(chalk.green('x'))})()"

마무리: ERR_REQUIRE_ESM은 ‘혼용 통제’ 문제다

ERR_REQUIRE_ESM 자체는 단순한 메시지지만, 실제 원인은 보통 “프로젝트 어딘가가 CJS로 실행되고 있다”는 구조적 문제입니다. 따라서 해결도 단일 처방이 아니라,

  • Node가 ESM/CJS를 판단하는 규칙(type, 확장자, tsconfig)을 먼저 고정하고
  • ESM-only 패키지 도입 지점을 파악한 뒤
  • 경계(테스트/빌드/설정 파일/레거시 모듈)에서 혼용을 명시적으로 통제

하는 방식으로 접근해야 재발이 줄어듭니다.

다음에 같은 오류를 다시 만났을 때는 “어느 파일이 CJS로 해석되고 있나?”를 먼저 확인하고, 그 파일이 왜 CJS가 되었는지(빌드 산출물/도구 체인/확장자/패키지 type)를 역추적하면 대부분 빠르게 정리됩니다.