Published on

Node.js ESM에서 require 미정의 에러 해결법

Authors

서버 코드를 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');

해결책 선택 가이드 (가장 중요한 분기)

아래 질문에 답하면 해결 방향이 빠르게 정해집니다.

  1. 내 코드가 ESM이어야 하나?
  • 이미 ESM으로 마이그레이션 중이거나, import 기반으로 통일하고 싶다: ESM 방식으로 고친다
  • 레거시 CJS가 많고, 당장 바꾸기 어렵다: CJS로 되돌리거나 일부만 브리지한다
  1. 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:modulecreateRequire로컬 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.jsonexports를 통해 진입점을 분기하는 것이 정석입니다.

{
  "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.jsonmodulemoduleResolution을 런타임에 맞춰야 합니다.
  • 산출물을 .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는 단순 문법 문제가 아니라 모듈 시스템 경계가 흐려졌다는 신호입니다. 해결의 우선순위는 다음처럼 잡는 것이 안정적입니다.

  1. 가능하면 requireimport로 치환해 ESM으로 통일
  2. 불가피하면 createRequire(import.meta.url)로 최소 범위 브리지
  3. 혼용이 길어질수록 .mjs/.cjs로 파일 단위 경계를 강제
  4. 라이브러리라면 exports로 소비자 환경을 흡수

이 원칙을 지키면 ESM 전환 과정에서 가장 빈번한 런타임 에러를 빠르게 정리할 수 있고, 배포 환경에서도 예측 가능한 동작을 확보할 수 있습니다.