레이블이 ESM인 게시물을 표시합니다. 모든 게시물 표시
레이블이 ESM인 게시물을 표시합니다. 모든 게시물 표시

화요일

ERR_REQUIRE_ASYNC_MODULE 해결 — require()로 ESM 불러올 때 top-level await 에러

🔍 검색 키워드: 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 ESM

Node 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: undefined

default 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 · 작성: 내멋스레 사는 이야기