Published on

Node.js ESM에서 require 에러 해결 8가지

Authors

Node.js에서 ESM(ECMAScript Modules)을 쓰기 시작하면 가장 흔하게 맞닥뜨리는 문제가 require 계열 에러입니다. 대표적으로 ReferenceError: require is not defined in ES module scope 같은 메시지가 나오거나, 반대로 CommonJS에서 ESM 패키지를 require() 하다가 ERR_REQUIRE_ESM가 터지기도 합니다.

이 글은 “왜 이런 에러가 나는지”를 먼저 구조적으로 정리하고, 팀/레포에서 바로 적용 가능한 해결책 8가지를 체크리스트처럼 제공합니다.

관련해서 단일 케이스를 빠르게 해결하고 싶다면 아래 글도 함께 참고하세요.

먼저, 에러를 3가지로 분류하기

require 문제는 보통 아래 3종류로 나뉩니다.

  1. ESM 파일 안에서 require를 직접 호출함
  2. CommonJS 파일에서 ESM 전용 패키지를 require()로 로딩함
  3. 번들러/트랜스파일 결과물에서 모듈 타입이 뒤섞여 런타임이 오판함

각각 해결책이 다르므로, 로그의 키워드를 먼저 확인하세요.

  • require is not defined in ES module scope
    ESM에서 require를 쓰고 있음
  • Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported
    CommonJS에서 ESM 패키지를 require()로 불러옴
  • Cannot use import statement outside a module
    ESM로 실행돼야 하는데 CommonJS로 실행 중(또는 반대)

해결 1) ESM에서는 import로 바꾸기(정공법)

가장 좋은 해결은 require를 없애고 ESM 문법으로 통일하는 것입니다.

변경 예시

// before (CommonJS)
const fs = require('node:fs');
const path = require('node:path');

// after (ESM)
import fs from 'node:fs';
import path from 'node:path';

라이브러리에 따라 default export 여부가 달라 import x from 대신 import * as x from가 필요한 경우도 있습니다.

// 일부 CJS 패키지는 namespace import가 안전
import * as lodash from 'lodash';

핵심은 “ESM 파일에서는 ESM 방식으로만 의존성을 선언”하는 것입니다.

해결 2) JSON 로딩은 assert 또는 createRequire로 처리

ESM에서 JSON을 다루다 보면 require('./config.json')을 그대로 쓰고 싶어집니다. 하지만 ESM에서는 기본적으로 require가 없고, JSON import 방식도 제약이 있습니다.

방법 A: JSON 모듈 import(지원 환경 확인)

import config from './config.json' assert { type: 'json' };

console.log(config);

Node.js 버전/플래그/도구체인에 따라 동작이 다를 수 있어, 팀 환경이 섞여 있다면 다음 방법이 더 실용적입니다.

방법 B: createRequire로 필요한 곳에만 require 주입

import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const config = require('./config.json');

console.log(config);

이 방식은 “ESM 파일이지만 특정 자원(JSON, CJS 전용 모듈)을 불러오기 위해 require를 국소적으로 허용”한다는 점에서 현실적인 타협안입니다.

해결 3) __dirname, __filename 대체(경로 문제로 연쇄 에러 방지)

ESM으로 전환할 때 require 에러와 함께 자주 터지는 게 __dirname/__filename 부재입니다. 이걸 고치지 않으면 “경로 계산을 위해 require('path')를 억지로 쓰다가” 문제를 더 키우기도 합니다.

ESM에서는 import.meta.url을 기반으로 계산합니다.

import { fileURLToPath } from 'node:url';
import path from 'node:path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

console.log(__dirname);

이 패턴을 유틸로 만들어두면 ESM 마이그레이션이 훨씬 수월해집니다.

해결 4) CommonJS에서 ESM 패키지를 써야 한다면 import() 동적 로딩

ERR_REQUIRE_ESM는 “ESM 전용 패키지를 CommonJS에서 require()로 불렀다”는 뜻입니다. 이때는 CommonJS 쪽에서 import()를 쓰는 게 가장 깔끔합니다.

// index.cjs
async function main() {
  const { default: got } = await import('got');
  const res = await got('https://example.com');
  console.log(res.statusCode);
}

main().catch(console.error);

포인트는 import()는 CommonJS에서도 동작하며 Promise를 반환한다는 점입니다. 따라서 호출부를 async로 감싸거나 top-level에서 Promise 체인을 만들어야 합니다.

해결 5) 파일 확장자와 type을 명확히: .mjs/.cjs 전략

