Published on

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

Authors

서론

Node.js 프로젝트를 CommonJS(CJS)에서 ESM(ECMAScript Modules)으로 전환하면 가장 먼저 부딪히는 에러 중 하나가 ERR_REQUIRE_ESM입니다. 보통은 “require()로 ESM 패키지를 로드하려 했다”는 뜻인데, 실제 현장에서는 원인이 더 다양합니다. 예를 들어:

  • 내 코드가 아직 CJS인데, 의존성이 ESM 전용으로 바뀐 경우
  • 패키지의 type/exports 설정 때문에 Node가 파일을 ESM으로 해석하는 경우
  • 테스트 러너(Jest 등)나 빌드 도구가 CJS로 실행되며 ESM을 끌어오는 경우
  • ts-node/tsx/ts-jest 같은 런타임 트랜스파일러가 모듈 해석을 뒤틀어 놓는 경우

이 글에서는 ERR_REQUIRE_ESM이 발생하는 대표 패턴을 분류하고, “가장 적은 변경으로 빨리 복구하는 방법”부터 “프로젝트 전체를 ESM으로 정리하는 방법”까지 단계적으로 정리합니다.

> CI/배포 환경에서만 재현되는 경우도 흔합니다. 빌드 캐시/컨테이너 레이어로 인해 의존성 버전이 달라지는 상황이 있다면, 캐시 관련 점검도 병행하세요. 예: Docker BuildKit 캐시 무효화 원인·해결 8가지, GitHub Actions 캐시 안 먹힘 원인 7가지

ERR_REQUIRE_ESM이 의미하는 것

Node의 핵심 메시지는 이겁니다.

  • CJS 컨텍스트에서 require()로 ESM 모듈을 불러오면 안 된다.
  • 해결은 크게 두 갈래:
    1. 호출하는 쪽을 ESM으로 전환해서 import를 사용
    2. 어쩔 수 없이 CJS를 유지한다면 동적 import() 또는 CJS 호환 엔트리를 사용

Node는 파일을 ESM/CJS로 해석할 때 다음 규칙을 사용합니다.

  • .mjs는 ESM
  • .cjs는 CJS
  • .jspackage.jsontype에 따라 결정
    • "type": "module"이면 .js는 ESM
    • "type": "commonjs"(기본)면 .js는 CJS
  • 패키지의 exports 필드가 있으면, 해당 조건에 맞는 엔트리를 선택

가장 흔한 발생 패턴 5가지

1) CJS 코드에서 ESM 전용 패키지를 require

예를 들어 chalk@5, node-fetch@3 등은 ESM 전용입니다.

// index.js (CommonJS)
const fetch = require('node-fetch'); // ❌ ERR_REQUIRE_ESM

해결 옵션

  • (권장) 프로젝트를 ESM으로 전환하고 import 사용
  • (응급) CJS에서 동적 import 사용
// index.cjs
async function main() {
  const { default: fetch } = await import('node-fetch');
  const res = await fetch('https://example.com');
  console.log(res.status);
}
main();

동적 import()는 CJS에서도 동작합니다. 다만 비동기 흐름으로 바뀌므로 초기화 코드 구조를 조정해야 합니다.

2) 내 프로젝트를 "type": "module"로 바꿨는데 일부 파일/도구가 CJS 가정

package.json에 아래를 추가하면 .js가 전부 ESM으로 해석됩니다.

{
  "type": "module"
}

이때 기존 CJS 스타일이 남아 있으면 즉시 깨집니다.

// 이제 ESM으로 해석되는 .js
const path = require('path'); // ❌ require is not defined (또는 관련 에러)

해결

// ESM
import path from 'node:path';
import fs from 'node:fs';

Node 내장 모듈은 node: 프리픽스를 붙이면 해석이 명확해지고 번들러/린터에서도 안정적입니다.

3) 경로 import에서 확장자 누락

ESM에서는 상대 경로 import 시 확장자를 명시해야 하는 경우가 많습니다(특히 Node native ESM).

// ESM
import { foo } from './foo'; // ❌ 상황에 따라 ERR_MODULE_NOT_FOUND

