- Published on
Node.js ESM 전환 시 ERR_REQUIRE_ESM 해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드 Node.js 프로젝트를 ESM으로 전환할 때 가장 흔하게 마주치는 에러가 ERR_REQUIRE_ESM 입니다. 보통은 "ESM 모듈을 require()로 불러오려 했다"는 의미지만, 실제 현장에서는 의존성 트리 어딘가에서 CommonJS와 ESM이 섞이면서 예상치 못한 지점에서 폭발합니다.
이 글에서는 ERR_REQUIRE_ESM이 왜 발생하는지, 어디서부터 확인해야 하는지, 그리고 전환 전략을 어떻게 잡아야 재발을 줄일 수 있는지 정리합니다.
ERR_REQUIRE_ESM이 의미하는 것
Node.js는 모듈을 크게 CommonJS(CJS)와 ECMAScript Modules(ESM)로 나눠 로딩합니다.
- CJS는
require()와module.exports기반 - ESM은
import와export기반
ERR_REQUIRE_ESM은 대부분 아래 상황에서 발생합니다.
- ESM 전용 패키지(또는 ESM으로 배포되는 엔트리)를 CJS 코드에서
require()로 로딩 - 프로젝트 자체는 ESM인데, 중간에 CJS 파일이 섞여서 ESM을
require()로 당김 - 번들러나 테스트 런너가 파일을 CJS로 해석한 뒤 ESM을
require()로 실행
에러 메시지에는 보통 다음과 같은 힌트가 포함됩니다.
Must use import to load ES Module- 어떤 파일이
require()를 호출했는지 - 어떤 패키지의 어떤 엔트리가 ESM인지
핵심은 "누가 ESM을 require() 했는가"를 찾는 것입니다.
1단계: 내 프로젝트가 ESM인지부터 확정하기
프로젝트가 ESM인지 여부는 대체로 package.json의 type으로 결정됩니다.
"type": "module"이면.js는 ESM으로 해석"type": "commonjs"(또는 미설정) 이면.js는 CJS로 해석
ESM으로 전환하는 기본 설정
package.json
{
"name": "my-app",
"type": "module",
"main": "./dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
}
}
여기서 중요한 점은 type을 켜는 순간, 기존 .js 파일이 전부 ESM으로 해석되기 시작한다는 것입니다. 즉, 예전에 동작하던 require()가 갑자기 문법 에러가 되거나, 반대로 CJS로 남아있어야 하는 파일이 ESM으로 읽히며 런타임이 꼬일 수 있습니다.
점진 전환을 위한 .cjs와 .mjs
전환 초기에 가장 안전한 방법은 확장자를 분리하는 것입니다.
- CJS 파일은
.cjs - ESM 파일은
.mjs
type을 바로 바꾸기 부담스럽다면, type은 유지하면서 특정 파일만 .mjs로 옮겨 ESM을 테스트할 수 있습니다.
2단계: 가장 흔한 원인 3가지와 해결
1) ESM 패키지를 CJS에서 require()로 불러옴
예를 들어 어떤 의존성이 ESM 전용으로 배포되면, 아래 코드가 깨집니다.
// CommonJS 파일
const chalk = require('chalk');
해결 옵션은 3가지입니다.
해결 A: 호출 측을 ESM으로 바꾸고 import 사용
import chalk from 'chalk';
console.log(chalk.green('ok'));
프로젝트 전환의 정석이지만, 영향 범위가 큽니다.
해결 B: 동적 import()로 브릿지
CJS를 당장 못 바꿀 때 현실적인 우회로입니다.
// CommonJS 파일에서도 동적 import는 가능
async function main() {
const chalk = (await import('chalk')).default;
console.log(chalk.green('ok'));
}
main().catch(console.error);
주의할 점은 import()가 비동기이므로, 초기화 순서가 중요한 코드에서는 구조 변경이 필요할 수 있습니다.
해결 C: 패키지의 CJS 엔트리를 명시적으로 사용
일부 패키지는 exports에 CJS 빌드를 같이 제공합니다. 이 경우 문서에 나온 CJS 경로를 사용하면 됩니다. 다만 최근 패키지들은 CJS를 아예 제거하는 추세라 장기적으로는 A 또는 B가 더 안전합니다.
2) 프로젝트는 ESM인데 내부에 CJS 파일이 섞여 require()가 남아있음
"type": "module" 이후에도 예전 파일이 그대로 남아있으면, 다음과 같은 상황이 생깁니다.
- 파일은 ESM으로 해석되는데, 코드에는
require()가 존재 - 또는 반대로
.cjs로 분리된 파일이 ESM 패키지를require()로 로딩
해결은 "경계를 명확히" 하는 것입니다.
- ESM 영역에서는
import만 사용 - CJS 영역은
.cjs로 고정하고, ESM 의존성은 동적import()로만 연결
예시로, 레거시 설정 파일을 .cjs로 유지하면서 ESM 라이브러리를 쓰는 경우:
// config/legacy-loader.cjs
module.exports = async function load() {
const mod = await import('../dist/esm-only-module.js');
return mod.createConfig();
};
3) TypeScript 출력이 CJS인데 런타임은 ESM으로 실행
TypeScript를 쓰면 tsconfig.json 설정이 핵심입니다. 흔한 사고는 다음입니다.
package.json은"type": "module"- 그런데
tsconfig.json의module이CommonJS - 결과적으로
dist에는require()가 남아 있고, 런타임은 ESM으로 실행하며 충돌
ESM용 tsconfig 기본 예시
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
module과moduleResolution을NodeNext로 맞추는 것이 포인트입니다.- ESM 환경에서의 확장자,
exports해석, 타입 해석이 Node에 가깝게 동작합니다.
ESM 전환 시 자주 놓치는 디테일
ESM에서는 상대 경로 import에 확장자가 필요할 수 있음
번들러 없이 Node.js에서 직접 실행한다면, ESM은 상대 경로에 확장자를 요구하는 경우가 많습니다.
// 잘못된 예
import { foo } from './foo';
// 권장
import { foo } from './foo.js';
TypeScript를 쓴다면 소스는 ./foo.js로 쓰고, TS가 타입만 맞춰주는 패턴이 흔합니다. 이 부분이 익숙하지 않으면 전환 초기에 빌드보다 런타임에서 계속 미끄러집니다.
__dirname과 __filename 대체
ESM에는 기본으로 __dirname이 없습니다. 다음처럼 import.meta.url 기반으로 대체합니다.
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
console.log(__dirname);
이 변경은 설정 로더, 템플릿 로더, 정적 파일 경로 처리에서 자주 필요합니다.
Jest, ts-node, nodemon 같은 도구 체인도 같이 점검
애플리케이션 코드만 ESM으로 바꿔도, 테스트나 개발 서버가 CJS로 실행되면 ERR_REQUIRE_ESM이 다시 튀어나옵니다.
- Jest는 ESM 지원이 있지만 설정이 복잡할 수 있음
ts-node는 ESM 모드 실행 옵션이 필요할 수 있음- nodemon은 실행 커맨드가 Node ESM과 일치해야 함
예를 들어 개발 실행을 이렇게 고정하면, "개발은 CJS, 운영은 ESM" 같은 불일치를 줄일 수 있습니다.
{
"scripts": {
"dev": "node --watch dist/index.js",
"start": "node dist/index.js"
}
}
TypeScript를 개발 중에 직접 실행해야 한다면, tsx 같은 도구를 고려하는 것도 방법입니다.
문제를 빨리 추적하는 실전 디버깅 절차
ERR_REQUIRE_ESM은 결국 "어떤 경로에서 ESM을 require()했는가" 싸움입니다. 다음 순서로 접근하면 시간을 줄일 수 있습니다.
- 에러 스택에서 최초의
require()호출 파일 확인 - 그 파일이 CJS인지 ESM인지 확인
- 확장자가
.cjs인지 package.json의type영향권인지
- 확장자가
node_modules의 해당 패키지package.json에서exports와type확인- 호출 측을 ESM으로 바꾸기 어려우면 동적
import()로 브릿지
빌드/배포 파이프라인에서 이 문제가 간헐적으로만 재현된다면, CI가 서로 다른 Node 버전이나 캐시된 산출물을 섞어 쓰는 경우도 있습니다. 이럴 때는 배포 겹침이나 캐시 꼬임을 먼저 잡아 재현성을 높이는 것이 중요합니다.
- 배포가 겹치며 산출물이 섞이는 문제는 GitHub Actions 동시성 꼬임으로 배포 겹침 막기 같은 방식으로 예방할 수 있습니다.
- 빌드 캐시가 잘못된 모듈 산출물을 계속 재사용한다면 Docker 빌드가 느릴 때 BuildKit 캐시 최적화에서 설명하는 캐시 레이어 점검 방식이 도움이 됩니다.
전환 전략: 한 번에 갈아엎기 vs 경계 분리
한 번에 ESM으로 통일
장점
- 장기적으로 가장 단순
- 라이브러리 생태계(ESM 전용 증가)에 대응 쉬움
단점
- 테스트/빌드/런타임/툴링까지 한 번에 손봐야 함
추천 조건
- 코드베이스가 작거나, 모듈 경계가 명확
- 테스트 러너와 빌드 파이프라인을 함께 정비할 여력이 있음
경계 분리로 점진 전환
핵심은 "CJS 섬"을 .cjs로 고정하고, ESM과의 접점에서만 동적 import()를 쓰는 것입니다.
장점
- 리스크가 작고, 전환을 단계적으로 진행 가능
단점
- 브릿지 코드가 늘어나면 복잡도가 올라감
추천 조건
- 레거시가 크고, 당장 전체 리팩터링이 어려움
- 특정 의존성만 ESM 전용이라 급히 우회가 필요
체크리스트: ERR_REQUIRE_ESM 재발 방지
package.json의type을 팀 표준으로 고정했는가- CJS 파일은
.cjs, ESM 파일은.js또는.mjs로 명확히 했는가 - TypeScript라면
tsconfig.json에서module과moduleResolution을NodeNext로 맞췄는가 - 상대 경로 import에 확장자 규칙을 정했는가
- CJS에서 ESM을 불러야 한다면 동적
import()를 표준 패턴으로 문서화했는가 - 테스트/개발 도구가 동일한 모듈 시스템으로 실행되는가
- CI에서 Node 버전이 로컬과 다르지 않은가
마무리
ERR_REQUIRE_ESM은 단순히 require()를 import로 바꾸는 문제로 끝나지 않는 경우가 많습니다. 프로젝트의 모듈 시스템을 무엇으로 정의할지, 경계를 어떻게 나눌지, 그리고 TypeScript와 도구 체인을 어떤 모드로 맞출지까지 한 세트로 정리해야 재발이 줄어듭니다.
전환을 진행 중이라면, 먼저 "내 런타임이 ESM인지"를 확정하고, 그 다음 "CJS 섬"을 .cjs로 격리한 뒤, 접점에서는 동적 import()를 사용해 단계적으로 통일해 나가는 접근이 가장 안전합니다.