- Published on
Node.js ESM에서 require is not defined 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 코드를 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으로 해석될 수 있습니다.
package.json에"type": "module"이 설정됨- 파일 확장자가
.mjs - 실행/로더 옵션이 ESM으로 강제됨(일부 런타임/툴 체인)
ESM으로 해석되는 파일에서는 require 가 전역으로 정의되지 않습니다. 따라서 CJS 코드를 그대로 두고 실행하면 위 에러가 발생합니다.
1) 가장 정석: require 를 import 로 마이그레이션
대부분의 경우 정답은 정적 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) 마이그레이션이 어려울 때: createRequire 로 require 복구
레거시 코드가 크거나, 특정 구간에서만 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')
이 작업을 안 해두면, require 를 import 로 바꿨는데도 런타임에서 경로 관련 버그가 이어질 수 있습니다.
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 전환이 목표
require를 전부import로 변환__dirname대체 코드 추가- JSON은 가능하면
fs로 읽기(툴 호환성 우선) 또는 assertion 적용
B. 레거시가 크고 빠른 복구가 필요
- ESM 파일에서
createRequire로 우선 동작 복구 - 자주 호출되는 모듈부터 점진적으로
import로 변경 - 레거시 구간은
.cjs로 격리
C. 플러그인/조건부 로딩이 핵심
- 동적 로딩은
import()로 전환 - 경로 계산은
new URL('...', import.meta.url)패턴 사용
9) 디버깅 체크리스트
문제가 계속되면 아래를 순서대로 확인하면 원인을 빠르게 좁힐 수 있습니다.
- 에러가 난 파일이 ESM으로 해석되는지 확인:
package.json의type, 확장자.mjs여부 - 해당 파일에서
require를 직접 쓰는지, 아니면 의존성 코드가 섞였는지 확인 - JSON, 경로 처리(
__dirname) 같이 “동시에 깨지는 패턴”이 있는지 확인 - CI/프로덕션에서만 재현되면 Node 버전과 실행 커맨드 차이를 비교
운영 환경에서만 이슈가 재현되는 패턴은 Node 런타임/배포 환경 차이에서 자주 나오는데, 이런 종류의 “환경 의존 장애”를 다루는 관점은 GCP Cloud Run 504와 콜드스타트 지연 해결 가이드 같은 글에서 설명하는 체크리스트 접근과도 통합니다.
마무리
require is not defined 는 단순 에러처럼 보이지만, 실제로는 “프로젝트가 어느 시점부터 ESM으로 해석되기 시작했다”는 신호입니다. 가장 좋은 해결은 import 로의 정석 마이그레이션이고, 현실적으로 시간이 필요하다면 createRequire 와 .cjs 격리를 통해 점진적으로 전환하는 전략이 안전합니다.
코드베이스 규모가 크다면, 먼저 동작을 복구한 뒤(예: createRequire) 테스트/CI에서 다양한 Node 버전 조합을 돌려 회귀를 막고, 핵심 모듈부터 import 로 바꾸는 순서로 진행하는 것을 권장합니다.