Published on

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

Authors

서버나 CLI를 ESM으로 전환한 뒤 가장 흔하게 마주치는 런타임 에러가 ReferenceError: require is not defined in ES module scope 입니다. CommonJS에서는 전역처럼 쓰던 require 가, ESM에서는 기본 제공되지 않기 때문에 발생합니다.

이 글에서는 에러가 나는 정확한 조건을 짚고, 코드베이스를 크게 흔들지 않으면서도 안전하게 고치는 선택지를 비교합니다. ESM 전환 과정에서 자주 함께 터지는 exports/import 관련 이슈는 Node.js ESM 전환 후 exports import 오류 해결 글도 같이 보면 맥락이 더 잘 잡힙니다.

에러가 발생하는 조건: ESM 스코프에는 require 가 없다

다음 중 하나라도 해당하면 파일이 ESM으로 해석될 수 있습니다.

  • package.json"type": "module" 이 있다
  • 파일 확장자가 .mjs 이다
  • (반대로) .cjs 는 CommonJS로 강제된다

ESM으로 해석된 파일에서는 require, module.exports, __filename, __dirname 같은 CommonJS 전용 전역이 존재하지 않습니다. 그래서 아래 코드가 그대로 실행되면 에러가 납니다.

// index.js (ESM으로 해석되는 상황)
const fs = require('node:fs');
console.log(fs.readFileSync('package.json', 'utf8'));

해결은 크게 3갈래입니다.

  1. 완전 전환: requireimport 로 바꾸기
  2. 브리지: ESM에서 createRequirerequire 를 만들어 쓰기
  3. 지연 로딩: import() (동적 import)로 대체하기

해결 1) 정석: requireimport 로 바꾸기

가장 권장되는 방식은 ESM 문법으로 일관되게 전환하는 것입니다.

Node 내장 모듈

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

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

내장 모듈은 대체로 위처럼 바꾸면 됩니다.

npm 패키지: default export vs named export 주의

패키지에 따라 import 방식이 달라질 수 있습니다.

// CommonJS에서 흔한 패턴
const express = require('express');

// ESM에서 보통은 이렇게
import express from 'express';

하지만 어떤 패키지는 named export만 제공하기도 합니다.

// 예시: named export
import { z } from 'zod';

전환 중 does not provide an export named ... 같은 에러가 나면, 패키지의 ESM 지원 형태(dual package, exports 필드, CJS only 여부)를 확인해야 합니다.

JSON import는 Node 버전에 따라 옵션 필요

ESM에서 JSON을 import하려면 Node 버전과 설정에 따라 assert 구문이 필요할 수 있습니다.

import pkg from './package.json' assert { type: 'json' };
console.log(pkg.name);

런타임/빌드 도구(예: ts-node, vite, jest) 조합에 따라 JSON 처리 방식이 달라지므로, 프로젝트 표준을 먼저 정해두는 것이 좋습니다.

해결 2) 현실적인 절충: createRequire 로 ESM에서 require 사용

레거시 코드가 많거나, 특정 패키지가 CJS만 지원해서 당장 import 로 바꾸기 어렵다면 node:modulecreateRequire 가 가장 실용적입니다.

// index.mjs 또는 type=module 환경
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);

const dotenv = require('dotenv');
dotenv.config();

이 패턴의 장점:

  • 기존 require(...) 기반 코드를 최소 수정으로 살릴 수 있음
  • CJS only 패키지 로딩에 특히 유용

주의점:

  • ESM/CJS 혼용이 계속되므로, 장기적으로는 기술 부채가 될 수 있음
  • 테스트 환경(Jest 등)에서 모듈 해석 방식이 섞이면 또 다른 오류가 날 수 있음

해결 3) 동적 import: 조건부 로딩/플러그인 구조에 적합

ESM에서는 require 대신 import() 를 런타임에 호출할 수 있습니다. 특히 플러그인, 선택 기능, 환경별 로딩에 좋습니다.

// ESM
export async function loadAdapter(kind) {
  if (kind === 's3') {
    const mod = await import('./adapters/s3.js');
    return mod.createS3Adapter();
  }

  const mod = await import('./adapters/local.js');
  return mod.createLocalAdapter();
}

