- Published on
Node.js ESM에서 require 오류 해결 9가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
Node.js에서 ESM(ECMAScript Modules)을 쓰기 시작하면 가장 먼저 마주치는 벽이 require입니다. 대표적으로 아래 오류가 반복됩니다.
ReferenceError: require is not defined in ES module scopeError [ERR_REQUIRE_ESM]: require() of ES Module ... not supportedCannot use import statement outside a module
이 글은 “ESM으로 가려는데 기존 생태계(CJS)와 섞여 있다”는 현실을 전제로, require 오류를 해결하는 9가지 실전 패턴을 정리합니다. 단순히 “import 쓰세요”가 아니라, 언제 어떤 방식으로 브리지해야 하는지를 코드로 보여드립니다.
> TypeScript를 함께 쓰는 프로젝트라면 런타임(Esm/Cjs)과 타입(compiler 옵션)이 엇갈리며 오류가 더 자주 납니다. 타입 안정성 패턴은 TS 5.x satisfies로 타입 오류를 줄이는 실전 패턴도 참고하면 좋습니다.
1) 원인부터 분류하기: “내 파일은 ESM인가 CJS인가?”
require 오류는 대부분 모듈 시스템 판정이 꼬여서 납니다. Node는 아래 규칙으로 파일을 ESM/CJS로 해석합니다.
package.json에"type": "module"이면 기본이 ESM.mjs는 항상 ESM.cjs는 항상 CJS"type": "commonjs"(또는 type 없음) +.js는 기본이 CJS
즉, 같은 .js라도 패키지의 type에 따라 의미가 바뀝니다.
빠른 체크 코드
ESM에서만 가능한 import.meta.url로 현재 환경을 감지할 수 있습니다.
// ESM에서만 동작
console.log(import.meta.url);
반대로 CJS에서만 가능한 __dirname/__filename이 “그대로” 존재하면 CJS일 가능성이 큽니다.
2) 해결 1: ESM에서는 require 대신 import로 치환
가장 정석입니다. ESM 파일에서는 require가 없으므로 아래처럼 바꿉니다.
// before (CJS)
const fs = require('node:fs');
// after (ESM)
import fs from 'node:fs';
Common gotcha: named import vs default import
CJS 모듈은 default export가 없는데도 ESM에서 default로 받는 형태가 가능합니다(interop). 하지만 라이브러리마다 다릅니다.
// 어떤 라이브러리는 이렇게
import pkg from 'some-cjs';
// 어떤 라이브러리는 이렇게
import { something } from 'some-esm';
문제가 생기면 일단 문서/타입 정의를 확인하고, 아래 3번의 브리지 패턴도 고려하세요.
3) 해결 2: ESM에서 꼭 require가 필요하면 createRequire 사용
ESM에서도 “의도적으로” CJS require를 쓰고 싶다면 Node가 제공하는 공식 브리지인 createRequire가 있습니다.
// ESM
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const pkg = require('legacy-cjs-package');
console.log(pkg);
언제 이게 필요한가?
- 레거시 CJS 패키지가 ESM import에서 깨질 때
- JSON/네이티브 애드온/특수 로더를 require로만 로딩하던 코드가 있을 때
- “조건부 로딩”을 동기적으로 처리해야 할 때(단, 아래 4번의 동적 import도 대안)
4) 해결 3: 동적 import()로 런타임에 로드하기
ESM에서 CJS처럼 조건부 로딩이 필요하면 import()를 사용합니다. import()는 Promise를 반환하므로 async 흐름으로 바꿔야 합니다.
// ESM
const isProd = process.env.NODE_ENV === 'production';
const logger = isProd
? (await import('./logger.prod.js')).default
: (await import('./logger.dev.js')).default;
logger.info('hello');
장점
- ESM 표준 방식
- 번들러/트리쉐이킹 친화적
단점
- 동기 require 대비 구조 변경 필요
5) 해결 4: “ERR_REQUIRE_ESM”은 반대로 CJS가 ESM을 require한 경우
오류 메시지 예:
Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported
이건 CJS 코드에서 ESM 패키지를 require했을 때 나옵니다. 해결책은 3가지입니다.
(A) CJS 쪽을 ESM으로 전환
가장 깔끔합니다.
- const lib = require('esm-only-lib');
+ import lib from 'esm-only-lib';
(B) CJS에서 동적 import로 불러오기
Node의 CJS에서도 import()는 쓸 수 있습니다.
// CJS 파일
(async () => {
const lib = await import('esm-only-lib');
console.log(lib.default ?? lib);
})();
(C) 라이브러리의 CJS 엔트리 포인트가 있는지 확인
일부 패키지는 exports에 CJS 경로를 따로 둡니다.
6) 해결 5: package.json의 type 설정을 명확히 하고, 혼합은 확장자로 해결
프로젝트 전체를 ESM으로 가려면:
{
"type": "module"
}
하지만 레거시가 섞여 있다면 확장자로 경계를 명확히 하는 편이 안전합니다.
- ESM 파일:
*.mjs또는type: module+*.js - CJS 파일:
*.cjs
예: ESM 프로젝트에서 일부만 CJS로 유지
src/
index.js (ESM)
legacy.cjs (CJS)
// src/index.js (ESM)
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const legacy = require('./legacy.cjs');
legacy.run();
7) 해결 6: JSON require 오류(또는 JSON import 문제) 처리
과거에는 require('./data.json')가 흔했지만, ESM에서는 JSON import가 제약이 있습니다.
(A) Node 20+에서 JSON import(assert) 사용
import data from './data.json' with { type: 'json' };
console.log(data);
(B) createRequire로 JSON을 계속 require
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const data = require('./data.json');
console.log(data);
(C) fs로 읽고 JSON.parse
배포 환경/도구 체인에서 JSON import가 애매하면 이 방식이 가장 이식성이 좋습니다.
import { readFile } from 'node:fs/promises';
const raw = await readFile(new URL('./data.json', import.meta.url), 'utf-8');
const data = JSON.parse(raw);
8) 해결 7: __dirname / __filename이 없어서 require 기반 경로 로딩이 깨질 때
ESM에는 __dirname, __filename이 없습니다. 대신 import.meta.url을 기반으로 변환합니다.
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log(__dirname);
이 이슈는 “require 오류”로 보이진 않지만, 레거시 코드가 require(path.join(__dirname, ...)) 형태로 구성돼 있으면 ESM 전환 시 연쇄적으로 터집니다.
9) 해결 8: TypeScript 사용 시(특히 ts-node/tsx) 모듈 설정 불일치 해결
TypeScript 프로젝트는 런타임(Node)과 컴파일러(TS)가 서로 다른 기준으로 모듈을 해석해 require/import 오류가 증폭됩니다.
추천 조합(ESM 목표)
module:NodeNextmoduleResolution:NodeNexttarget:ES2022이상 권장
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true
}
}
그리고 실행 도구에 따라 아래가 필요할 수 있습니다.
ts-nodeESM 모드 옵션- 또는
tsx사용 - 또는 빌드 후
node dist/index.js
타입 레벨에서 객체 형태를 안전하게 고정하고 싶다면 TS 5.x satisfies로 타입 안전 유지하며 객체 검증도 같이 보면 좋습니다.
10) 해결 9: 의존성 자체가 ESM-only로 바뀐 경우(버전 핀/대체/래퍼)
최근 많은 패키지가 메이저 업데이트에서 ESM-only로 전환했습니다. 이때 레거시 CJS 코드가 require()로 불러오면 ERR_REQUIRE_ESM이 납니다.
해결 전략은 현실적으로 3가지입니다.
- 의존성 버전 핀: 마지막 CJS 지원 버전으로 고정
- 대체 라이브러리 사용: CJS 지원이 남아있는 패키지로 교체
- 래퍼 모듈 작성: ESM에서 import 후 CJS에 제공(또는 반대)
예: ESM 래퍼를 만들어 CJS에서 동적 import로 쓰기
// wrapper.mjs (ESM)
import lib from 'esm-only-lib';
export default lib;
// legacy.cjs (CJS)
(async () => {
const lib = (await import('./wrapper.mjs')).default;
console.log(lib);
})();
마무리: “한 프로젝트에 ESM/CJS가 섞이는 순간”이 핵심 리스크
정리하면, Node.js ESM에서의 require 오류는 단순 문법 문제가 아니라 모듈 경계(ESM vs CJS), 패키지 exports, 실행 도구(ts-node 등), 파일 확장자 전략이 얽혀서 발생합니다.
실무에서 가장 재현성이 높은 해결 루트는 아래 순서입니다.
package.json의type부터 확정- 혼합이 불가피하면
.cjs/.mjs로 경계 분리 - ESM에서 CJS는
createRequire, CJS에서 ESM은import() - JSON/경로/TS 설정 같은 “부수효과”를 같이 정리
런타임 이슈는 환경 차이에서 더 자주 터집니다. 배포/컨테이너 환경에서의 네트워크·타임아웃 같은 운영 이슈를 함께 다루는 글로는 GCP Cloud Run 503와 콜드스타트 지연 원인·튜닝도 참고할 만합니다.