Published on

Node.js ESM에서 require is not defined 오류 해결

Authors

서버나 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

빠른 체크리스트

  1. node -p "process.versions.node"로 Node 버전 확인(가능하면 18+ 권장)
  2. 해당 파일이 ESM으로 해석되는지 확인
    • package.jsontype
    • 확장자 .mjs/.cjs
  3. 실행 명령 확인
    • node index.js인지, ts-node/tsx/번들된 산출물인지

2) 가장 정석적인 해결: requireimport로 교체

가장 좋은 해결은 “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/moduleResolutionNodeNext로 맞추면 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)을 참조

해결 방향

  1. 해당 코드를 서버 전용으로 격리
  2. 클라이언트에서 필요하면 API로 우회
  3. 번들러 설정으로 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다

  • tsconfigNodeNext로 정렬
  • 남은 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)까지 같이 정리하고 싶다면 위 내부 링크 글을 함께 읽어보는 것을 권합니다.