해결

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

TypeScript를 쓴다면 컴파일 결과가 .js로 떨어지므로, 소스에서부터 .js 확장자를 쓰는 패턴이 필요합니다(뒤에서 설명).

4) exports 조건부 엔트리 때문에 의도치 않게 ESM이 선택됨

의존성 패키지가 아래처럼 exports를 제공하면, Node는 조건에 따라 엔트리를 고릅니다.

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

내 코드가 CJS인데도 번들러/트랜스파일러 설정에 따라 import 조건을 타버리면 ERR_REQUIRE_ESM으로 이어질 수 있습니다.

해결 포인트

  • 런타임이 CJS면 require 조건을 타게 해야 함
  • 테스트/빌드 도구가 ESM으로 실행되는지(CJS로 실행되는지)부터 확인

5) Jest/ts-jest/ts-node 환경에서 모듈 시스템이 섞임

예: 앱 런타임은 ESM인데, Jest가 CJS로 테스트를 실행하면서 ESM 모듈을 require()로 끌어오면 ERR_REQUIRE_ESM이 납니다.

해결 방향

  • Jest를 ESM 모드로 전환하거나
  • 테스트만 CJS로 유지하되 ESM 의존성은 동적 import로 우회하거나
  • tsx 같은 실행기를 써서 테스트 런타임을 단순화

해결 전략: “응급 처치”부터 “정석 전환”까지

전략 A) 프로젝트는 CJS 유지 + ESM 의존성만 우회(가장 빠름)

레거시가 크고 전환 비용이 큰 경우, 다음 규칙으로 안정화할 수 있습니다.

  1. 엔트리/핵심 파일은 .cjs로 고정
  2. ESM 전용 패키지는 동적 import로 로드
  3. 가능하면 ESM 전용 패키지의 CJS 대체재/구버전 사용(단, 장기적으로는 비권장)
// server.cjs
const http = require('node:http');

async function createServer() {
  const { default: chalk } = await import('chalk');
  const server = http.createServer((req, res) => {
    console.log(chalk.green(req.url));
    res.end('ok');
  });
  return server;
}

createServer().then((s) => s.listen(3000));

장점: 변경 범위가 작음 단점: 비동기 초기화 증가, ESM 전환의 이점을 온전히 못 얻음

전략 B) 듀얼 패키지/듀얼 엔트리로 점진 전환

라이브러리(사내 패키지 포함)를 만들고 있다면, 소비자가 CJS/ESM 어느 쪽이든 쓸 수 있게 듀얼 엔트리를 제공하는 방식이 좋습니다.

package.json 예시:

{
  "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"
    }
  }
}

빌드 결과물:

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

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

전략 C) 애플리케이션을 ESM으로 “정석” 전환(권장)

서비스 애플리케이션이라면 장기적으로 ESM 정리가 유지보수에 유리합니다.

1) package.json에 type=module 설정

{
  "type": "module",
  "scripts": {
    "start": "node ./dist/index.js"
  }
}

2) 모든 require/module.exports를 import/export로 변경

// before (CJS)
const express = require('express');
module.exports = { createApp };

// after (ESM)
import express from 'express';
export function createApp() {}

3) __dirname, __filename 대체

ESM에는 __dirname이 없습니다. 아래 패턴을 씁니다.

import { fileURLToPath } from 'node:url';
import path from 'node:path';

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

console.log(__dirname);

4) JSON import(버전/옵션 주의)

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

// Node 20+에서 주로 사용
import pkg from './package.json' with { type: 'json' };
console.log(pkg.name);

호환성이 필요하면 fs로 읽는 방식이 안전합니다.

import fs from 'node:fs/promises';
const pkg = JSON.parse(await fs.readFile(new URL('./package.json', import.meta.url)));

TypeScript 사용 시: tsconfig에서 가장 많이 삐끗하는 지점

TypeScript + ESM 전환에서 핵심은 “Node의 ESM 해석 규칙”과 “TS가 내보내는 JS”를 일치시키는 겁니다.

권장 조합(최근 Node 기준):

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true
  }
}

