Published on

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

Authors

서버 코드를 ESM으로 바꾸는 순간 가장 자주 마주치는 에러가 ReferenceError: require is not defined in ES module scope 입니다. 기존 CommonJS 프로젝트에서는 당연하던 require()module.exports 가, ESM 환경에서는 더 이상 기본 제공되지 않기 때문입니다.

이 글에서는 왜 이 에러가 발생하는지, 그리고 프로젝트 상황별로 가장 안전하고 유지보수하기 좋은 해결책이 무엇인지 정리합니다. 단순히 “import 로 바꾸세요” 수준이 아니라, JSON 로딩, __dirname 대체, 서드파티 라이브러리 호환, 테스트/빌드 환경까지 포함해 실무에서 필요한 케이스를 다룹니다.

에러가 발생하는 정확한 조건

Node.js는 모듈 시스템을 크게 두 가지로 운영합니다.

  • CommonJS(CJS): require, module.exports, __dirname 사용
  • ECMAScript Modules(ESM): import, export, import.meta.url 사용

다음 조건 중 하나라도 만족하면 해당 파일은 ESM으로 해석될 수 있습니다.

  1. package.json"type": "module" 이 설정됨
  2. 파일 확장자가 .mjs
  3. 실행/로더 옵션이 ESM으로 강제됨(일부 런타임/툴 체인)

ESM으로 해석되는 파일에서는 require 가 전역으로 정의되지 않습니다. 따라서 CJS 코드를 그대로 두고 실행하면 위 에러가 발생합니다.

1) 가장 정석: requireimport 로 마이그레이션

대부분의 경우 정답은 정적 import 로 바꾸는 것입니다.

기본 변환

// before (CJS)
const express = require('express')
module.exports = { app }

// after (ESM)
import express from 'express'
export { app }

named export 가져오기

// CJS
const { readFile } = require('node:fs/promises')

// ESM
import { readFile } from 'node:fs/promises'

CommonJS 라이브러리를 ESM에서 가져올 때

많은 라이브러리가 아직 CJS로 배포됩니다. ESM에서 CJS를 가져오면 “default import” 형태로 들어오는 경우가 흔합니다.

import pkg from 'some-cjs-only-lib'
// pkg 안에 함수/프로퍼티가 들어있음

라이브러리 문서가 CJS 기준으로 const x = require('lib') 형태라면, ESM에서는 위처럼 import x from 'lib' 를 먼저 의심하세요.

2) 마이그레이션이 어려울 때: createRequirerequire 복구

레거시 코드가 크거나, 특정 구간에서만 require 가 꼭 필요한 경우(예: 조건부 로딩, JSON 로딩, 플러그인 시스템)에는 Node.js가 제공하는 createRequire 를 쓰면 됩니다.

import { createRequire } from 'node:module'

const require = createRequire(import.meta.url)

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

이 방식은 “ESM 파일 안에서만 CJS 로더를 한정적으로 사용”하게 해주므로, 점진적 마이그레이션에 특히 유용합니다.

언제 createRequire 를 추천하나

  • 빠르게 에러를 막고 점진적으로 import 로 전환하고 싶을 때
  • CJS 전용으로만 제공되는 모듈을 다수 사용 중일 때
  • 동적 모듈 해석이 필요한 플러그인 구조일 때

반대로, 신규 코드라면 가능하면 정적 import 를 권장합니다. 트리 쉐이킹, 정적 분석, 번들링, 타입 추론에서 장점이 더 큽니다.

3) 동적 로딩이 필요하면 import() 를 사용

CJS에서 흔히 쓰던 패턴이 “조건에 따라 require() 로 모듈을 늦게 로딩”하는 방식입니다. ESM에서는 동적 import() 로 대체합니다.

let sharp

if (process.env.ENABLE_IMAGE === '1') {
  sharp = (await import('sharp')).default
}

주의할 점은 import() 는 Promise를 반환하므로 await 가 필요하고, 호출 위치가 async 컨텍스트여야 한다는 점입니다.

4) __dirname__filename 도 함께 깨진다

ESM으로 전환하면 require 뿐 아니라 __dirname, __filename 도 사라집니다. 보통은 파일 경로를 만들 때 함께 터집니다.

ESM에서는 import.meta.url 기반으로 변환합니다.

import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'

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

const configPath = join(__dirname, 'config.json')

이 작업을 안 해두면, requireimport 로 바꿨는데도 런타임에서 경로 관련 버그가 이어질 수 있습니다.

5) JSON을 require() 하던 코드의 대체

CJS에서는 다음이 흔했습니다.

const config = require('./config.json')