프로젝트에서 가장 위험한 상태는 “이 파일이 ESM인지 CJS인지 사람이 봐도 애매한 상태”입니다. Node.js는 package.jsontype과 확장자로 모듈 타입을 결정합니다.

권장 전략

  • ESM 파일은 .mjs 또는 type: 'module' 환경의 .js
  • CommonJS 파일은 .cjs로 고정

예를 들어 레거시 스크립트만 CJS로 남겨야 한다면:

{
  "type": "module",
  "scripts": {
    "start": "node src/index.js",
    "legacy": "node src/legacy.cjs"
  }
}

이렇게 하면 ESM 기본 프로젝트에서도 특정 파일은 확실하게 CJS로 실행되어 require를 정상 사용합니다.

해결 6) 패키지 배포자라면 exports로 ESM/CJS 듀얼 제공

라이브러리(사내 패키지 포함)를 배포하는 입장이라면, 소비자 앱이 ESM인지 CJS인지에 따라 진입점을 다르게 제공해야 require 문제가 줄어듭니다.

{
  "name": "my-lib",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  }
}
  • import 경로는 ESM 빌드
  • require 경로는 CJS 빌드

이 구성을 하면 소비자는 ESM이면 import로, CJS면 require()로 자연스럽게 로딩됩니다.

해결 7) TypeScript 사용 시 module/moduleResolution을 ESM에 맞추기

TypeScript 프로젝트에서 “소스는 ESM 의도인데 컴파일 결과가 CJS로 떨어져 런타임에서 require가 튀어나오는” 케이스가 흔합니다.

ESM 지향 설정 예시

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "esModuleInterop": true,
    "resolveJsonModule": true
  }
}
  • moduleNodeNext로 두면 확장자/type 규칙을 Node.js와 비슷하게 따라갑니다.
  • esModuleInterop는 CJS 패키지 import 호환을 완화합니다.

또한 ts-node 같은 런타임 실행 도구를 쓴다면, 실행 옵션이 ESM을 제대로 켰는지도 확인해야 합니다.

해결 8) 테스트/번들러 환경에서 모듈 타입을 강제로 맞추기

Jest, Vitest, Babel, ts-jest, webpack, esbuild 같은 도구체인에서는 “Node 런타임 규칙”과 “도구가 해석하는 규칙”이 어긋나 require 에러가 발생할 수 있습니다.

대표 증상

  • 앱은 잘 뜨는데 테스트에서만 require is not defined가 발생
  • 번들된 결과물에서 require가 남아 브라우저/런타임에서 터짐

대응 방향(도구별로 체크)

  • 테스트 러너가 ESM을 지원하는 모드인지 확인
  • 변환 결과물에 require()가 남지 않도록 번들 타깃을 조정
  • 필요하면 테스트 전용으로 CJS 진입점(.cjs)을 제공

예를 들어 Vitest에서는 Node 환경 ESM을 자연스럽게 지원하지만, Jest는 설정 난이도가 더 높습니다. “테스트만 CJS로 유지”하는 전략이 비용 대비 효과가 좋은 경우도 많습니다.

실전 체크리스트: 내 프로젝트는 어디에 해당하나

아래 순서대로 보면 대부분 10분 안에 원인 좁히기가 됩니다.

  1. 에러 메시지에 ERR_REQUIRE_ESM가 있는가
    • 있으면 CommonJS에서 ESM을 require()
    • 해결 4 또는 해결 6
  2. require is not defined인가
    • ESM 파일에서 require를 호출함
    • 해결 1 또는 해결 2
  3. 파일 확장자 .js만 쓰고 type이 애매한가
    • 해결 5
  4. TS 컴파일 결과가 의도와 다르게 나오나
    • 해결 7
  5. 테스트/빌드에서만 재현되나
    • 해결 8

마무리: “혼용을 통제”하면 require 에러는 사라진다

ESM 전환에서 require 에러가 반복되는 근본 원인은 “혼용 자체”가 아니라 “혼용이 무질서하게 일어나는 것”입니다. 즉,

  • ESM은 ESM으로 통일(해결 1)
  • 불가피한 곳에만 createRequire 또는 import()로 다리 놓기(해결 2, 4)
  • 경계는 확장자와 exports로 명확히 선언(해결 5, 6)
  • TS/테스트 도구까지 같은 규칙으로 정렬(해결 7, 8)

이 4가지만 지켜도 require 관련 이슈는 대부분 재발하지 않습니다.

추가로 ESM에서 require가 사라지는 대표 케이스를 더 빠르게 확인하려면 아래 글을 함께 보세요.