그리고 TS 소스에서 상대 import에 .js 확장자를 붙이는 패턴을 받아들여야 합니다.

// src/index.ts
import { foo } from './foo.js'; // 중요: .ts가 아니라 .js

TS는 컴파일 시 이를 유지하고, 런타임 Node는 dist/foo.js를 찾습니다.

CJS로 남겨야 하는 파일이 있다면 .cjs/.cts 활용

예를 들어 설정 파일 하나만 CJS로 유지하고 싶다면:

  • JS: .cjs
  • TS: .cts (CommonJS TypeScript)
// config.cts
const config = { port: 3000 };
export = config;

실전 디버깅 체크리스트

ERR_REQUIRE_ESM이 났을 때는 “어느 파일이 CJS 컨텍스트인지”를 먼저 확정해야 합니다.

  1. 스택 트레이스에서 최초로 require() 호출한 파일 확인
  2. 해당 파일의 확장자/패키지 type 확인
    • .cjs면 CJS 확정
    • .js면 상위 package.jsontype 확인
  3. 문제의 의존성이 ESM 전용인지 확인
    • 패키지의 package.json에서 type: module 또는 exportsrequire 엔트리 부재
  4. 실행 커맨드가 도구에 의해 바뀌지 않았는지 확인
    • node dist/index.js인지, ts-node/jest/babel-node인지
  5. CI에서만 발생한다면 lockfile/캐시로 의존성 버전이 달라지지 않았는지 확인
    • 캐시가 꼬이면 “로컬은 CJS 호환 버전, CI는 ESM 전용 버전” 같은 상황이 생깁니다.

자주 쓰는 해결 레시피 모음

레시피 1) ESM 패키지를 CJS에서 써야 할 때(동적 import)

// legacy.cjs
module.exports = async function run() {
  const { default: ora } = await import('ora');
  const spinner = ora('loading').start();
  spinner.succeed('done');
};

레시피 2) ESM 프로젝트에서 CJS 모듈 가져오기

CJS는 ESM에서 기본 import로 받을 수 있습니다(대부분의 경우).

import pkg from 'some-commonjs-package';

다만 named import는 기대대로 동작하지 않을 수 있어, 문서/타입 정의를 확인하세요.

레시피 3) 파일 하나만 CJS로 고정

  • "type": "module" 프로젝트에서
  • 특정 파일만 .cjs로 만들면 CJS로 동작
// scripts/generate.cjs
const fs = require('node:fs');
console.log('still commonjs');

마이그레이션 시 운영 관점 주의사항

ESM 전환은 “코드가 돌아간다”에서 끝나지 않고, 운영 환경에서 다음이 흔히 문제됩니다.

  • 컨테이너 빌드 캐시로 인해 의존성 해석이 달라짐
  • CI 캐시가 lockfile 변경을 제대로 반영하지 않음
  • 런타임 Node 버전 차이(로컬 20, 서버 18 등)

특히 Docker/CI에서만 터지는 케이스는 캐시와 Node 버전부터 맞추는 게 시간을 아낍니다. 관련해서는 Docker BuildKit 캐시 무효화 원인·해결 8가지GitHub Actions 캐시 안 먹힘 원인 7가지를 같이 점검하면 좋습니다.

결론

ERR_REQUIRE_ESM은 단순히 “require를 import로 바꾸세요” 수준의 문제가 아니라, 프로젝트의 모듈 시스템 경계(CJS/ESM), 패키지의 exports 조건, 실행 도구(테스트/트랜스파일러), 그리고 배포 환경의 재현성이 한 번에 얽혀서 터지는 경우가 많습니다.

빠른 복구가 목표라면 CJS를 유지한 채 동적 import()로 우회하고, 장기적으로는 type: module + NodeNext(TS) 조합으로 ESM을 정리하면서 .cjs/.cts를 보조적으로 사용하는 전략이 가장 안정적입니다.

원하시면 현재 에러 로그(스택 트레이스)와 package.json, 실행 커맨드(node/jest/ts-node 등)를 기준으로 “최소 변경” 해법을 케이스별로 딱 맞게 제안해드릴 수 있습니다.