Published on

Node.js 22에서 require가 깨질 때 ESM 전환

Authors

서버 런타임을 Node.js 22로 올린 뒤, 기존에는 잘 되던 require 기반 코드가 특정 환경에서만 깨지거나(로컬은 되는데 CI에서 실패), 의존성 업데이트 이후 갑자기 ERR_REQUIRE_ESM 류의 에러가 터지는 경우가 많습니다. 특히 최근 생태계는 ESM 우선으로 빠르게 이동 중이고, 패키지들이 exports 맵과 ESM 엔트리만 제공하는 사례가 늘면서 CommonJS가 “그냥 계속 될 것”이라는 가정이 위험해졌습니다.

이 글은 Node.js 22에서 require가 깨지는 전형적인 패턴을 분류하고, 최소 변경으로 복구하는 응급처치부터 프로젝트를 안전하게 ESM으로 전환하는 단계적 방법까지 다룹니다.

Node.js 22에서 require가 깨지는 대표 증상

1) Error [ERR_REQUIRE_ESM]

가장 흔합니다. CommonJS에서 ESM 전용 모듈을 require()로 불러오면 발생합니다.

// index.cjs
const got = require('got');
// Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported

원인은 대개 다음 중 하나입니다.

  • 의존성 패키지가 ESM 전용으로 전환됨
  • package.jsonexports가 ESM 경로만 노출함
  • 번들러나 트랜스파일 결과가 ESM인데 실행은 CJS로 함

2) ReferenceError: require is not defined in ES module scope

파일이 ESM으로 해석되는데(예: "type": "module"), 코드가 require를 사용하면 발생합니다.

// index.js (ESM으로 해석됨)
const fs = require('node:fs');
// ReferenceError: require is not defined in ES module scope

3) 특정 패키지만 require가 실패한다

같은 프로젝트에서도 일부 패키지는 require가 되고, 일부는 안 됩니다. 이는 패키지별로 CJS/ESM 지원이 다르기 때문입니다.

  • CJS 듀얼 지원 패키지: require 가능
  • ESM 전용 패키지: require 불가

4) TS/빌드 설정 때문에 런타임 모듈 포맷이 뒤틀린다

TypeScript나 Babel이 ESM으로 빌드했는데, 실행 스크립트는 CJS로 간주하거나 그 반대인 케이스입니다.

이때는 런타임 에러가 모듈 로더 단계에서 터지므로, “코드가 문제”가 아니라 “산출물 포맷”이 문제인 경우가 많습니다.

먼저 확인할 체크리스트

프로젝트가 ESM으로 해석되는지 확인

다음 중 하나면 ESM으로 해석될 수 있습니다.

  • package.json"type": "module"
  • 파일 확장자가 .mjs
  • 특정 서브패키지의 package.json"type": "module"
{
  "type": "module"
}

문제가 되는 패키지가 ESM 전용인지 확인

패키지의 package.json을 확인합니다.

  • "type": "module"
  • exports에서 require 조건이 없음
{
  "name": "some-lib",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.js"
    }
  }
}

이 경우 CommonJS에서 require('some-lib')는 원천적으로 안 됩니다.

응급처치: 당장 깨진 require를 살리는 방법

방법 A) ESM 전용 패키지는 import()로 동적 로딩

CommonJS 파일에서도 import()는 쓸 수 있습니다(비동기).

// 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);

장점

  • 기존 CJS 구조를 크게 바꾸지 않고도 ESM 패키지를 사용 가능

단점

  • 비동기 흐름으로 바뀜
  • 상위 레벨에서 동기 초기화가 필요하면 설계 변경이 필요

방법 B) createRequire로 ESM에서 CJS 로딩

반대로 ESM 코드에서 CJS를 require로 가져와야 할 때가 있습니다.

// index.mjs
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const pkg = require('./legacy-cjs.cjs');

console.log(pkg);

이 패턴은 “레거시 CJS를 당장 못 고치는데, 엔트리는 ESM으로 가야” 할 때 유용합니다.

방법 C) 파일 확장자로 경계를 강제

전환 과정에서 가장 안전한 방법 중 하나는 확장자로 의도를 명확히 하는 것입니다.

  • CJS는 .cjs
  • ESM은 .mjs

예를 들어 프로젝트 전체를 ESM으로 옮기더라도, 레거시 파일은 .cjs로 남겨두면 혼란이 크게 줄어듭니다.

근본 해결: 점진적 ESM 전환 전략

ESM 전환을 한 번에 끝내려 하면, 모듈 해석 규칙과 도구체인 변경이 한꺼번에 터져서 디버깅 비용이 급증합니다. 다음 순서가 실무에서 안전합니다.

1단계) 엔트리 포인트부터 ESM으로 전환

가장 바깥(실행 파일)부터 ESM으로 바꾸고, 내부는 CJS를 유지하는 방식입니다.

  • package.json은 일단 그대로
  • 실행 파일만 .mjs로 시작
  • 내부 레거시는 createRequire 또는 CJS 유지
// server.mjs
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);

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

