- Published on
Node 22에서 require가 안 될 때 ESM 전환법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버를 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.json의 type이 불일치하면 런타임에서 모듈 로딩이 꼬입니다.
예: 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는 기본적으로 ESMtype이 없거나commonjs이면.js는 기본적으로 CJS
파일 확장자 규칙
- ESM:
.mjs(항상 ESM) - CJS:
.cjs(항상 CJS) .js는type에 따라 달라짐
이 규칙을 이해하면 “왜 어떤 파일에서는 require가 되고 어떤 파일에서는 안 되지?” 같은 혼란이 크게 줄어듭니다.
ESM 전환의 가장 안전한 전략: 점진 전환(혼합 운영)
대규모 코드베이스에서 한 번에 ESM으로 갈아엎으면, 배포 직전에 예상치 못한 import 경로/확장자/테스트 문제로 폭발하기 쉽습니다. Node는 .cjs/.mjs 확장자로 혼합 운영이 가능하므로, 아래 순서가 안전합니다.
- 새 코드/엔트리부터 ESM으로 시작
- 기존 CJS 파일은
.cjs로 고정 - ESM에서 CJS를 불러올 때는
createRequire또는 default import 규칙을 명확히 - 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 전환이 당장 어렵다면, 충돌 지점을 격리하는 방식으로 시간을 벌 수 있습니다.
- 문제 파일을
.cjs로 변경해 CJS로 강제 - ESM only 패키지는 해당 부분만
import()로 로드 - 프로젝트 루트의
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.json의type과 엔트리 확장자(.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를 활용해 점진적으로 경계를 정리하는 것입니다.
가장 추천하는 실전 루트는 다음입니다.
- 엔트리부터 ESM로 전환 → 2) 레거시는
.cjs로 고정 → 3) require를 import/import()로 치환 → 4)__dirname등 런타임 전역 치환 → 5) TS/테스트/린트 설정 정리
이 순서대로 진행하면 Node 22에서도 모듈 시스템 충돌 없이 안정적으로 ESM 전환을 완료할 수 있습니다.