Published on

Node 22에서 require가 안 될 때 ESM 전환법

Authors

서버를 Node 22로 올린 뒤 갑자기 require is not defined 또는 ERR_REQUIRE_ESM 같은 에러를 만나면, 대부분은 모듈 시스템(CommonJS vs ESM) 경계가 바뀐 상태에서 코드/설정이 섞였기 때문입니다. Node 22가 "require를 없앴다"기보다, 프로젝트가 ESM으로 해석되는 순간 require가 더 이상 기본 제공되지 않거나(브라우저/ESM 컨텍스트), ESM 전용 패키지를 require()로 불러오려다 실패합니다.

이 글에서는 Node 22 환경에서 require 문제가 터지는 대표 케이스를 분류하고, ESM으로 전환하는 가장 안전한 경로(점진 전환 포함)를 코드와 함께 정리합니다.

> 운영 환경에서 모듈 전환은 배포/빌드 파이프라인과도 얽힙니다. 네트워크/권한 계열 장애 점검 루틴이 필요하다면 EKS에서 kubectl exec·logs가 안 될 때 진단법처럼 “원인 분류 → 체크리스트” 스타일로 접근하는 게 효과적입니다.

Node 22에서 require가 실패하는 대표 시나리오

1) 파일이 ESM으로 해석됨: require is not defined

다음 중 하나면 해당 파일은 ESM으로 로드됩니다.

  • package.json"type": "module"
  • 파일 확장자가 .mjs
  • 일부 툴(번들러/테스트 러너)이 ESM으로 실행

ESM 컨텍스트에서는 require, module.exports, __dirname, __filename이 기본으로 없습니다.

2) ESM 전용 패키지를 require로 로드: ERR_REQUIRE_ESM

최근 패키지들은 ESM only로 전환하는 경우가 많습니다. 예를 들어 어떤 의존성이 ESM only인데 다음처럼 쓰면 실패합니다.

// CommonJS
const pkg = require("some-esm-only-package");

Node는 CommonJS에서 ESM 모듈을 require()로 동기 로드할 수 없기 때문에 ERR_REQUIRE_ESM를 던집니다.

3) TS/번들러 출력이 ESM인데 런타임은 CJS로 가정

TypeScript의 module 설정, 번들러 출력 포맷, package.jsontype이 불일치하면 런타임에서 모듈 로딩이 꼬입니다.

예: TS가 ESM(module: NodeNext)을 출력했는데, 실행은 CJS로 가정하고 require()를 호출한다든지.

4) 테스트 러너/CLI가 ESM으로 실행되면서 require 사용 코드가 터짐

Jest/Vitest/tsx/ts-node 등 실행기 옵션에 따라 ESM 모드가 되면, 기존 CJS 스타일 코드가 실패할 수 있습니다.

먼저 확인할 것: 지금 내 프로젝트는 ESM인가 CJS인가

package.json의 type 확인

{
  "name": "my-app",
  "type": "module",
  "main": "./dist/index.js"
}
  • type: module이면 .js는 기본적으로 ESM
  • type이 없거나 commonjs이면 .js는 기본적으로 CJS

파일 확장자 규칙

  • ESM: .mjs (항상 ESM)
  • CJS: .cjs (항상 CJS)
  • .jstype에 따라 달라짐

이 규칙을 이해하면 “왜 어떤 파일에서는 require가 되고 어떤 파일에서는 안 되지?” 같은 혼란이 크게 줄어듭니다.

ESM 전환의 가장 안전한 전략: 점진 전환(혼합 운영)

대규모 코드베이스에서 한 번에 ESM으로 갈아엎으면, 배포 직전에 예상치 못한 import 경로/확장자/테스트 문제로 폭발하기 쉽습니다. Node는 .cjs/.mjs 확장자로 혼합 운영이 가능하므로, 아래 순서가 안전합니다.

  1. 새 코드/엔트리부터 ESM으로 시작
  2. 기존 CJS 파일은 .cjs로 고정
  3. ESM에서 CJS를 불러올 때는 createRequire 또는 default import 규칙을 명확히
  4. ESM only 의존성은 import로만 사용

