- Published on
Node.js ESM에서 require is not defined 오류 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 CLI를 ESM(ECMAScript Modules)로 전환한 뒤 갑자기 ReferenceError: require is not defined를 만나면 당황하기 쉽습니다. CommonJS(CJS)에서는 당연히 존재하던 require()가 ESM에서는 기본 제공되지 않기 때문입니다. 문제는 단순히 “require를 import로 바꿔라” 수준에서 끝나지 않습니다.
- 패키지 설정(
"type": "module") 때문에 파일이 ESM으로 해석되었는지 - 번들러(Vite/Webpack/tsup)가 브라우저 타깃으로 번들하며
require를 제거했는지 - Node에서 ESM으로 실행 중인데 CJS 습관(
__dirname,require.resolve)을 그대로 썼는지
이 글에서는 오류가 발생하는 전형적인 패턴을 분류하고, 각 상황에서 가장 안전한 수정 방법을 코드로 제시합니다.
> ESM 전환 과정에서 함께 자주 마주치는 ERR_REQUIRE_ESM 이슈는 별도 글로 더 깊게 다뤘습니다: Node.js ESM 전환 시 ERR_REQUIRE_ESM 해결 가이드
1) 오류의 본질: ESM에는 require가 없다
Node.js에서 ESM 파일은 아래 조건 중 하나만 만족해도 ESM으로 해석됩니다.
package.json에"type": "module"이 있다- 파일 확장자가
.mjs다 - (반대로)
.cjs는 강제로 CommonJS
ESM은 import/export를 사용하며, CJS 전용 전역인 require, module, exports, __filename, __dirname을 제공하지 않습니다. 따라서 ESM 파일에서 다음을 실행하면 바로 터집니다.
// index.js (ESM으로 해석되는 경우)
const fs = require('fs');
// ReferenceError: require is not defined
빠른 체크리스트
node -p "process.versions.node"로 Node 버전 확인(가능하면 18+ 권장)- 해당 파일이 ESM으로 해석되는지 확인
package.json의type- 확장자
.mjs/.cjs
- 실행 명령 확인
node index.js인지,ts-node/tsx/번들된 산출물인지
2) 가장 정석적인 해결: require를 import로 교체
가장 좋은 해결은 “ESM 파일은 ESM답게 작성”하는 것입니다.
(1) 내장 모듈/일반 패키지
// before (CJS)
const fs = require('node:fs');
const path = require('node:path');
const express = require('express');
// after (ESM)
import fs from 'node:fs';
import path from 'node:path';
import express from 'express';
(2) named export / default export 차이 주의
CJS 패키지를 ESM에서 import할 때는 export 형태가 달라 헷갈릴 수 있습니다.
// lodash는 보통 default import로 동작
import _ from 'lodash';
// 어떤 라이브러리는 named import가 필요할 수 있음
import { nanoid } from 'nanoid';
문제가 생기면 다음을 점검하세요.
- 라이브러리의 README가 ESM 예제를 제공하는지
exports필드(패키지의 conditional exports)가 ESM/CJS를 어떻게 나누는지
3) “어쩔 수 없이 require가 필요”할 때: createRequire
레거시 코드나 특정 라이브러리(플러그인 로딩, JSON/네이티브 바인딩, CJS-only 모듈)를 당장 전부 ESM으로 바꾸기 어렵다면, ESM에서 require를 “만들어” 쓸 수 있습니다.
// index.mjs 또는 type:module 환경
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const pkg = require('./package.json');
const cjsOnly = require('some-cjs-only-lib');
console.log(pkg.name);
언제 createRequire가 적합한가?
- 점진적 마이그레이션(전체 리팩터링 전 임시 브릿지)
- 플러그인/설정 파일을 CJS로 유지해야 하는 경우
require.resolve()기반 로딩이 필요한 경우
주의점
- 남발하면 코드베이스가 ESM/CJS 혼종이 되어 유지보수성이 떨어집니다.
- “일단 돌아가게”는 가능하지만, 장기적으로는 import 기반으로 정리하는 게 좋습니다.
4) 동적 로딩은 import()로 바꾼다
CJS에서는 조건부 로딩을 이렇게 했습니다.
// CJS
let mod;
if (process.platform === 'win32') {
mod = require('./win.js');
} else {
mod = require('./unix.js');
}
ESM에서는 동적 import()로 바꾸는 게 자연스럽습니다.
// ESM
let mod;
if (process.platform === 'win32') {
mod = await import('./win.js');
} else {
mod = await import('./unix.js');
}
mod.default?.();
여기서 중요한 포인트:
import()는 Promise를 반환합니다.- top-level await를 쓰려면 Node 버전과 실행 환경이 이를 지원해야 합니다(현대 Node에서는 대체로 OK).
5) __dirname/__filename 때문에 require를 쓰는 경우의 대체
실무에서 require is not defined가 나는 지점이 __dirname을 만들기 위해 path+require를 끼워 넣은 코드인 경우가 많습니다. ESM에서는 import.meta.url을 사용합니다.
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
console.log(__dirname);
정적 파일 로딩/템플릿 경로 계산/설정 파일 탐색 등 대부분의 케이스가 이 패턴으로 해결됩니다.
6) TypeScript에서 자주 터지는 케이스: 컴파일 결과가 ESM인데 코드가 CJS
TypeScript는 설정에 따라 출력이 ESM이 될 수도, CJS가 될 수도 있습니다. 다음 조합에서 사고가 많이 납니다.
tsconfig.json에서"module": "ESNext"또는"NodeNext"package.json에"type": "module"- 그런데 코드에
require()가 남아 있음
권장 tsconfig(서버/Node 기준 예시)
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"esModuleInterop": true,
"resolveJsonModule": true,
"strict": true
}
}
module/moduleResolution을NodeNext로 맞추면 ESM/CJS 경계에서 예측 가능성이 좋아집니다.- JSON을 import해야 하면
resolveJsonModule을 켜고, 가능하면import xxx from './a.json' assert { type: 'json' }또는 런타임 로딩 방식을 프로젝트 정책에 맞게 정하세요(Node 버전/툴체인에 따라 다름).
7) 번들러/프론트 환경에서의 require is not defined
Node가 아니라 브라우저 번들에서 이 오류가 나는 경우도 많습니다. 이때의 의미는 “브라우저에는 require가 없다”입니다.
전형적인 상황:
- Vite/Next.js/Remix 같은 환경에서 서버 전용 코드를 클라이언트 번들로 끌고 감
- 라이브러리가 Node 내장 모듈(
fs,path)을 참조
해결 방향
- 해당 코드를 서버 전용으로 격리
- 클라이언트에서 필요하면 API로 우회
- 번들러 설정으로 polyfill을 넣는 방식은 최후의 수단(요즘은 보안/용량 이슈로 권장되지 않음)
예: Next.js에서 클라이언트 컴포넌트에 서버 코드를 import해버린 경우
- 서버 전용 로직은
app/api/*라우트나 server action으로 이동 - 클라이언트는 fetch로 호출
이런 “환경 경계” 문제는 ESM 이슈와 결이 비슷합니다. 장애 대응 관점에서는 재시도/폴백처럼 계층을 나누는 습관이 도움이 되는데, API 호출 안정화 패턴은 다음 글도 참고할 만합니다: OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커
8) 상황별 처방전(결론만 빠르게)
A. Node ESM 파일에서 require를 썼다
- 최선:
import로 교체 - 임시:
createRequire(import.meta.url)
B. 조건부 로딩/런타임 로딩이 필요하다
require()→await import()
C. 경로 계산 때문에 require를 끌어왔다
__dirname대체:fileURLToPath(import.meta.url)
D. TS/빌드 산출물이 ESM인데 코드가 CJS다
tsconfig를NodeNext로 정렬- 남은
require제거 또는createRequire로 브릿지
E. 브라우저 번들에서 require가 터진다
- 서버 코드가 클라이언트로 섞였는지 확인
- Node 내장 모듈 의존성 제거/격리
9) 실전 예제: ESM 기반 CLI에서 레거시 CJS 플러그인 로딩
ESM으로 CLI를 작성하되, 플러그인은 CJS로 유지해야 하는 현실적인 케이스를 예로 들겠습니다.
// cli.mjs
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// 사용자가 만든 CJS 플러그인 (예: plugins/foo.cjs)
const pluginPath = path.join(__dirname, 'plugins', 'foo.cjs');
const plugin = require(pluginPath);
// CJS export 형태에 따라 default 처리
const run = plugin.default ?? plugin;
await run({ argv: process.argv.slice(2) });
이 방식은 ESM의 장점(정적 import, top-level await 등)을 유지하면서도, 레거시 생태계를 현실적으로 수용합니다.
마무리
require is not defined는 “Node가 이상하다”가 아니라 “지금 이 파일/번들이 ESM(또는 브라우저) 컨텍스트로 실행되고 있다”는 신호입니다.
- 가능하면
import로 정리하고 - 불가피하면
createRequire로 브릿지를 놓고 - 경로/동적 로딩/환경 경계를 ESM 방식으로 재구성하면
대부분의 프로젝트에서 깔끔하게 해결됩니다. 다음 단계로 ESM 전환 중 다른 대표 오류(ERR_REQUIRE_ESM)까지 같이 정리하고 싶다면 위 내부 링크 글을 함께 읽어보는 것을 권합니다.