Published on

Node.js ESM에서 require 오류 해결 9가지

Authors

서론

Node.js에서 ESM(ECMAScript Modules)을 쓰기 시작하면 가장 먼저 마주치는 벽이 require입니다. 대표적으로 아래 오류가 반복됩니다.

  • ReferenceError: require is not defined in ES module scope
  • Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported
  • Cannot use import statement outside a module

이 글은 “ESM으로 가려는데 기존 생태계(CJS)와 섞여 있다”는 현실을 전제로, require 오류를 해결하는 9가지 실전 패턴을 정리합니다. 단순히 “import 쓰세요”가 아니라, 언제 어떤 방식으로 브리지해야 하는지를 코드로 보여드립니다.

> TypeScript를 함께 쓰는 프로젝트라면 런타임(Esm/Cjs)과 타입(compiler 옵션)이 엇갈리며 오류가 더 자주 납니다. 타입 안정성 패턴은 TS 5.x satisfies로 타입 오류를 줄이는 실전 패턴도 참고하면 좋습니다.


1) 원인부터 분류하기: “내 파일은 ESM인가 CJS인가?”

require 오류는 대부분 모듈 시스템 판정이 꼬여서 납니다. Node는 아래 규칙으로 파일을 ESM/CJS로 해석합니다.

  • package.json"type": "module"이면 기본이 ESM
  • .mjs는 항상 ESM
  • .cjs는 항상 CJS
  • "type": "commonjs"(또는 type 없음) + .js는 기본이 CJS

즉, 같은 .js라도 패키지의 type에 따라 의미가 바뀝니다.

빠른 체크 코드

ESM에서만 가능한 import.meta.url로 현재 환경을 감지할 수 있습니다.

// ESM에서만 동작
console.log(import.meta.url);

반대로 CJS에서만 가능한 __dirname/__filename이 “그대로” 존재하면 CJS일 가능성이 큽니다.


2) 해결 1: ESM에서는 require 대신 import로 치환

가장 정석입니다. ESM 파일에서는 require가 없으므로 아래처럼 바꿉니다.

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

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

Common gotcha: named import vs default import

CJS 모듈은 default export가 없는데도 ESM에서 default로 받는 형태가 가능합니다(interop). 하지만 라이브러리마다 다릅니다.

// 어떤 라이브러리는 이렇게
import pkg from 'some-cjs';

// 어떤 라이브러리는 이렇게
import { something } from 'some-esm';

문제가 생기면 일단 문서/타입 정의를 확인하고, 아래 3번의 브리지 패턴도 고려하세요.


3) 해결 2: ESM에서 꼭 require가 필요하면 createRequire 사용

ESM에서도 “의도적으로” CJS require를 쓰고 싶다면 Node가 제공하는 공식 브리지인 createRequire가 있습니다.

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

const pkg = require('legacy-cjs-package');
console.log(pkg);

언제 이게 필요한가?

  • 레거시 CJS 패키지가 ESM import에서 깨질 때
  • JSON/네이티브 애드온/특수 로더를 require로만 로딩하던 코드가 있을 때
  • “조건부 로딩”을 동기적으로 처리해야 할 때(단, 아래 4번의 동적 import도 대안)

4) 해결 3: 동적 import()로 런타임에 로드하기

ESM에서 CJS처럼 조건부 로딩이 필요하면 import()를 사용합니다. import()는 Promise를 반환하므로 async 흐름으로 바꿔야 합니다.

// ESM
const isProd = process.env.NODE_ENV === 'production';

const logger = isProd
  ? (await import('./logger.prod.js')).default
  : (await import('./logger.dev.js')).default;

logger.info('hello');

장점

  • ESM 표준 방식
  • 번들러/트리쉐이킹 친화적

단점

  • 동기 require 대비 구조 변경 필요

5) 해결 4: “ERR_REQUIRE_ESM”은 반대로 CJS가 ESM을 require한 경우

오류 메시지 예:

Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported

이건 CJS 코드에서 ESM 패키지를 require했을 때 나옵니다. 해결책은 3가지입니다.

(A) CJS 쪽을 ESM으로 전환

가장 깔끔합니다.

- const lib = require('esm-only-lib');
+ import lib from 'esm-only-lib';

(B) CJS에서 동적 import로 불러오기

Node의 CJS에서도 import()는 쓸 수 있습니다.

// CJS 파일
(async () => {
  const lib = await import('esm-only-lib');
  console.log(lib.default ?? lib);
})();

(C) 라이브러리의 CJS 엔트리 포인트가 있는지 확인