ESM에서는 버전/설정에 따라 방식이 달라집니다.

방법 A: JSON import assertion 사용

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

다만 이 문법은 실행 환경/툴체인(예: ts-node, jest, bundler) 조합에 따라 추가 설정이 필요할 수 있습니다.

방법 B: fs 로 읽어서 JSON.parse

가장 호환성이 좋고, 런타임 동작이 명확합니다.

import { readFile } from 'node:fs/promises'

const raw = await readFile(new URL('./config.json', import.meta.url), 'utf-8')
const config = JSON.parse(raw)

방법 C: createRequire 로 기존 방식 유지

import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)

const config = require('./config.json')

6) 파일 확장자로 ESM/CJS를 공존시키는 전략

프로젝트 전체를 한 번에 바꾸기 어렵다면, 확장자로 모듈 시스템을 분리하는 게 안전합니다.

  • .mjs: ESM
  • .cjs: CommonJS

예를 들어, 프로젝트는 ESM으로 가되 특정 레거시 유틸만 CJS로 남기는 식입니다.

// legacy.cjs
module.exports = function legacyFn() {
  return 'ok'
}
// main.mjs
import legacyFn from './legacy.cjs'
console.log(legacyFn())

이 방식은 팀 단위 마이그레이션에서 충돌을 줄이고, 릴리즈 리스크를 낮추는 데 도움이 됩니다.

7) 자주 놓치는 원인: 도구가 ESM으로 실행하고 있다

코드 자체는 CJS라고 생각했는데, 실행기가 ESM으로 올려버려 require 가 터지는 경우가 있습니다.

체크 포인트:

  • package.json"type": "module" 여부
  • 실행 파일 확장자가 .mjs 인지
  • 테스트 러너/런타임 옵션이 ESM 강제인지

예를 들어 CI에서만 터진다면, 로컬과 CI의 Node 버전/실행 방식이 달라서일 가능성이 큽니다. CI 최적화나 매트릭스 빌드로 Node 버전을 다양하게 돌리는 환경이라면 더 자주 드러납니다. 관련해서는 GitHub Actions 매트릭스 빌드로 CI 50% 줄이기 글의 “환경 조합을 늘려 조기 발견” 전략도 함께 참고할 만합니다.

8) 실전 해결 레시피(상황별 추천)

A. 신규 프로젝트, 또는 ESM 전환이 목표

  1. require 를 전부 import 로 변환
  2. __dirname 대체 코드 추가
  3. JSON은 가능하면 fs 로 읽기(툴 호환성 우선) 또는 assertion 적용

B. 레거시가 크고 빠른 복구가 필요

  1. ESM 파일에서 createRequire 로 우선 동작 복구
  2. 자주 호출되는 모듈부터 점진적으로 import 로 변경
  3. 레거시 구간은 .cjs 로 격리

C. 플러그인/조건부 로딩이 핵심

  1. 동적 로딩은 import() 로 전환
  2. 경로 계산은 new URL('...', import.meta.url) 패턴 사용

9) 디버깅 체크리스트

문제가 계속되면 아래를 순서대로 확인하면 원인을 빠르게 좁힐 수 있습니다.

  1. 에러가 난 파일이 ESM으로 해석되는지 확인: package.jsontype, 확장자 .mjs 여부
  2. 해당 파일에서 require 를 직접 쓰는지, 아니면 의존성 코드가 섞였는지 확인
  3. JSON, 경로 처리(__dirname) 같이 “동시에 깨지는 패턴”이 있는지 확인
  4. CI/프로덕션에서만 재현되면 Node 버전과 실행 커맨드 차이를 비교

운영 환경에서만 이슈가 재현되는 패턴은 Node 런타임/배포 환경 차이에서 자주 나오는데, 이런 종류의 “환경 의존 장애”를 다루는 관점은 GCP Cloud Run 504와 콜드스타트 지연 해결 가이드 같은 글에서 설명하는 체크리스트 접근과도 통합니다.

마무리

require is not defined 는 단순 에러처럼 보이지만, 실제로는 “프로젝트가 어느 시점부터 ESM으로 해석되기 시작했다”는 신호입니다. 가장 좋은 해결은 import 로의 정석 마이그레이션이고, 현실적으로 시간이 필요하다면 createRequire.cjs 격리를 통해 점진적으로 전환하는 전략이 안전합니다.

코드베이스 규모가 크다면, 먼저 동작을 복구한 뒤(예: createRequire) 테스트/CI에서 다양한 Node 버전 조합을 돌려 회귀를 막고, 핵심 모듈부터 import 로 바꾸는 순서로 진행하는 것을 권장합니다.