- Published on
Node.js ESM에서 require 미정의 에러 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드를 ESM(ECMAScript Modules)로 전환하거나, "type": "module"을 켠 뒤 가장 흔히 마주치는 런타임 에러가 ReferenceError: require is not defined in ES module scope입니다. 이 글에서는 왜 이런 문제가 생기는지부터, 프로젝트 규모에 따라 어떤 해결책을 선택해야 하는지, 그리고 라이브러리/번들러/테스트 환경에서 재발을 막는 설정까지 정리합니다.
특히 Node.js는 CJS(CommonJS)와 ESM을 동시에 지원하지만, 동일한 파일에서 두 모듈 시스템의 전역 API를 섞어 쓰는 것은 허용하지 않습니다. 따라서 “어떻게든 require를 살리는 방법”과 “ESM 방식으로 바꾸는 방법”을 구분해 접근하는 게 핵심입니다.
에러가 발생하는 정확한 조건
다음 중 하나라도 만족하면 해당 파일은 ESM으로 해석될 수 있습니다.
package.json에"type": "module"이 있다- 파일 확장자가
.mjs다 - (반대로)
.cjs는 CJS로 강제된다
ESM으로 해석된 파일에서는 다음이 기본적으로 성립합니다.
require,module,exports,__filename,__dirname같은 CJS 전역이 없다- 대신
import,export,import.meta.url을 사용한다
즉, 아래 코드는 ESM 파일에서 바로 터집니다.
// index.js (ESM으로 해석되는 상황)
const fs = require('fs');
해결책 선택 가이드 (가장 중요한 분기)
아래 질문에 답하면 해결 방향이 빠르게 정해집니다.
- 내 코드가 ESM이어야 하나?
- 이미 ESM으로 마이그레이션 중이거나,
import기반으로 통일하고 싶다: ESM 방식으로 고친다 - 레거시 CJS가 많고, 당장 바꾸기 어렵다: CJS로 되돌리거나 일부만 브리지한다
require가 필요한 이유가 뭔가?
- CJS 전용 패키지를 불러와야 한다
- JSON/네이티브 애드온/조건부 로딩 등
require패턴이 남아있다 __dirname같은 경로 유틸이 필요하다
이제 케이스별로 가장 안전한 해법을 소개합니다.
해법 1) ESM에서는 import로 바꾸기 (정석)
가장 권장되는 방식은 require를 제거하고 ESM 문법으로 바꾸는 것입니다.
// index.js (ESM)
import fs from 'node:fs';
import path from 'node:path';
console.log(fs.readFileSync('package.json', 'utf8'));
Node 내장 모듈은 node:fs처럼 node: 프리픽스를 붙이면 해석이 더 명확하고, 번들러/린터에서도 충돌 가능성이 줄어듭니다.
JSON import는 버전에 따라 주의
Node.js ESM에서 JSON을 import하려면 import assertion이 필요할 수 있습니다.
import pkg from './package.json' assert { type: 'json' };
console.log(pkg.name);
환경에 따라 assertion 지원 여부가 다르거나 트랜스파일러 설정이 필요할 수 있으니, 서버 런타임(Node 버전)과 빌드 파이프라인을 함께 확인하세요.
해법 2) ESM에서 require가 꼭 필요하면 createRequire 사용
ESM 파일에서 CJS 패키지를 불러와야 하는데, 해당 패키지가 ESM import를 제대로 지원하지 않는 경우가 있습니다. 이때는 node:module의 createRequire로 로컬 require를 만들어 사용합니다.
// index.js (ESM)
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const legacy = require('some-commonjs-only-lib');
console.log(legacy);
이 패턴의 장점은 다음과 같습니다.
- ESM 컨텍스트를 유지하면서 필요한 곳에서만 CJS 로딩을 허용
require가 전역으로 살아나는 게 아니라 “이 파일 스코프”에만 존재- 마이그레이션 중 임시 브리지로 유용
다만 장기적으로는 해당 의존성을 ESM 지원 버전으로 올리거나, 대체 라이브러리를 검토하는 편이 운영 안정성에 좋습니다.
해법 3) 조건부/지연 로딩은 동적 import()로 치환
require를 쓰는 흔한 이유 중 하나가 “조건에 따라 로딩”입니다. ESM에서는 동적 import()가 동일한 역할을 합니다.
// index.js (ESM)
export async function loadDriver(kind) {
if (kind === 'pg') {
const mod = await import('pg');
return mod;
}
if (kind === 'mysql') {
const mod = await import('mysql2');
return mod;
}
throw new Error('unknown driver');
}
주의할 점:
import()는 Promise를 반환하므로 호출부가 async 흐름을 가져야 합니다.- CJS 모듈을
import()로 불러오면 default export 형태가 기대와 다를 수 있습니다(interop 이슈). 이 경우mod.default여부를 확인하세요.
해법 4) __dirname/__filename 대체 (ESM 경로 처리)
require 에러를 고치다 보면, 연쇄적으로 __dirname is not defined를 만나기도 합니다. ESM에서는 import.meta.url을 기반으로 경로를 계산합니다.
// paths.js (ESM)
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export { __filename, __dirname };
이 방식으로 정적 파일 경로, 설정 파일 로딩 등을 ESM에서도 동일하게 처리할 수 있습니다.
해법 5) 파일 확장자와 type으로 모듈 시스템을 명시적으로 분리
프로젝트가 혼용 상태라면 “어떤 파일이 ESM인지”가 모호해지는 순간부터 에러가 폭발합니다. 가장 실용적인 전략은 확장자로 강제 분리하는 것입니다.
- ESM 파일:
.mjs - CJS 파일:
.cjs
예를 들어 레거시 설정 파일만 CJS로 유지하고 싶다면:
// config.cjs (CJS)
module.exports = {
port: 3000,
};
// index.mjs (ESM)
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const config = require('./config.cjs');
console.log(config.port);
이렇게 하면 package.json의 "type" 설정과 무관하게 파일 단위로 의도를 고정할 수 있어, 팀 단위 협업에서도 사고가 줄어듭니다.
해법 6) 라이브러리 배포자라면 exports로 ESM/CJS 동시 지원
직접 패키지를 만들고 있고, 소비자가 ESM과 CJS를 섞어 쓴다면 package.json의 exports를 통해 진입점을 분기하는 것이 정석입니다.
{
"name": "my-lib",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
이렇게 하면:
- ESM 사용자는
import로 - CJS 사용자는
require로
각자 자연스러운 방식으로 로딩하고, require is not defined 같은 런타임 충돌이 크게 줄어듭니다.
자주 놓치는 함정 체크리스트
1) 트랜스파일 결과물이 CJS인데 런타임은 ESM
TypeScript나 Babel 설정이 module: commonjs로 빌드되는데, 실행 환경은 "type": "module"이라면 “빌드 산출물”이 ESM으로 해석되어 예상치 못한 에러가 납니다.
- TS라면
tsconfig.json의module과moduleResolution을 런타임에 맞춰야 합니다. - 산출물을
.cjs로 내보내는 전략도 고려하세요.
2) 테스트 러너가 모듈 시스템을 다르게 해석
Jest, Vitest, ts-node 같은 도구는 모듈 해석 방식이 제각각입니다. 특히 Jest는 ESM 지원이 과거에 까다로웠고 설정에 따라 CJS로 돌아가기도 합니다.
- 테스트 환경에서만
require가 되거나, 반대로 테스트에서만require가 터지는 현상이 발생합니다. - 해결은 “테스트도 ESM으로 통일”하거나 “테스트 전용 엔트리만 CJS로 분리”하는 식으로 접근합니다.
3) 서버 환경에서만 재현되는 경우 (systemd, 컨테이너)
로컬에서는 Node 버전이 높아 ESM이 잘 동작하지만, 배포 서버의 Node 버전이 낮거나 실행 커맨드가 다르면 모듈 해석이 바뀔 수 있습니다. 배포 후 프로세스가 재시작 루프에 빠지면 로그에서 require 관련 에러가 반복될 수 있습니다.
이런 상황에서는 서비스 실행 방식과 환경 변수를 함께 점검해야 합니다. 서버 프로세스가 반복 재시작되는 디버깅 관점은 systemd 서비스 재시작 반복? ExecStart 디버깅도 함께 참고하면 좋습니다.
실전 예시: ESM으로 전환하면서 레거시 CJS 의존성 유지하기
아래는 “앱 코드는 ESM으로 유지하되, 일부 레거시 모듈만 CJS로 로딩”하는 전형적인 마이그레이션 패턴입니다.
// app.mjs
import http from 'node:http';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const legacyConfig = require('./legacy-config.cjs');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
res.end(`dir=${__dirname}, port=${legacyConfig.port}`);
});
server.listen(legacyConfig.port);
// legacy-config.cjs
module.exports = {
port: 3000,
};
이 구성의 장점은 다음과 같습니다.
- 애플리케이션 진입점은 ESM으로 깔끔하게 유지
- 레거시 파일은
.cjs로 격리되어 해석이 명확 - 점진적으로
legacy-config.cjs를 ESM으로 옮길 수 있음
결론: “ESM 파일에서 require”는 브리지로만 쓰자
require is not defined는 단순 문법 문제가 아니라 모듈 시스템 경계가 흐려졌다는 신호입니다. 해결의 우선순위는 다음처럼 잡는 것이 안정적입니다.
- 가능하면
require를import로 치환해 ESM으로 통일 - 불가피하면
createRequire(import.meta.url)로 최소 범위 브리지 - 혼용이 길어질수록
.mjs/.cjs로 파일 단위 경계를 강제 - 라이브러리라면
exports로 소비자 환경을 흡수
이 원칙을 지키면 ESM 전환 과정에서 가장 빈번한 런타임 에러를 빠르게 정리할 수 있고, 배포 환경에서도 예측 가능한 동작을 확보할 수 있습니다.