일부 패키지는 exports에 CJS 경로를 따로 둡니다.


6) 해결 5: package.json의 type 설정을 명확히 하고, 혼합은 확장자로 해결

프로젝트 전체를 ESM으로 가려면:

{
  "type": "module"
}

하지만 레거시가 섞여 있다면 확장자로 경계를 명확히 하는 편이 안전합니다.

  • ESM 파일: *.mjs 또는 type: module + *.js
  • CJS 파일: *.cjs

예: ESM 프로젝트에서 일부만 CJS로 유지

src/
  index.js        (ESM)
  legacy.cjs      (CJS)
// src/index.js (ESM)
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);

const legacy = require('./legacy.cjs');
legacy.run();

7) 해결 6: JSON require 오류(또는 JSON import 문제) 처리

과거에는 require('./data.json')가 흔했지만, ESM에서는 JSON import가 제약이 있습니다.

(A) Node 20+에서 JSON import(assert) 사용

import data from './data.json' with { type: 'json' };
console.log(data);

(B) createRequire로 JSON을 계속 require

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

const data = require('./data.json');
console.log(data);

(C) fs로 읽고 JSON.parse

배포 환경/도구 체인에서 JSON import가 애매하면 이 방식이 가장 이식성이 좋습니다.

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

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

8) 해결 7: __dirname / __filename이 없어서 require 기반 경로 로딩이 깨질 때

ESM에는 __dirname, __filename이 없습니다. 대신 import.meta.url을 기반으로 변환합니다.

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

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

console.log(__dirname);

이 이슈는 “require 오류”로 보이진 않지만, 레거시 코드가 require(path.join(__dirname, ...)) 형태로 구성돼 있으면 ESM 전환 시 연쇄적으로 터집니다.


9) 해결 8: TypeScript 사용 시(특히 ts-node/tsx) 모듈 설정 불일치 해결

TypeScript 프로젝트는 런타임(Node)과 컴파일러(TS)가 서로 다른 기준으로 모듈을 해석해 require/import 오류가 증폭됩니다.

추천 조합(ESM 목표)

  • module: NodeNext
  • moduleResolution: NodeNext
  • target: ES2022 이상 권장
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true
  }
}

그리고 실행 도구에 따라 아래가 필요할 수 있습니다.

  • ts-node ESM 모드 옵션
  • 또는 tsx 사용
  • 또는 빌드 후 node dist/index.js

타입 레벨에서 객체 형태를 안전하게 고정하고 싶다면 TS 5.x satisfies로 타입 안전 유지하며 객체 검증도 같이 보면 좋습니다.


10) 해결 9: 의존성 자체가 ESM-only로 바뀐 경우(버전 핀/대체/래퍼)

최근 많은 패키지가 메이저 업데이트에서 ESM-only로 전환했습니다. 이때 레거시 CJS 코드가 require()로 불러오면 ERR_REQUIRE_ESM이 납니다.

해결 전략은 현실적으로 3가지입니다.

  1. 의존성 버전 핀: 마지막 CJS 지원 버전으로 고정
  2. 대체 라이브러리 사용: CJS 지원이 남아있는 패키지로 교체
  3. 래퍼 모듈 작성: ESM에서 import 후 CJS에 제공(또는 반대)

예: ESM 래퍼를 만들어 CJS에서 동적 import로 쓰기

// wrapper.mjs (ESM)
import lib from 'esm-only-lib';
export default lib;
// legacy.cjs (CJS)
(async () => {
  const lib = (await import('./wrapper.mjs')).default;
  console.log(lib);
})();

마무리: “한 프로젝트에 ESM/CJS가 섞이는 순간”이 핵심 리스크

정리하면, Node.js ESM에서의 require 오류는 단순 문법 문제가 아니라 모듈 경계(ESM vs CJS), 패키지 exports, 실행 도구(ts-node 등), 파일 확장자 전략이 얽혀서 발생합니다.

실무에서 가장 재현성이 높은 해결 루트는 아래 순서입니다.

  1. package.jsontype부터 확정
  2. 혼합이 불가피하면 .cjs/.mjs로 경계 분리
  3. ESM에서 CJS는 createRequire, CJS에서 ESM은 import()
  4. JSON/경로/TS 설정 같은 “부수효과”를 같이 정리

런타임 이슈는 환경 차이에서 더 자주 터집니다. 배포/컨테이너 환경에서의 네트워크·타임아웃 같은 운영 이슈를 함께 다루는 글로는 GCP Cloud Run 503와 콜드스타트 지연 원인·튜닝도 참고할 만합니다.