- Published on
Node.js 22에서 require·ESM 혼용 에러 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Node.js 22로 올린 뒤 갑자기 빌드나 런타임에서 모듈 관련 에러가 폭발하는 경우가 많습니다. 대부분은 CommonJS(require)와 ESM(import)의 혼용 지점에서 발생합니다. 특히 프로젝트가 오래되어 CJS 기반인데, 일부 의존성이 ESM 전용으로 바뀌었거나(또는 반대), 번들러/테스트 러너가 모듈 해석을 다르게 하면서 문제가 표면화됩니다.
이 글에서는 Node.js 22 환경에서 흔히 보는 에러 메시지를 기준으로 원인을 분류하고, 가장 덜 위험한 해결책부터 적용 순서대로 정리합니다.
Node.js 22에서 혼용 문제가 더 잘 드러나는 이유
Node.js는 오래전부터 CJS와 ESM을 모두 지원하지만, 두 시스템은 설계가 다릅니다.
- CJS는 런타임에
require()로 동기 로딩하고module.exports로 내보냅니다. - ESM은 정적 분석 가능한
import/export를 기본으로 하며, 로딩/링킹 단계가 다릅니다.
Node.js 22에서 “갑자기” 문제가 늘어난 이유는 보통 다음 조합 때문입니다.
- 의존성이 메이저 업데이트되며 ESM 전용이 됨(대표적으로 여러 유틸 라이브러리들)
- 패키지의
exports필드가 추가/강화되어 진입점 해석이 달라짐 - 테스트 러너(Jest/Vitest), 트랜스파일러(ts-node/tsx), 번들러가 Node 해석 규칙과 어긋남
문제는 Node 자체가 바뀌었다기보다, 생태계가 ESM 중심으로 이동하면서 혼용 지점이 더 빈번해졌다는 데 있습니다.
증상 1: Error [ERR_REQUIRE_ESM]
가장 흔한 케이스입니다.
- 에러 예:
Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported
원인
CJS 파일에서 ESM 전용 패키지를 require()로 불러오려 했기 때문입니다.
예를 들어 index.cjs에서 ESM 패키지를 이렇게 로드하면 터집니다.
// index.cjs
const chalk = require('chalk');
console.log(chalk.green('hello'));
해결 1) CJS에서 ESM을 동적으로 로드하기(import())
CJS를 당장 ESM으로 못 바꾼다면, 가장 현실적인 우회는 동적 import입니다.
// index.cjs
(async () => {
const { default: chalk } = await import('chalk');
console.log(chalk.green('hello'));
})();
- ESM 패키지가 default export를 제공하는 경우
{ default: ... }형태가 필요할 수 있습니다. - 동적 import는 비동기이므로 호출부 구조를 바꿔야 합니다.
해결 2) 해당 파일을 ESM으로 전환
프로젝트 단위로 ESM 전환이 어렵더라도, 문제가 되는 엔트리/모듈부터 ESM으로 바꾸는 방식이 있습니다.
package.json에"type": "module"을 주면.js는 ESM으로 해석됩니다.- 또는 파일 확장자를
.mjs로 바꿉니다.
{
"type": "module"
}
그리고:
// index.js (ESM)
import chalk from 'chalk';
console.log(chalk.green('hello'));
다만 이 선택은 파급이 큽니다. 기존 CJS 코드의 __dirname, __filename 같은 전역이 사라지고(대체 방법 필요), require도 기본으로는 사용할 수 없습니다.
증상 2: SyntaxError: Cannot use import statement outside a module
- 에러 예:
Cannot use import statement outside a module
원인
Node가 해당 파일을 CJS로 해석했는데, 코드에는 import가 들어있을 때 발생합니다.
대표적인 케이스:
package.json에"type": "module"이 없고.js파일에import를 사용- 또는 실행 환경(테스트 러너)이 파일을 CJS로 돌림
해결
다음 중 하나로 “이 파일은 ESM이다”를 명확히 해줍니다.
- 프로젝트를 ESM으로 통일:
package.json에"type": "module" - 해당 파일만 ESM: 확장자를
.mjs로 변경 - TypeScript/번들러가 있다면, 출력 포맷을 ESM으로 맞춤
간단 예:
node ./src/index.mjs
증상 3: ReferenceError: require is not defined in ES module scope
- 에러 예:
require is not defined in ES module scope
원인
해당 파일이 ESM으로 해석되는데, 코드에서 require()를 사용한 경우입니다.
예:
// index.js (ESM으로 해석됨)
const fs = require('node:fs');
해결 1) ESM 문법으로 변경
import fs from 'node:fs';
Node 내장 모듈은 보통 node:fs, node:path 같은 스킴을 쓰는 것을 권장합니다.
해결 2) ESM에서 createRequire로 CJS 로더 사용
레거시 CJS 모듈을 꼭 require로 불러야 한다면:
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const legacy = require('./legacy.cjs');
console.log(legacy);
이 패턴은 “ESM 안에서만” 제한적으로 require가 필요할 때 유용합니다.
증상 4: Named export ... not found 또는 default import 관련 오류
- 에러 예:
Named export 'x' not found. The requested module ... is a CommonJS module
원인
ESM에서 CJS 모듈을 가져올 때, named export처럼 가져오면 실패할 수 있습니다. CJS는 기본적으로 module.exports 하나만 내보내는 형태이기 때문입니다.
문제 예:
// ESM
import { something } from 'some-cjs-package';
해결
CJS는 default import(또는 namespace import)로 받고 구조분해하는 방식이 안전합니다.
import pkg from 'some-cjs-package';
const { something } = pkg;
또는:
import * as pkg from 'some-cjs-package';
라이브러리마다 interop이 달라서, 실제로는 pkg.default가 필요한 경우도 있습니다. 이 경우는 패키지 문서 또는 타입 정의를 확인해야 합니다.
프로젝트 단위 해결 전략(권장 순서)
혼용 에러를 “그때그때 땜질”하면 기술부채가 빠르게 쌓입니다. 아래 순서대로 정리하면 재발을 줄일 수 있습니다.
1) 먼저 현재 모듈 시스템을 확정한다
- 당분간 CJS 유지할 건지
- ESM으로 전환할 건지
- 또는 패키지(라이브러리)로 배포하면서 dual build를 할 건지
서버 애플리케이션이라면 ESM 전환이 장기적으로 유리한 경우가 많지만, 레거시가 크면 CJS 유지 + 필요한 곳만 import()로 우회하는 게 현실적일 수 있습니다.
2) package.json의 type, main, exports를 점검한다
특히 라이브러리를 만들거나 사내 패키지를 배포한다면 exports 설정이 혼용 문제를 크게 줄입니다.
예: ESM/CJS 둘 다 제공(dual)하는 패턴 예시입니다.
{
"name": "my-lib",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
주의할 점:
- 위처럼 설정하면 소비자는
import/require어느 쪽이든 자연스럽게 동작합니다. - 빌드 산출물도 실제로 ESM 파일과 CJS 파일을 각각 만들어야 합니다.
3) TypeScript를 쓴다면 moduleResolution과 런타임을 맞춘다
TS에서 컴파일은 되는데 Node 런타임에서 깨지는 경우가 많습니다.
- TS의
module설정 moduleResolution이 Node의 ESM 해석과 맞는지- 실행기를
node로 할지,tsx같은 런타임 트랜스파일러를 쓸지
대표적으로 Node 환경에선 moduleResolution을 Node 계열로 두는 것이 안전합니다(프로젝트 상황에 따라 NodeNext 또는 Bundler 선택).
tsconfig.json 예:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist"
}
}
그리고 실행은:
node dist/index.js
4) 테스트 러너/번들러 설정을 모듈 전략에 맞춘다
혼용 에러는 로컬 실행에서는 안 나는데 테스트에서만 나는 식으로 나타납니다. Jest를 쓰는 경우 ESM 지원 설정이 필요할 수 있고, Vitest는 상대적으로 ESM 친화적이지만 여전히 type/확장자/tsconfig에 영향을 받습니다.
문제가 재현되는 최소 케이스를 만들어 “Node로 직접 실행했을 때”와 “테스트 러너로 실행했을 때”의 차이를 비교하세요.
이런 식의 재현-격리 접근은 인프라 장애에서 CrashLoopBackOff 원인을 빠르게 좁히는 방식과 유사합니다. 필요하면 K8s CrashLoopBackOff 즉시 원인 찾는 법처럼, 증상-원인 매핑을 먼저 만들면 시간 낭비가 줄어듭니다.
실전 패턴: CJS 앱에서 ESM 의존성만 안전하게 쓰기
레거시 서버(예: Express)가 CJS인데 일부 최신 라이브러리가 ESM 전용인 상황을 가정해보겠습니다.
문제 코드
// server.cjs
const express = require('express');
const open = require('open'); // open이 ESM 전용이면 여기서 ERR_REQUIRE_ESM
const app = express();
app.listen(3000, () => open('http://localhost:3000'));
해결 코드
// server.cjs
const express = require('express');
const app = express();
app.listen(3000, async () => {
const { default: open } = await import('open');
await open('http://localhost:3000');
});
이 방식은 “앱 전체를 ESM으로 바꾸지 않고”도 문제를 해결할 수 있습니다.
실전 패턴: ESM 앱에서 CJS 유틸을 끌어안기
반대로 ESM으로 전환했는데, 사내 레거시 유틸이 CJS로만 제공되는 경우입니다.
// index.js (ESM)
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const legacyUtil = require('./legacy-util.cjs');
console.log(legacyUtil.doWork());
이 패턴은 전환기(과도기)에 특히 유용합니다.
디버깅 체크리스트
혼용 에러는 “내 코드는 맞는데 왜 Node가 이렇게 해석하지?”에서 시작합니다. 아래를 순서대로 확인하면 대부분 잡힙니다.
- 실행 중인 Node 버전 확인
node -v
- 해당 파일이 ESM인지 CJS인지 결정하는 요소 확인
package.json의type- 파일 확장자
.cjs,.mjs,.js - 실행 경로에 다른
package.json이 있는지(모노레포에서 자주 발생)
- 문제가 되는 패키지의 배포 형태 확인
package.json에type이 무엇인지exports가 있는지- ESM 전용인지(README에 명시되는 경우가 많음)
- 동일 코드를 Node로 직접 실행해보기
테스트/번들러 환경에서만 깨지면, 런타임이 Node가 아니라는 뜻입니다.
마이그레이션 팁: 한 번에 바꾸지 말고 경계부터 바꿔라
대규모 코드베이스에서 ESM 전환을 한 번에 하려다 실패하는 이유는 모듈 시스템 변경이 “문법 변경”이 아니라 “로딩 모델 변경”이기 때문입니다.
권장 접근:
- 외부 경계(엔트리, 어댑터)부터 ESM으로 전환
- 내부는 당분간 CJS 유지, 필요한 곳만
createRequire/import()로 브릿지 - 의존성 업데이트 시 ESM 전용으로 바뀐 패키지를 우선적으로 격리
변경 중 실수로 브랜치가 꼬이거나 되돌려야 한다면, 모듈 전환 작업은 커밋이 많아지기 쉬우니 Git rebase 실수? reflog로 커밋 복구하는 법도 함께 참고해두면 안전합니다.
결론
Node.js 22에서 require와 ESM 혼용 에러는 대개 아래 3가지로 정리됩니다.
- CJS에서 ESM을
require()로 불러옴:import()로 우회하거나 ESM으로 전환 - ESM에서
require()를 씀:import로 바꾸거나createRequire사용 - CJS/ESM interop에서 export 형태가 안 맞음: default 또는 namespace import로 수습
가장 중요한 것은 “현재 프로젝트는 CJS인지 ESM인지”를 먼저 확정하고, 그 결정에 맞춰 package.json과 빌드/테스트 도구 설정을 정렬하는 것입니다. 그 다음에 브릿지 패턴(import(), createRequire)로 과도기를 관리하면 Node.js 22에서도 안정적으로 운영할 수 있습니다.