- Published on
Node.js ESM 전환 시 ERR_REQUIRE_ESM 해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드 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로 해석되고 있다”는 점입니다. 따라서 해결은 크게 두 갈래입니다.
- 호출 측을 ESM으로 바꾸거나
- 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인데 런타임에서만 터짐
tsconfig의module/moduleResolution을NodeNext로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)를 역추적하면 대부분 빠르게 정리됩니다.