🔍 검색 키워드: ERR_REQUIRE_ASYNC_MODULE, require esm top-level await, node require esm 에러, experimental-print-required-tla, ERR_REQUIRE_ESM 차이, CJS에서 ESM 불러오기
요약: require()는 ES 모듈을 동기적으로 평가하기 때문에, 불러오려는 모듈이나 그 모듈이 import하는 그래프 어딘가에 top-level await가 하나라도 있으면 Node.js는 ERR_REQUIRE_ASYNC_MODULE을 던진다. 호출하는 쪽을 await import()로 바꾸거나 모듈 쪽의 top-level await를 init() 같은 함수 안으로 옮기면 해결된다. 에러가 가리키는 파일에 await가 보이지 않을 때는 --experimental-print-required-tla 플래그로 실제 위치를 찾는다.
어떤 상황에서 ERR_REQUIRE_ASYNC_MODULE이 발생하나요?
Node.js 공식 문서의 버전 기록에 따르면 require()로 ES 모듈을 불러오는 기능(require(esm))은 v22.0.0과 v20.17.0에서 추가됐고, v23.0.0 · v22.12.0 · v20.19.0부터 --experimental-require-module 플래그 없이 동작한다. v23.5.0 · v22.13.0 · v20.19.0부터는 실험 경고도 기본으로는 출력되지 않고, v25.4.0에서 실험 기능 딱지가 떨어졌다.
그래서 Node 버전을 올리고 나면 예전에 보던 ERR_REQUIRE_ESM은 사라지는데, 대신 이 에러를 만나는 경우가 생긴다. 공식 문서가 정리한 require()가 받아줄 수 있는 ES 모듈의 조건은 다음과 같다.
- 모듈 그래프가 완전히 동기적일 것. 즉 top-level await가 없을 것
- 그리고 다음 중 하나: 확장자가 .mjs이거나
- 확장자가 .js이고 가장 가까운 package.json에 "type": "module"이 있거나
- 확장자가 .js이고 가장 가까운 package.json에 "type": "commonjs"가 없으면서 파일이 ES 모듈 문법을 포함할 것
이 글의 주제는 첫 번째 조건이다. 나머지를 모두 만족해도 await 하나면 require()는 실패한다.
테스트 환경
아래 출력은 전부 이 환경에서 직접 실행한 결과다.
- macOS 27.0.1 (Darwin 27.0.0, arm64)
node --version출력: v20.20.2
재현: 어디까지 되고 어디서 터지는가
1단계. top-level await가 없으면 require()로 잘 불러와진다
// sync.mjs
export const name = "sync-esm";
export function greet() { return "hello from ESM"; }
// main.cjs (CommonJS)
const mod = require("./sync.mjs");
console.log("loaded:", mod.name, "/", mod.greet());직접 실행 결과
$ node main.cjs
loaded: sync-esm / hello from ESMNode 20.20.2에서는 경고 한 줄 없이 그냥 동작했다. 플래그를 붙이지 않았다.
2단계. top-level await를 넣는 순간 터진다
// tla.mjs
const config = await Promise.resolve({ port: 3000 }); // top-level await
export const port = config.port;
// main-tla.cjs
const mod = require("./tla.mjs");
console.log("port:", mod.port);직접 실행 결과 (긴 줄은 가독성을 위해 접었다. 종료 코드는 1이었다.)
$ node main-tla.cjs
node:internal/modules/esm/module_job:433
throw new ERR_REQUIRE_ASYNC_MODULE(filename, parentFilename);
^
Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM
graph with top-level await. Use import() instead. To see where the
top-level await comes from, use --experimental-print-required-tla.
From /tmp/tla-repro/main-tla.cjs
Requiring /tmp/tla-repro/tla.mjs
at ModuleJobSync.runSync (node:internal/modules/esm/module_job:433:13)
at ModuleLoader.importSyncForRequire (node:internal/modules/esm/loader:389:47)
at loadESMFromCJS (node:internal/modules/cjs/loader:1363:24)
code: 'ERR_REQUIRE_ASYNC_MODULE'3단계. 에러가 가리키는 파일에는 await가 없을 수 있다
가장 헷갈리는 지점이다. 직접 require하는 파일이 아니라 그 파일이 import하는 그래프 전체가 검사 대상이다.
// deep.mjs - 여기에만 await가 있다
export const value = await Promise.resolve(42);
// entry.mjs - await가 전혀 없다
import { value } from "./deep.mjs";
export const doubled = value * 2;
// main-nested.cjs
const mod = require("./entry.mjs");
console.log("doubled:", mod.doubled);직접 실행 결과
$ node main-nested.cjs
Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM
graph with top-level await. ...
From /tmp/tla-repro/main-nested.cjs
Requiring /tmp/tla-repro/entry.mjs에러는 entry.mjs를 지목하지만 실제 원인은 deep.mjs다. entry.mjs를 아무리 들여다봐도 await는 없다.
에러 메시지가 가리키는 파일에 await가 없는데 어떻게 찾나요?
Node.js가 제공하는 --experimental-print-required-tla 플래그를 쓰면 된다. 공식 CLI 문서의 설명은 "ES 모듈 그래프가 top-level await를 포함해 require()될 수 없을 때, 이 플래그는 Node.js가 그 위치를 찾아 출력하게 한다"이다.
직접 실행 결과
$ node --experimental-print-required-tla main-nested.cjs
Error: unexpected top-level await at
file:///tmp/tla-repro/deep.mjs:1
export const value = await Promise.resolve(42);
^파일과 줄 번호, 해당 소스 줄까지 정확히 찍어준다.
이 플래그를 쓸 때 주의할 점
Node 20.20.2에서는 이 플래그가 모듈을 실제로 평가하면서 await를 찾는다. 즉 모듈 최상단의 부수 효과가 실행된다. 아래는 모듈 본문에 console.log 한 줄을 넣고 비교한 결과다.
// side.mjs
console.log("[side effect] module body executed");
export const v = await Promise.resolve(1);
$ node main-side.cjs
caught: ERR_REQUIRE_ASYNC_MODULE
$ node --experimental-print-required-tla main-side.cjs
[side effect] module body executed
Error: unexpected top-level await at file:///tmp/tla-repro/side.mjs:2플래그가 없으면 모듈 본문이 실행되지 않고, 플래그를 켜면 실행된다. DB 연결이나 파일 쓰기 같은 부수 효과가 모듈 최상단에 있다면 운영 환경에서 이 플래그를 켜지 않는 편이 안전하다. 공식 문서의 버전 기록을 보면 v26.5.0에서 "모듈을 평가하지 않고 top-level await를 출력한다"로 동작이 바뀌었다.
4단계. 내 코드가 아니라 node_modules 안의 패키지가 원인일 때
실무에서는 보통 이 형태로 만난다. ESM 전용 패키지 안에 top-level await가 있으면 CommonJS 쪽에서는 require()로 쓸 수 없다.
// node_modules/my-esm-pkg/package.json
{ "name": "my-esm-pkg", "type": "module", "exports": "./index.js" }
// node_modules/my-esm-pkg/index.js
const token = await Promise.resolve("secret-token");
export function getToken() { return token; }
// main-pkg.cjs
const { getToken } = require("my-esm-pkg");직접 실행 결과
$ node --experimental-print-required-tla main-pkg.cjs
Error: unexpected top-level await at
file:///tmp/tla-repro/node_modules/my-esm-pkg/index.js:1
const token = await Promise.resolve("secret-token");
^원인: 왜 이런 제약이 있나요?
require()는 호출한 자리에서 값을 반환하는 동기 함수다. 반면 top-level await가 있는 ES 모듈은 평가가 끝날 때까지 기다려야 하는 비동기 모듈이라 반환값이 Promise의 완료에 묶인다. 동기 호출 안에서 비동기 완료를 기다릴 방법이 없으므로 Node.js는 이 조합을 아예 막고 에러를 던진다. 공식 문서도 "require()되는 모듈 또는 그 모듈이 import하는 모듈 그래프에 top-level await가 있으면 ERR_REQUIRE_ASYNC_MODULE이 던져지며, 이 경우 import()로 비동기 모듈을 불러와야 한다"고 못 박는다.
어떻게 해결하나요?
해결 1. 호출하는 쪽을 동적 import()로 바꾼다
가장 확실한 방법이다. CommonJS 파일에서도 import()는 쓸 수 있다.
// fixed.cjs
async function main() {
const mod = await import("./entry.mjs");
console.log("doubled:", mod.doubled);
}
main();직접 실행 결과
$ node fixed.cjs
doubled: 84단점은 전염성이다. 호출부가 동기 함수였다면 async로 바꾸고 그 위로도 await를 전파해야 한다. 모듈 로딩 시점이 바뀌므로 초기화 순서에 의존하는 코드는 함께 점검해야 한다.
해결 2. 모듈 쪽에서 top-level await를 걷어낸다
내가 작성한 모듈이라면 이 방법이 더 낫다. 초기화를 최상단이 아니라 함수 안으로 옮긴다.
// before - CommonJS 소비자를 막는다
export const db = await connect(process.env.DB_URL);
// after - 초기화를 함수로 뺀다
let db;
export async function init() {
if (!db) db = await connect(process.env.DB_URL);
return db;
}라이브러리를 배포한다면 더 중요하다. require() 가능한 그래프에 top-level await를 새로 넣는 것은 CommonJS 사용자 전부에게 깨지는 변경이므로 semver major로 다뤄야 한다.
해결 3. 어떤 require가 ESM을 끌어오는지 추적한다
프로젝트가 크면 어느 require가 ESM을 당겨오는지부터 모를 수 있다. --trace-require-module=all을 붙이면 CJS가 ESM을 require()할 때마다 알려준다.
$ node --trace-require-module=all main.cjs
loaded: sync-esm / hello from ESM
(node:19931) ExperimentalWarning: CommonJS module /tmp/tla-repro/main.cjs
is loading ES Module /tmp/tla-repro/sync.mjs using require().
Support for loading ES Module in require() is an experimental feature
and might change at any time주의할 점과 한계
ERR_REQUIRE_ESM과 혼동하지 않기
두 에러는 원인이 다르다. ERR_REQUIRE_ESM이 지금도 나온다면 require(esm)이 꺼져 있다는 뜻이고, 보통 Node 버전이 낮거나 NODE_OPTIONS에 플래그가 박혀 있는 경우다. 끄고 실행해 보면 차이가 분명하다.
$ node --no-experimental-require-module main.cjs
Error [ERR_REQUIRE_ESM]: require() of ES Module /tmp/tla-repro/sync.mjs
from /tmp/tla-repro/main.cjs not supported.
Instead change the require of /tmp/tla-repro/sync.mjs to a dynamic
import() which is available in all CommonJS modules.
code: 'ERR_REQUIRE_ESM'
$ node --no-require-module main.cjs
node: bad option: --no-require-module참고로 짧은 이름인 --no-require-module은 Node 20.20.2에서는 인식되지 않았다. 20.x LTS에서는 --no-experimental-require-module을 써야 한다.
require(esm)의 반환값은 module.exports가 아니다
에러를 넘기고 나면 다음에 걸리는 지점이다. require(esm)은 CommonJS의 module.exports가 아니라 모듈 네임스페이스 객체를 돌려준다. default export는 .default에 들어간다.
// def.mjs
export default { kind: "default-export" };
export const extra = "named";
// main-def.cjs
const mod = require("./def.mjs");직접 실행 결과
typeof: object
keys: __esModule,default,extra
mod.default: {"kind":"default-export"}
mod.kind: undefineddefault export가 있으면 상호 운용을 위해 __esModule: true가 함께 붙는다. 공식 문서는 이 속성이 실험적이며 ES 모듈을 CommonJS로 변환하는 도구용이니 직접 작성하는 CommonJS 코드가 여기에 의존하지 말라고 안내한다. 모듈 작성자가 반환값을 직접 정하고 싶다면 export { Point as 'module.exports' } 형태를 쓸 수 있지만, 그러면 named export는 CommonJS 소비자에게 보이지 않는다.
이 글이 다루지 않는 것
번들러(webpack, Vite, esbuild)가 개입하는 경우는 동작이 달라질 수 있다. 위 결과는 모두 번들러 없이 Node 20.20.2에서 직접 실행한 것이다.
정리표
| 구분 | 내용 |
| 에러 코드 | ERR_REQUIRE_ASYNC_MODULE |
| 메시지 | require() cannot be used on an ESM graph with top-level await. Use import() instead. |
| 발생 조건 | require() 대상 모듈 또는 그 모듈이 import하는 그래프에 top-level await가 있음 |
| require(esm) 기본 활성 | v23.0.0 · v22.12.0 · v20.19.0 이상 |
| 원인 위치 찾기 | node --experimental-print-required-tla app.cjs |
| 호출부 해결 | const mod = await import("./x.mjs") |
| 모듈 해결 | top-level await를 init() 같은 함수 안으로 이동 |
| 혼동 주의 | ERR_REQUIRE_ESM은 require(esm)이 꺼져 있을 때 나오는 다른 에러 |
| 추적 플래그 | node --trace-require-module=all app.cjs |
자주 묻는 질문(FAQ)
Node 버전만 올리면 해결되나요?
아니다. Node 20.19.0 이상이면 동기 ES 모듈은 require()로 불러올 수 있지만, top-level await가 포함된 모듈은 버전과 무관하게 계속 ERR_REQUIRE_ASYNC_MODULE을 던진다. 버전을 올렸는데도 에러가 남아 있다면 top-level await가 원인이다.
에러가 가리키는 파일에 await가 없는데 왜 에러가 나나요?
검사 대상이 그 파일 하나가 아니라 그 파일이 import하는 모듈 그래프 전체이기 때문이다. 직접 재현해 보면 await가 없는 entry.mjs를 require해도 그 아래 deep.mjs의 await 때문에 에러가 난다. node --experimental-print-required-tla로 실행하면 실제 await의 파일명과 줄 번호가 출력된다.
top-level await를 쓰는 라이브러리를 CommonJS 프로젝트에서 쓰려면 어떻게 하나요?
require()를 await import()로 바꾸는 것이 유일하게 확실한 방법이다. 호출하는 함수를 async로 바꾸고 그 위로 await를 전파해야 한다. 라이브러리에 CommonJS 빌드가 함께 제공되는지, top-level await가 없는 이전 버전이 있는지도 확인할 만하다.
--experimental-print-required-tla를 운영 환경에서 켜도 되나요?
권장하지 않는다. Node 20.20.2에서 직접 확인한 결과, 이 플래그를 켜면 await 위치를 찾기 위해 모듈 본문을 실제로 평가한다. 모듈 최상단에 DB 연결이나 파일 쓰기 같은 부수 효과가 있으면 그대로 실행된다. 로컬 디버깅에서만 쓰는 편이 안전하다.
관련 글: .env 파일인데 환경변수가 undefined — dotenv 로딩 에러 완전 해결 · TypeScript cannot find module 해결 — ts(2307) 에러 완벽 정리
공식 문서: Node.js — Loading ECMAScript modules using require() · Node.js CLI — --experimental-print-required-tla
최종 업데이트: 2026-10-13 · 작성: 내멋스레 사는 이야기