전환 1단계: 엔트리 포인트를 ESM으로 바꾸기

방법 A) type: module로 프로젝트 전체를 ESM로

{
  "type": "module",
  "scripts": {
    "start": "node ./src/index.js"
  }
}

이 경우 기존 CJS 파일은 .cjs로 바꿔야 안전합니다.

mv src/legacy.js src/legacy.cjs

방법 B) 엔트리만 .mjs로(프로젝트 전체는 유지)

mv src/index.js src/index.mjs

이 방식은 레거시 CJS가 많은 프로젝트에서 충격이 적습니다.

전환 2단계: require → import 문법으로 치환

기본 치환

// before (CJS)
const express = require("express");
const { readFileSync } = require("node:fs");

// after (ESM)
import express from "express";
import { readFileSync } from "node:fs";
  • Node 내장 모듈은 node: 프리픽스를 권장합니다.
  • CommonJS 패키지를 ESM에서 import할 때는 보통 default import가 동작하지만, 패키지 export 형태에 따라 다를 수 있습니다.

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

require()를 조건부로 쓰던 코드는 import()로 바꿉니다.

// ESM
let client;
if (process.env.USE_MOCK === "1") {
  client = await import("./mock-client.js");
} else {
  client = await import("./real-client.js");
}

> ESM의 top-level await를 활용할 수 있지만, 함수 내부로 감싸는 게 호환성/테스트 측면에서 더 편한 경우도 많습니다.

전환 3단계: __dirname / __filename 대체

ESM에는 __dirname이 없습니다. 대신 import.meta.url + fileURLToPath를 씁니다.

// 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);

이 변경은 설정 파일 로딩, 정적 파일 경로, 템플릿 경로 등에서 자주 필요합니다.

전환 4단계: ESM에서 CJS를 불러와야 할 때(레거시 유지)

ESM 코드에서 레거시 .cjs 모듈을 불러오는 가장 명확한 방법은 createRequire입니다.

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

const legacy = require("./legacy.cjs");
console.log(legacy);

이 패턴은 “전환 중간 단계”에서 특히 유용합니다.

케이스별 해결 레시피

케이스 A) require is not defined (ESM 파일에서 require 사용)

  • 해결: 해당 파일을 CJS로 유지하려면 .cjs로 바꾸거나, require를 import로 바꾸세요.
# CJS로 고정
mv src/config.js src/config.cjs

또는

// ESM로 전환
import config from "./config.js";

케이스 B) ERR_REQUIRE_ESM (ESM only 패키지를 require)

  • 해결: 해당 의존성을 import로 불러오도록 호출부를 ESM으로 전환하거나, CJS에서라면 import()로 우회합니다.
// CommonJS에서 ESM only를 써야 한다면
(async () => {
  const { default: got } = await import("got");
  const res = await got("https://example.com");
  console.log(res.statusCode);
})();

다만 이 경우 호출부가 async 컨텍스트가 되어 파급이 있을 수 있으니, 가능하면 해당 모듈을 ESM으로 전환하는 게 깔끔합니다.

케이스 C) TS 프로젝트에서 Node 22 + ESM 정석 설정

tsconfig.json을 Node의 ESM 해석 규칙에 맞추려면 보통 아래 조합이 안정적입니다.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "esModuleInterop": true,
    "strict": true
  }
}

그리고 package.json에:

{
  "type": "module",
  "main": "./dist/index.js",
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "start": "node dist/index.js"
  }
}

이때 TS가 출력하는 import 경로/확장자 문제(특히 상대경로)가 생기면, 빌드 산출물에서 실제 파일 확장자와 import specifier가 일치하는지 확인하세요.

실전 팁: 모듈 전환 시 자주 터지는 함정

1) 상대 경로 import에서 확장자 이슈

Node ESM은 브라우저처럼 상대 경로에 확장자를 명시하는 패턴이 자주 요구됩니다(툴체인/설정에 따라 완화되기도 함).