주의할 점은 import() 가 Promise를 반환하므로 호출부가 비동기 흐름으로 바뀐다는 것입니다. 기존 동기 require 와 동일한 형태로 바꾸기 어렵다면 createRequire 가 더 낫습니다.

자주 같이 터지는 이슈: __dirname / __filename 대체

require 에러를 고치다 보면, 다음 에러도 연달아 만납니다.

  • __dirname is not defined in ES module scope

ESM에서는 아래처럼 대체합니다.

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으로 실행됐나

require is not defined 는 “이 파일이 ESM으로 해석됐다”는 신호입니다. 아래를 먼저 확인하면 삽질을 크게 줄일 수 있습니다.

1) package.jsontype

{
  "type": "module"
}

이 설정이 있으면 .js 는 기본적으로 ESM입니다. 레거시 CJS 파일을 유지해야 한다면 해당 파일만 .cjs 로 바꾸는 것도 방법입니다.

config.cjs
server.cjs

2) 실행 커맨드와 로더

  • node index.js 는 기본 규칙대로 해석
  • ts-node, tsx, babel-node 같은 로더는 ESM 해석/트랜스파일 규칙이 별도로 존재

특히 TypeScript를 쓰는 경우 tsconfig.jsonmodule, moduleResolution 이 ESM 전환에 큰 영향을 줍니다.

3) 번들러/테스트 러너의 모듈 해석

Jest, Vitest, Next.js, webpack 등은 각각 ESM 지원 방식이 다릅니다. 런타임은 ESM인데 테스트만 CJS로 돌거나 그 반대면, 동일 코드가 환경마다 다르게 깨질 수 있습니다.

이런 “환경별로만 재현되는” 문제는 원인 파악이 어려운데, 접근 방식은 장애 진단과 유사합니다. 서비스가 계속 재시작되는 상황에서 재현 조건을 좁히는 방법론은 systemd 서비스가 계속 재시작될 때 진단 체크리스트 글의 체크 방식(로그, 실행 인자, 환경 변수, 워킹 디렉터리 확인)이 그대로 도움이 됩니다.

어떤 해결책을 선택해야 하나: 실무 기준

빠르게 막아야 한다면

  • 단일 파일/레거시 블록만 문제라면 createRequire 가 가장 빠릅니다.
  • 배포 직전 핫픽스라면 “최소 변경”이 중요합니다.

장기적으로 유지보수하려면

  • 신규 코드부터는 import 로 통일
  • 레거시 require 는 점진적으로 제거
  • 파일 경로/JSON import/테스트 러너 설정까지 한 번에 정리

특히 ESM 전환은 인증/세션처럼 “한 번 꼬이면 증상이 랜덤하게 보이는” 영역과도 비슷한 면이 있습니다. 환경별로 다르게 깨진다면 재현 조건을 좁혀가며 확인해야 하고, 그런 문제를 다루는 사고방식은 NextAuth.js JWT 세션이 랜덤 로그아웃될 때 점검 같은 트러블슈팅 글에서 말하는 접근과도 통합니다.

실전 예제: ESM 프로젝트에서 CJS 패키지 로딩하기

아래는 ESM 기반 CLI에서 CJS 패키지(가령 설정 로더)를 불러와 사용하는 전형적인 예입니다.

// cli.mjs
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 configPath = path.join(__dirname, 'app.config.cjs');
const config = require(configPath);

console.log('loaded config:', config);
// app.config.cjs (CommonJS로 유지)
module.exports = {
  port: 3000,
  featureFlag: true
};

이 구성의 포인트:

  • 전체 앱은 ESM으로 유지하되, “설정 파일”처럼 CJS가 편한 영역만 .cjs 로 분리
  • ESM에서 require 가 필요할 때만 createRequire 로 브리지

결론

require is not defined 는 단순히 문법 오류가 아니라, “지금 이 파일은 ESM으로 실행 중”이라는 신호입니다. 해결은 세 가지로 요약됩니다.

  • 가장 좋은 해법: requireimport 로 전환해 ESM으로 통일
  • 레거시/호환성 해법: createRequire(import.meta.url) 로 CJS 로딩
  • 구조적 해법: 조건부/플러그인 로딩은 import() 로 전환

전환 범위(단일 파일 vs 전체 프로젝트), 의존성의 ESM 지원 여부, 테스트/빌드 도구까지 함께 고려하면 재발을 크게 줄일 수 있습니다.