- Published on
Node.js ESM에서 require 오류 5분 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버를 Node.js로 운영하다가 어느 날부터 갑자기 require is not defined 혹은 Error [ERR_REQUIRE_ESM]가 터지면, 대부분 프로젝트가 ESM(ECMAScript Modules)로 해석되고 있는데 CommonJS 방식(require)을 섞어 쓴 케이스입니다. 특히 Node 18+에서 ESM 사용이 늘고, Node 20/22로 업그레이드하면서 "type": "module"을 켜는 순간 이런 문제가 폭발적으로 나타납니다.
이 글은 “왜 터졌는지”를 길게 설명하기보다, 5분 안에 고치는 선택지를 상황별로 제시합니다. (원인 파악 → 가장 안전한 처방 → 예외 케이스 순)
관련해서 Node 22에서의 전환 포인트가 궁금하면 이 글도 함께 보세요: Node 22에서 require가 안 될 때 ESM 전환법
1) 지금 겪는 오류 메시지로 원인 10초 진단
ESM에서 require 관련 오류는 크게 3종입니다.
1-1. ReferenceError: require is not defined in ES module scope
- 현재 파일이 ESM으로 실행되고 있고, 그 파일 안에서
require()를 호출함 - 흔한 트리거
package.json에"type": "module"- 파일 확장자가
.mjs
1-2. Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported
- 호출하는 쪽은 CommonJS(
require)인데, 불러오려는 대상이 ESM 전용 - 흔한 트리거
- 의존성이 ESM-only로 바뀜(대표적으로 일부 라이브러리 최신 버전)
exports필드에서import만 제공
1-3. Cannot use import statement outside a module
- 반대로 CommonJS로 실행 중인데 ESM 문법(import)을 사용
- 흔한 트리거
type이 없거나commonjs.cjs파일에서import사용
이 글은 주제상 1-1/1-2(ESM에서 require 문제)에 집중합니다.
2) 5분 해결법 A: ESM에서는 import로 바꿔라(가장 정석)
가장 깔끔한 해결은 require를 제거하고 ESM 문법으로 통일하는 겁니다.
2-1. 기본 변환
// before (CommonJS)
const fs = require('node:fs');
const path = require('node:path');
// after (ESM)
import fs from 'node:fs';
import path from 'node:path';
Node 내장 모듈은 대체로 위처럼 됩니다.
2-2. __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);
2-3. JSON을 require 없이 읽기
Node 버전에 따라 JSON import 방식이 다릅니다. 가장 호환성 좋은 방식은 fs로 읽는 것입니다.
import fs from 'node:fs/promises';
const raw = await fs.readFile(new URL('./config.json', import.meta.url), 'utf-8');
const config = JSON.parse(raw);
(최신 Node에서는 JSON import assertion도 가능하지만, 런타임/툴체인 차이로 삽질이 생길 수 있어 “5분 해결” 관점에선 위가 안전합니다.)
3) 5분 해결법 B: ESM에서 꼭 require가 필요하면 createRequire
레거시 코드가 크거나, 특정 라이브러리가 문서상 require() 사용을 전제하는 경우가 있습니다. 이때 ESM 파일에서 require를 “만들어” 쓰면 됩니다.
// ESM file (e.g. index.js with "type":"module" or .mjs)
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const legacy = require('some-commonjs-only-lib');
console.log(legacy);
언제 이 방식이 정답인가?
- 당장 전체를 ESM으로 리팩터링할 시간이 없음
- 특정 모듈만 CommonJS로 남겨야 함
- 테스트/스크립트 유틸에서 빠르게 우회하고 싶음
주의점
require()로 ESM-only 패키지를 불러오면 여전히ERR_REQUIRE_ESM이 납니다. 이 경우는 아래 “동적 import”로 가야 합니다.
4) 5분 해결법 C: ERR_REQUIRE_ESM이면 동적 import()로 우회
오류가 이런 형태라면:
Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported
즉, 불러오려는 대상이 ESM입니다. CommonJS에서든 ESM에서든, require()로는 못 가져옵니다. 가장 빠른 처방은 동적 import입니다.
4-1. CommonJS 파일에서 ESM 모듈을 불러오기
// CommonJS file (.cjs or type: commonjs)
(async () => {
const { default: got } = await import('got');
const res = await got('https://example.com');
console.log(res.statusCode);
})();
대부분의 ESM 패키지는 default export를 사용하므로 default를 꺼내는 패턴이 자주 필요합니다.
4-2. require 기반 코드 흐름을 유지하고 싶다면 래퍼 모듈을 하나 둔다
// got-wrapper.mjs (ESM)
import got from 'got';
export default got;
// legacy.cjs (CommonJS)
(async () => {
const { default: got } = await import('./got-wrapper.mjs');
console.log((await got('https://example.com')).statusCode);
})();
이렇게 하면 “ESM-only 의존성”을 한 곳에 격리할 수 있어, 마이그레이션 중간 단계에서 특히 유용합니다.
5) 5분 해결법 D: 파일 확장자 2개로 혼용하기(.mjs/.cjs)
프로젝트는 ESM인데 특정 파일만 CommonJS로 유지하고 싶다면, 확장자로 강제할 수 있습니다.
- ESM:
.mjs - CommonJS:
.cjs
예를 들어, package.json이 "type": "module"이라서 기본이 ESM인 상황에서:
// scripts/legacy-task.cjs
const dotenv = require('dotenv');
dotenv.config();
console.log('loaded env');
이 파일은 .cjs이므로 require가 정상 동작합니다.
주의
- ESM 파일에서
.cjs를import하면 “default export 형태” 등 상호운용 규칙이 적용됩니다. - 반대로
.cjs에서.mjs를require하면 100%ERR_REQUIRE_ESM가 납니다(동적 import 필요).
6) 5분 해결법 E: package.json의 type과 엔트리포인트를 재점검
의외로 “코드는 멀쩡한데 실행만 하면 터지는” 케이스는 설정이 원인입니다.
6-1. "type": "module"의 영향
{
"name": "my-app",
"type": "module"
}
.js가 전부 ESM으로 해석됩니다.- 기존 CommonJS 코드를 그대로 두면
require is not defined가 바로 납니다.
6-2. 패키지를 배포하는 라이브러리라면 exports도 확인
라이브러리(패키지)를 만드는 입장이라면 소비자가 어떤 방식으로 로드하는지에 따라 exports를 분기해야 합니다.
{
"name": "my-lib",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
이 구성이 없으면 CommonJS 사용자가 require('my-lib') 했을 때 바로 깨질 수 있습니다.
7) TypeScript를 쓰는 경우: tsconfig 조합이 require 문제를 키운다
TypeScript에서 ESM 전환 중에는 “컴파일 결과가 ESM인지 CJS인지”가 핵심입니다.
7-1. NodeNext 조합(ESM 친화)
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"outDir": "dist"
}
}
- ESM/CJS를 확장자와
package.json규칙에 맞춰 해석합니다. - 레거시 require 코드를 섞을 때는
.cts/.mts같은 확장자 전략이 필요할 수 있습니다.
7-2. 가장 흔한 함정: TS는 import로 썼는데 런타임은 CJS로 떨어짐
- 번들러/빌드 설정이
module: commonjs로 되어 있으면 결과물이 CJS가 됩니다. - 이때 ESM-only 패키지를 가져오면 런타임에서
ERR_REQUIRE_ESM가 발생할 수 있습니다.
타입 추론/설정 이슈로 디버깅이 길어질 때는 이 글도 참고가 됩니다(문제 접근 방식이 비슷합니다): TypeScript 5.5 noImplicitAny 폭탄 - inferred type 디버깅
8) 실전 “5분 체크리스트” (가장 빠른 순서)
- 오류 메시지부터 분류
require is not defined→ 지금 파일이 ESMERR_REQUIRE_ESM→ require로 ESM 모듈을 로드 중
package.json의"type"확인- ESM 파일이면 우선
import로 바꿀 수 있는지 판단 - 당장 못 바꾸면
createRequire(import.meta.url)로 임시 봉합 ERR_REQUIRE_ESM이면await import()로 로딩 방식 전환- 혼용이 필요하면
.cjs/.mjs로 경계 명확히
9) 자주 묻는 Q&A
Q1. ESM 프로젝트에서 require('dotenv').config()를 가장 빨리 살리는 방법은?
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
require('dotenv').config();
또는 dotenv가 ESM을 지원한다면:
import 'dotenv/config';
Q2. Jest/테스트에서만 require가 터진다
- 테스트 러너가 CommonJS 기준으로 실행하거나, 반대로 ESM으로 실행하는데 설정이 섞였을 가능성이 큽니다.
- 우선 테스트 파일 확장자를
.cjs로 고정하거나, 러너의 ESM 옵션을 명시하세요.
Q3. “그냥 type을 commonjs로 되돌리면” 끝 아닌가?
- 단기적으로는 끝납니다.
- 하지만 의존성들이 ESM-only로 이동 중이라, 결국 다른 곳에서
ERR_REQUIRE_ESM를 다시 만나게 됩니다. - 따라서 핵심 엔트리포인트만이라도 ESM로 정리해두면 장기적으로 덜 아픕니다.
마무리
ESM에서 require 오류는 복잡해 보이지만, 실제로는 모듈 시스템 경계가 흐려졌을 때 생기는 일관된 증상입니다. 5분 안에 해결하려면 “정석(import로 통일) → 임시(createRequire) → 불가피(동적 import) → 경계(.cjs/.mjs)” 순으로 접근하면 됩니다.
특히 Node 22 업그레이드 이후 require 이슈가 본격화되는 경우가 많으니, 더 큰 마이그레이션 관점은 다음 글에서 정리한 체크포인트도 함께 보세요: Node 22에서 require가 안 될 때 ESM 전환법