Published on

Node.js ESM에서 require 오류 5분 해결법

Authors

서버를 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 파일에서 .cjsimport하면 “default export 형태” 등 상호운용 규칙이 적용됩니다.
  • 반대로 .cjs에서 .mjsrequire하면 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분 체크리스트” (가장 빠른 순서)

  1. 오류 메시지부터 분류
    • require is not defined → 지금 파일이 ESM
    • ERR_REQUIRE_ESM → require로 ESM 모듈을 로드 중
  2. package.json"type" 확인
  3. ESM 파일이면 우선 import로 바꿀 수 있는지 판단
  4. 당장 못 바꾸면 createRequire(import.meta.url)로 임시 봉합
  5. ERR_REQUIRE_ESM이면 await import()로 로딩 방식 전환
  6. 혼용이 필요하면 .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 전환법