본문으로 건너뛰기
개발 머꼬
개발 노트JavaScript
hohyeon.dev5

import 한 줄 넣었더니 Cannot use import statement outside a module이 브라우저와 Node에서 따로 난 이유

  • #Common Pitfall
  • #Engineering Note
  • #JavaScript

문제 발생

관리자 페이지와 야간 배치 스크립트가 같이 쓰는 날짜 유틸이 있었습니다. 파일 하나에 함수를 몰아넣고 전역 변수로 쓰던 걸 정리하려고, export로 내보내고 쓰는 쪽에서 import로 가져오게 바꿨습니다.

import { formatDate } from "./date-utils.js";

한 줄 바꿨을 뿐인데 에러가 세 군데서 따로 났습니다. 관리자 페이지에서는 화면이 하얗게 비고 콘솔에 이게 찍혔습니다.

SyntaxError: Cannot use import statement outside a module

배치 스크립트도 똑같은 문장으로 죽었는데, 앞에 경고가 한 줄 더 붙어 있었습니다.

Warning: Failed to load the ES module: /srv/batch/report.js. Make sure to set "type": "module"
in the nearest package.json file or use the .mjs extension.
...
SyntaxError: Cannot use import statement outside a module

경고가 시키는 대로 package.json에 "type": "module"을 넣자, 이번엔 같은 폴더의 다른 배치 스크립트가 죽었습니다.

ReferenceError: require is not defined in ES module scope, you can use import instead

그런데 같은 저장소를 받은 동료 노트북에서는 처음부터 잘 돌았다고 합니다. 경고가 하나 뜨긴 했지만요. 이 글의 메시지는 Node 22.23과 Chromium으로 같은 상황을 만들어 재현한 것이고, 경로만 바꿔 적었습니다.

원인 분석

import는 "모듈"로 읽히는 파일에서만 쓸 수 있습니다. JavaScript 파일은 일반 스크립트로 읽힐 수도, ES 모듈로 읽힐 수도 있습니다. MDN의 JavaScript 모듈 가이드는 이걸 알려 주는 방법으로 <script> 태그의 type="module" 속성, Node.js에서는 .mjs 확장자나 가장 가까운 package.json의 "type": "module"을 듭니다. 이 표시가 없는 파일에서 import 문을 만나면 스크립트 문법으로는 해석할 수 없으니 SyntaxError가 납니다. 같은 문장이지만 브라우저와 Node는 각자 다른 이유로 이 판단을 했습니다.

브라우저는 <script src="main.js">를 무조건 일반 스크립트로 읽습니다. 관리자 페이지의 태그에는 type="module"이 없었습니다. 재현해 보니 같은 파일을 type="module"로 불렀을 때는 결과가 정상으로 나왔고, 속성만 뺐을 때 위 에러가 났습니다.

Node는 파일마다 규칙대로 판단합니다. Node.js의 packages 문서에 따르면 .mjs 파일과, 가장 가까운 package.json의 "type"이 "module"인 .js 파일은 ES 모듈로 취급합니다. 배치 저장소의 package.json에는 "type": "commonjs"가 적혀 있었습니다. 이건 명시적인 표시라서 .js 파일은 전부 CommonJS로 읽히고, 그 안의 import는 문법 에러가 됩니다.

동료 노트북에서 된 건 Node의 문법 감지 때문입니다. 같은 문서의 "Syntax detection" 항목에 따르면, .js 파일인데 package.json이 없거나 "type" 필드가 없는 모호한 경우 Node는 소스를 살펴보고 import, export 같은 ES 모듈 문법이 있으면 ES 모듈로 다룹니다. 이 동작은 v22.7.0과 v20.19.0부터 기본으로 켜졌습니다. 동료는 "type" 필드가 없는 오래된 브랜치를 받아 둔 상태였고, 재현해 보니 이 경우 결과는 제대로 나오면서 이런 경고가 붙었습니다.

[MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///.../report.js is not specified
and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to /.../package.json.

그러니 "누구는 되고 누구는 안 되는" 차이는 Node 버전과 package.json의 "type" 값에서 갈렸던 겁니다.

해결 방안

  1. 브라우저에서는 type="module"을 붙이고, HTTP 서버로 엽니다. <script type="module" src="main.js"></script>로 바꾸면 import가 동작합니다. 여기서 한 번 더 막히기 쉬운 곳이 있습니다. HTML 파일을 더블클릭해서 file://로 열면 이번엔 Access to script at 'file://.../main.js' from origin 'null' has been blocked by CORS policy가 납니다. MDN의 "웹에서 모듈 사용하기" 문서에 따르면 모듈을 쓰기 시작하면 더 이상 file://로 HTML을 볼 수 없습니다. 모듈 요청은 CORS를 쓰고 file:// URL은 절대 같은 출처가 아니기 때문입니다. 문서 권고대로 로컬 HTTP 서버로 띄워 http://localhost:8000/처럼 열면 됩니다. 재현에서도 python3 -m http.server로 띄우자 바로 동작했습니다.

  2. 브라우저의 import 경로는 파일 주소 그대로 씁니다. 번들러에 익숙하면 여기서 또 걸립니다. import { sum } from "./math"처럼 확장자를 빼면 브라우저는 정말로 ./math라는 주소를 요청하고, 재현에서는 404가 났습니다. MDN 설명대로 런타임이 확장자를 붙여 주지 않습니다. import { debounce } from "lodash-es"처럼 패키지 이름만 쓰면 Failed to resolve module specifier "lodash-es". Relative references must start with either "/", "./", or "../".가 납니다. MDN의 import 문서에 따르면 Node는 이런 이름을 node_modules에서 찾지만, 브라우저는 import map이 있어야 이런 이름을 URL로 바꿀 수 있습니다. 패키지를 많이 쓴다면 import map보다 Vite 같은 번들러를 쓰는 쪽이 현실적입니다.

  3. Node에서는 모듈 방식을 파일이나 패키지 단위로 분명히 정합니다. 저장소 전체를 ES 모듈로 옮길 거라면 package.json에 "type": "module"을 넣습니다. 일부 파일만 먼저 옮길 거라면 그 파일 이름을 .mjs로 바꿉니다. "type"을 비워 두고 문법 감지에 맡기는 건 권하지 않습니다. 위 경고대로 다시 파싱하는 비용이 들고, 무엇보다 그 감지가 없는 Node 버전에서는 같은 파일이 에러로 끝나서 "내 컴퓨터에서는 되는" 상황을 만듭니다. 재현에서도 --no-experimental-detect-module로 감지를 끄자, "type"이 없는 같은 파일이 Cannot use import statement outside a module로 죽었습니다.

  4. "type": "module"로 바꾼 뒤 require가 죽으면 에러 메시지를 따라갑니다. Node가 내는 require is not defined in ES module scope 메시지에는 해결 방법까지 적혀 있었습니다. 이 파일은 .js 확장자이고 package.json에 "type": "module"이 있어서 ES 모듈로 다뤄지니, CommonJS로 다루려면 확장자를 .cjs로 바꾸라고요. 당장 다 옮길 수 없는 스크립트는 .cjs로 이름을 바꿔 두고, 옮길 수 있는 건 require를 import로 바꿉니다. ES 모듈에서는 __dirname도 없어지는데, 이건 예전 글 "type: module로 바꿨더니 __dirname is not defined가 난 이유"에 정리해 두었습니다.

  5. CommonJS 쪽에서 ES 모듈을 require해야 한다면 Node 버전과 top-level await를 확인합니다. 반대 방향도 자주 막힙니다. Node 문서의 "require()로 ECMAScript 모듈 불러오기" 항목에 따르면 이 기능은 v23.0.0, v22.12.0, v20.19.0부터 플래그 없이 쓸 수 있고, 모듈 전체가 동기적이어야 합니다. 즉 top-level await가 없어야 합니다. 재현에서 require("./lib.mjs")는 그대로 동작했지만, top-level await가 있는 모듈을 require하자 이렇게 막혔습니다.

Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with top-level await.
Use import() instead.

이럴 때는 메시지대로 await import("./lib.mjs")를 씁니다. 이 기능이 꺼진 상태(--no-experimental-require-module)로 같은 코드를 돌리면 예전 버전에서 보던 ERR_REQUIRE_ESM이 났습니다. CI나 서버의 Node가 오래됐다면 로컬에서 되던 require가 거기서만 이 에러를 낼 수 있으니, engines 필드나 .nvmrc로 Node 버전을 맞춰 둡니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.