- Published on
Node.js ESM에서 require 에러 해결 8가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Node.js에서 ESM(ECMAScript Modules)을 쓰기 시작하면 가장 흔하게 맞닥뜨리는 문제가 require 계열 에러입니다. 대표적으로 ReferenceError: require is not defined in ES module scope 같은 메시지가 나오거나, 반대로 CommonJS에서 ESM 패키지를 require() 하다가 ERR_REQUIRE_ESM가 터지기도 합니다.
이 글은 “왜 이런 에러가 나는지”를 먼저 구조적으로 정리하고, 팀/레포에서 바로 적용 가능한 해결책 8가지를 체크리스트처럼 제공합니다.
관련해서 단일 케이스를 빠르게 해결하고 싶다면 아래 글도 함께 참고하세요.
먼저, 에러를 3가지로 분류하기
require 문제는 보통 아래 3종류로 나뉩니다.
- ESM 파일 안에서
require를 직접 호출함 - CommonJS 파일에서 ESM 전용 패키지를
require()로 로딩함 - 번들러/트랜스파일 결과물에서 모듈 타입이 뒤섞여 런타임이 오판함
각각 해결책이 다르므로, 로그의 키워드를 먼저 확인하세요.
require is not defined in ES module scope
ESM에서require를 쓰고 있음Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported
CommonJS에서 ESM 패키지를require()로 불러옴Cannot use import statement outside a module
ESM로 실행돼야 하는데 CommonJS로 실행 중(또는 반대)
해결 1) ESM에서는 import로 바꾸기(정공법)
가장 좋은 해결은 require를 없애고 ESM 문법으로 통일하는 것입니다.
변경 예시
// before (CommonJS)
const fs = require('node:fs');
const path = require('node:path');
// after (ESM)
import fs from 'node:fs';
import path from 'node:path';
라이브러리에 따라 default export 여부가 달라 import x from 대신 import * as x from가 필요한 경우도 있습니다.
// 일부 CJS 패키지는 namespace import가 안전
import * as lodash from 'lodash';
핵심은 “ESM 파일에서는 ESM 방식으로만 의존성을 선언”하는 것입니다.
해결 2) JSON 로딩은 assert 또는 createRequire로 처리
ESM에서 JSON을 다루다 보면 require('./config.json')을 그대로 쓰고 싶어집니다. 하지만 ESM에서는 기본적으로 require가 없고, JSON import 방식도 제약이 있습니다.
방법 A: JSON 모듈 import(지원 환경 확인)
import config from './config.json' assert { type: 'json' };
console.log(config);
Node.js 버전/플래그/도구체인에 따라 동작이 다를 수 있어, 팀 환경이 섞여 있다면 다음 방법이 더 실용적입니다.
방법 B: createRequire로 필요한 곳에만 require 주입
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const config = require('./config.json');
console.log(config);
이 방식은 “ESM 파일이지만 특정 자원(JSON, CJS 전용 모듈)을 불러오기 위해 require를 국소적으로 허용”한다는 점에서 현실적인 타협안입니다.
해결 3) __dirname, __filename 대체(경로 문제로 연쇄 에러 방지)
ESM으로 전환할 때 require 에러와 함께 자주 터지는 게 __dirname/__filename 부재입니다. 이걸 고치지 않으면 “경로 계산을 위해 require('path')를 억지로 쓰다가” 문제를 더 키우기도 합니다.
ESM에서는 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);
이 패턴을 유틸로 만들어두면 ESM 마이그레이션이 훨씬 수월해집니다.
해결 4) CommonJS에서 ESM 패키지를 써야 한다면 import() 동적 로딩
ERR_REQUIRE_ESM는 “ESM 전용 패키지를 CommonJS에서 require()로 불렀다”는 뜻입니다. 이때는 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);
포인트는 import()는 CommonJS에서도 동작하며 Promise를 반환한다는 점입니다. 따라서 호출부를 async로 감싸거나 top-level에서 Promise 체인을 만들어야 합니다.
해결 5) 파일 확장자와 type을 명확히: .mjs/.cjs 전략
프로젝트에서 가장 위험한 상태는 “이 파일이 ESM인지 CJS인지 사람이 봐도 애매한 상태”입니다. Node.js는 package.json의 type과 확장자로 모듈 타입을 결정합니다.
권장 전략
- ESM 파일은
.mjs또는type: 'module'환경의.js - CommonJS 파일은
.cjs로 고정
예를 들어 레거시 스크립트만 CJS로 남겨야 한다면:
{
"type": "module",
"scripts": {
"start": "node src/index.js",
"legacy": "node src/legacy.cjs"
}
}
이렇게 하면 ESM 기본 프로젝트에서도 특정 파일은 확실하게 CJS로 실행되어 require를 정상 사용합니다.
해결 6) 패키지 배포자라면 exports로 ESM/CJS 듀얼 제공
라이브러리(사내 패키지 포함)를 배포하는 입장이라면, 소비자 앱이 ESM인지 CJS인지에 따라 진입점을 다르게 제공해야 require 문제가 줄어듭니다.
{
"name": "my-lib",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
import경로는 ESM 빌드require경로는 CJS 빌드
이 구성을 하면 소비자는 ESM이면 import로, CJS면 require()로 자연스럽게 로딩됩니다.
해결 7) TypeScript 사용 시 module/moduleResolution을 ESM에 맞추기
TypeScript 프로젝트에서 “소스는 ESM 의도인데 컴파일 결과가 CJS로 떨어져 런타임에서 require가 튀어나오는” 케이스가 흔합니다.
ESM 지향 설정 예시
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"esModuleInterop": true,
"resolveJsonModule": true
}
}
module을NodeNext로 두면 확장자/type규칙을 Node.js와 비슷하게 따라갑니다.esModuleInterop는 CJS 패키지 import 호환을 완화합니다.
또한 ts-node 같은 런타임 실행 도구를 쓴다면, 실행 옵션이 ESM을 제대로 켰는지도 확인해야 합니다.
해결 8) 테스트/번들러 환경에서 모듈 타입을 강제로 맞추기
Jest, Vitest, Babel, ts-jest, webpack, esbuild 같은 도구체인에서는 “Node 런타임 규칙”과 “도구가 해석하는 규칙”이 어긋나 require 에러가 발생할 수 있습니다.
대표 증상
- 앱은 잘 뜨는데 테스트에서만
require is not defined가 발생 - 번들된 결과물에서
require가 남아 브라우저/런타임에서 터짐
대응 방향(도구별로 체크)
- 테스트 러너가 ESM을 지원하는 모드인지 확인
- 변환 결과물에
require()가 남지 않도록 번들 타깃을 조정 - 필요하면 테스트 전용으로 CJS 진입점(
.cjs)을 제공
예를 들어 Vitest에서는 Node 환경 ESM을 자연스럽게 지원하지만, Jest는 설정 난이도가 더 높습니다. “테스트만 CJS로 유지”하는 전략이 비용 대비 효과가 좋은 경우도 많습니다.
실전 체크리스트: 내 프로젝트는 어디에 해당하나
아래 순서대로 보면 대부분 10분 안에 원인 좁히기가 됩니다.
- 에러 메시지에
ERR_REQUIRE_ESM가 있는가- 있으면 CommonJS에서 ESM을
require()함 - 해결 4 또는 해결 6
- 있으면 CommonJS에서 ESM을
require is not defined인가- ESM 파일에서
require를 호출함 - 해결 1 또는 해결 2
- ESM 파일에서
- 파일 확장자
.js만 쓰고type이 애매한가- 해결 5
- TS 컴파일 결과가 의도와 다르게 나오나
- 해결 7
- 테스트/빌드에서만 재현되나
- 해결 8
마무리: “혼용을 통제”하면 require 에러는 사라진다
ESM 전환에서 require 에러가 반복되는 근본 원인은 “혼용 자체”가 아니라 “혼용이 무질서하게 일어나는 것”입니다. 즉,
- ESM은 ESM으로 통일(해결 1)
- 불가피한 곳에만
createRequire또는import()로 다리 놓기(해결 2, 4) - 경계는 확장자와
exports로 명확히 선언(해결 5, 6) - TS/테스트 도구까지 같은 규칙으로 정렬(해결 7, 8)
이 4가지만 지켜도 require 관련 이슈는 대부분 재발하지 않습니다.
추가로 ESM에서 require가 사라지는 대표 케이스를 더 빠르게 확인하려면 아래 글을 함께 보세요.