- Published on
Node.js 22에서 require가 깨질 때 ESM 전환
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 런타임을 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.json의exports가 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.json에 type을 도입할지 결정
여기서 선택지가 갈립니다.
- 선택지 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 목표라면 module과 moduleResolution을 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 전용 의존성 증가라는 현실에 안정적으로 대응할 수 있습니다.