// ESM에서 권장
import { foo } from "./foo.js";

TS를 쓰면 소스는 ./foo.js로 쓰고, TS가 타입 체크 시 이를 매핑하는 방식(NodeNext)이 일반적입니다.

2) default import vs named import 혼동

CJS 모듈을 ESM에서 불러올 때:

import pkg from "cjs-package";
// 또는
import * as pkg from "cjs-package";

패키지마다 export 형태가 달라서 런타임에서 undefined가 나올 수 있습니다. 이 경우 문서의 ESM 사용 예제를 확인하거나, 실제 export를 로그로 확인하세요.

3) 린트/포맷/테스트 설정도 같이 바꿔야 함

ESM 전환은 코드만 바꾸면 끝나지 않고, 다음도 동반 수정이 필요합니다.

  • ESLint 설정 파일(.eslintrc.cjs로 고정하는 경우 많음)
  • Jest 설정(jest.config.cjs 유지 등)
  • 실행 스크립트(node 옵션, ts-node/tsx 사용 방식)

이런 “주변부 설정 파일”은 오히려 CJS로 고정하는 게 편한 경우가 많습니다.

최소 변경으로 "일단" Node 22에서 돌아가게 하는 응급 처치

ESM 전환이 당장 어렵다면, 충돌 지점을 격리하는 방식으로 시간을 벌 수 있습니다.

  1. 문제 파일을 .cjs로 변경해 CJS로 강제
  2. ESM only 패키지는 해당 부분만 import()로 로드
  3. 프로젝트 루트의 type 변경은 마지막에

예를 들어, 프로젝트는 CJS인데 특정 의존성이 ESM only라면 아래처럼 “어댑터 모듈”을 하나 두는 방식이 실무에서 자주 쓰입니다.

// esm-adapter.cjs (CommonJS)
module.exports = async function loadEsmOnly() {
  const mod = await import("some-esm-only-package");
  return mod;
};
// app.cjs
const load = require("./esm-adapter.cjs");

(async () => {
  const { default: client } = await load();
  await client.run();
})();

마이그레이션 체크리스트(배포 전)

  • package.jsontype과 엔트리 확장자(.js/.mjs/.cjs)가 의도대로인가?
  • ERR_REQUIRE_ESM 발생 지점에서 require → import/import()로 전환했는가?
  • __dirname 사용 코드를 import.meta.url 기반으로 바꿨는가?
  • 테스트/린트/설정 파일은 .cjs로 고정했는가?
  • 번들러/TS 출력 포맷과 런타임 로딩 방식이 일치하는가?

운영에서 장애가 나면 원인이 “코드”가 아니라 “환경/설정/권한/네트워크”인 경우도 많습니다. 예를 들어 인증/쿼터 계열 이슈는 앱 로직이 정상이어도 즉시 실패합니다. 그런 경우엔 OpenAI Responses API 401 403 인증오류 점검 가이드처럼 체크리스트 기반으로 범위를 빠르게 좁히는 접근이 도움이 됩니다.

결론: Node 22에서 require 문제의 본질은 “해석 모드”

Node 22에서 require가 안 되는 상황은 대부분 파일이 ESM으로 해석되는 조건을 만족했거나, ESM only 의존성을 CJS 방식으로 불러오려 했기 때문입니다. 해결의 정석은 ESM으로 전환하되, 한 번에 바꾸기보다 .cjs/.mjs를 활용해 점진적으로 경계를 정리하는 것입니다.

가장 추천하는 실전 루트는 다음입니다.

  1. 엔트리부터 ESM로 전환 → 2) 레거시는 .cjs로 고정 → 3) require를 import/import()로 치환 → 4) __dirname 등 런타임 전역 치환 → 5) TS/테스트/린트 설정 정리

이 순서대로 진행하면 Node 22에서도 모듈 시스템 충돌 없이 안정적으로 ESM 전환을 완료할 수 있습니다.