이렇게 하면 ESM 전용 의존성은 ESM에서 import로 자연스럽게 쓰고, 내부 레거시는 천천히 정리할 수 있습니다.

2단계) 내부 모듈을 “변경 비용 낮은 것부터” ESM으로 이동

우선순위 추천

  • 유틸/헬퍼 모듈
  • 외부 의존성이 적은 모듈
  • 순수 함수 위주 모듈

CJS에서 ESM으로 바꿀 때 가장 자주 막히는 지점은 __dirname, __filename입니다. ESM에서는 다음처럼 대체합니다.

// 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);

3단계) package.jsontype을 도입할지 결정

여기서 선택지가 갈립니다.

  • 선택지 1: "type": "module"로 프로젝트 전체를 ESM 기본으로
  • 선택지 2: type은 건드리지 않고, .mjs/.cjs로 혼용

대규모 레거시가 있으면 선택지 2가 안전합니다. 라이브러리로 배포한다면 선택지 1 + 듀얼 배포 전략을 고려해야 합니다.

라이브러리/패키지라면 듀얼 배포를 고려

내가 만드는 패키지를 다른 프로젝트가 require로도, import로도 쓰게 하려면 exports에 조건부 엔트리를 둡니다.

{
  "name": "my-lib",
  "version": "1.0.0",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  },
  "types": "./dist/index.d.ts"
}

빌드는 보통 두 번 냅니다.

  • ESM 산출물: dist/index.js
  • CJS 산출물: dist/index.cjs

TypeScript를 쓴다면 tsconfig를 2개로 나누거나, 빌드 도구(tsup, rollup 등)로 듀얼 출력하는 방식이 흔합니다.

TypeScript 사용 시 흔한 함정과 권장 설정

Node.js 22 환경에서 TS를 쓴다면, 런타임 모듈 해석과 TS의 모듈 해석을 맞추는 게 핵심입니다.

ESM 목표라면 modulemoduleResolution을 NodeNext로

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

이 설정은 exports 조건부 해석과 확장자 규칙 등 Node의 현실을 TS가 최대한 따라가게 합니다.

추가로 타입 안전성 옵션을 올리다 보면 예상치 못한 인덱싱 에러가 늘어날 수 있는데, 이건 모듈 전환과 별개로 코드 정리가 필요합니다. 관련해서는 TS 5.5 noUncheckedIndexedAccess 에러 해결 가이드도 함께 참고하면 좋습니다.

실전 마이그레이션 예시: require 기반 CLI를 ESM으로

기존(CJS)

// cli.cjs
const fs = require('node:fs');
const path = require('node:path');
const kleur = require('kleur');

function run() {
  const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
  console.log(kleur.green(pkg.name));
}

run();

여기서 kleur가 ESM 전용으로 바뀌면 ERR_REQUIRE_ESM이 납니다.

전환(ESM)

// cli.mjs
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import kleur from 'kleur';

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

function run() {
  const pkgPath = path.join(process.cwd(), 'package.json');
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
  console.log(kleur.green(pkg.name));
}

run();

포인트

  • 엔트리만 .mjs로 바꿔도 효과가 큼
  • ESM 전용 의존성은 이제 자연스럽게 import 가능

운영/CI에서 터질 때의 디버깅 팁

1) Node 실행 커맨드와 파일 확장자부터 확인

CI는 로컬과 다르게 node dist/index.js만 실행하고, type이나 확장자에 따라 ESM/CJS가 바뀌어 버릴 수 있습니다.

  • 산출물이 ESM이면 .mjs 또는 type 정합성이 필요
  • 산출물이 CJS이면 .cjs가 안전

2) 의존성 락 파일과 Node 버전을 고정

Node 22로 올리면서 동시에 의존성도 같이 업데이트하면, “Node 문제”인지 “의존성 ESM 전환”인지 분리가 어렵습니다.

  • Node 버전 고정 후 의존성 업데이트
  • 또는 의존성 고정 후 Node만 업데이트

원인 분리만 잘 해도 해결 시간이 크게 줄어듭니다. CI 캐시가 꼬여서 다른 버전이 섞이는 경우도 많으니, 캐시 이슈가 의심되면 GitHub Actions 캐시가 안먹을 때 9가지 원인 체크리스트로 함께 점검하는 것을 권합니다.

결론: Node.js 22에서 안전한 선택은 “점진적 ESM”

Node.js 22 자체가 require를 갑자기 없애는 것은 아니지만, 생태계가 ESM을 기본값으로 밀어붙이는 흐름 속에서 CommonJS만으로는 점점 더 자주 막힙니다. 실무적으로는 다음이 가장 비용 대비 효과가 좋습니다.

  • 당장 깨진 건 CJS에서 import()로 응급처치
  • 엔트리 포인트부터 ESM으로 바꾸고 레거시는 .cjs로 격리
  • TS를 쓴다면 NodeNext 계열로 해석 규칙을 런타임과 맞춤
  • 라이브러리라면 exports 조건부로 듀얼 배포 고려

이 순서로 가면 “한 번에 다 바꾸다 폭발”을 피하면서도, ESM 전용 의존성 증가라는 현실에 안정적으로 대응할 수 있습니다.