본문 바로가기
빌드노트 검색

빌드노트

파일은 있는데 Node.js의 ESM 상대 import가 실패한다

format.js가 있어도 ESM의 ./format은 자동으로 그 파일을 찾지 않는다. 실제 파일 부재와 확장자 생략, 디렉터리 import를 실행 결과로 구별하고 import하는 파일을 기준으로 경로를 고친다.

CommonJS에서 쓰던 require('./format')을 ESM의 import로 옮긴 뒤, 바로 옆에 format.js가 있는데도 ERR_MODULE_NOT_FOUND가 날 수 있다. Node.js의 기본 ESM 로더는 상대 경로 뒤에 .js를 붙여 찾아주지 않는다. 실제 파일이 format.js라면 ./format.js로 적고, tools/index.js라면 디렉터리 이름에서 멈추지 않고 ./tools/index.js까지 적는다. Node.js v22.23.2의 상대 경로와 확장자 규칙 (새 탭에서 열림)

오류가 가리킨 파일 옆을 확인한다

먼저 오류의 Cannot find module 뒤 대상과 imported from 뒤 파일을 함께 읽는다. 상대 경로의 기준은 import를 작성한 파일의 위치다. 터미널에서 현재 열어 둔 폴더와 다를 수 있다. 예를 들어 dist/main.js가 ./format을 가져온다면 확인할 곳은 dist/format.js다. 소스 폴더에 같은 이름의 파일이 있다는 것만으로는 충분하지 않다. 상대 경로의 기준 (새 탭에서 열림)

Windows PowerShell에서 다음 조회로 확장자 없는 이름과 .js 파일을 구별할 수 있다. $importer는 오류의 imported from에 나온 자신의 파일로, format은 실패한 상대 경로로 바꾼다. 이 예시는 그 파일에 ./format이라는 import가 있는 경우다.

$importer = 'D:/my-app/dist/main.js'
$importDir = Split-Path -LiteralPath $importer
Get-Content -LiteralPath $importer
Test-Path -LiteralPath "$importDir/format" -PathType Leaf
Test-Path -LiteralPath "$importDir/format.js" -PathType Leaf

import문이 ./format이고 마지막 두 결과가 False, True라면, 요청한 이름에는 파일이 없고 .js를 붙인 이름에는 있다는 뜻이다. 아래 실험에서도 이 조건으로 실패했다. 둘 다 False라면 .js를 붙이는 수정만으로는 부족하다. 실행하는 파일의 위치와 그 옆에 있어야 할 대상 파일부터 확인한다. Get-Content가 실패했다면 먼저 조회 경로를 바로잡는다.

실제 파일이 format.js와 tools/index.js라면 import를 다음처럼 고친다. 가져오는 label과 tool은 예시의 export 이름이므로 자신의 모듈이 내보내는 이름을 유지한다.

import { label } from './format.js';
import { tool } from './tools/index.js';

모든 상대 import에 무조건 .js를 덧붙이라는 뜻은 아니다. 대상 파일이 .mjs라면 그 이름을 적는다. 디렉터리 문제는 ./tools.js로 바꾸는 것이 아니라, 의도한 진입 파일이 tools/index.js인지 확인하는 문제다. 명시한 파일도 존재하는데 계속 실패하면 새 오류가 같은 import를 가리키는지 다시 읽는다. 그 파일 안의 다른 상대 import에서 실패했을 수도 있다.

같은 파일을 두고 경로만 바꿨다

Windows 11, PowerShell 7.6.6, Node.js v22.23.2에서 새 합성 패키지로 비교했다. 운영 장애 기록이 아니라 별도 재현이다. 외부 패키지는 설치하지 않았고, package.json에 "type": "module"을 명시했다. 이 설정은 이 패키지의 .js를 ES 모듈로 해석하게 한다. type과 파일 확장자의 관계 (새 탭에서 열림)

직접 비교하려면 Node.js가 설치된 환경에서 새 빈 폴더를 열고 아래 세 파일을 만든다. 기존 프로젝트의 package.json을 덮어쓰지 않는다. tools는 하위 폴더이며, absent.js와 확장자 없는 format 파일은 만들지 않는다.

  • package.json: {"type":"module"}
  • format.js: export const label = 'file-ok';
  • tools/index.js: export const tool = 'index-ok';

그 폴더의 PowerShell에서 다음 두 명령으로 확장자 생략과 명시를 비교한다. --input-type=module은 -e에 전달한 코드를 ESM으로 실행한다. 이 한 줄 실행에서는 현재 폴더가 상대 import의 기준이다. 각 exit 출력은 바로 앞 Node 명령의 종료 코드다.

node --input-type=module -e "import { label } from './format'; console.log(label);"
"exit=$LASTEXITCODE"
node --input-type=module -e "import { label } from './format.js'; console.log(label);"
"exit=$LASTEXITCODE"

첫 명령은 ERR_MODULE_NOT_FOUND, exit=1이었다. 오류에는 Did you mean to import "./format.js"?라는 제안도 나왔다. 두 번째는 file-ok, exit=0이었다. 파일 내용을 바꾸지 않고 import에 실제 파일 이름을 적은 것만으로 결과가 달라졌다.

디렉터리도 같은 방식으로 비교한다. tools/index.js가 이미 있어야 한다.

node --input-type=module -e "import { tool } from './tools'; console.log(tool);"
"exit=$LASTEXITCODE"
node --input-type=module -e "import { tool } from './tools/index.js'; console.log(tool);"
"exit=$LASTEXITCODE"

./tools는 ERR_UNSUPPORTED_DIR_IMPORT, exit=1이었다. ./tools/index.js는 index-ok, exit=0이었다. 별도로 ./tools/도 실행했지만 같은 디렉터리 오류와 종료 코드 1이었다. 끝에 슬래시를 붙여도 index.js를 자동으로 선택하지 않았다. 공식 문서도 ESM에서는 디렉터리 인덱스 파일까지 명시하도록 설명한다. 디렉터리 import 오류 (새 탭에서 열림)

파일 자체가 없는 대조군도 실행했다. 첫 명령의 대상을 ./absent.js로 바꾸면 확장자가 있어도 ERR_MODULE_NOT_FOUND, exit=1이었다. 이 오류 코드 하나로 실제 파일 누락과 확장자 생략을 구별할 수는 없다. 요청한 경로와 실제 파일 이름을 대조해야 한다. 공식 오류 설명도 ESM 로더가 모듈 파일을 찾지 못했다는 범위로 설명한다. ERR_MODULE_NOT_FOUND (새 탭에서 열림)

CommonJS에서 되던 경로가 그대로 통하지 않는 이유

CommonJS 대조는 별도 "type": "commonjs" 패키지와 .cjs 실행 파일에서 했다. 같은 문자열을 CommonJS 문법으로 내보내는 format.js와 tools/index.js를 두고, require('./format')과 require('./tools')를 각각 실행했다. 둘 다 정상 문자열을 출력하고 종료 코드 0이었다. ESM 파일에 require를 섞어서 비교하지 않았다.

공식 문서는 require() 쪽은 정확히 일치하는 파일이 없으면 확장자를 붙여 찾고 폴더 진입점도 찾지만, ESM 쪽은 그런 탐색을 하지 않는다고 구분한다. "type": "module"로 바꿔도 예전 탐색 방식까지 유지되는 것은 아니다. 두 로더의 해석 차이 (새 탭에서 열림)

확인한 범위는 사용자 정의 로더 없이 Node.js로 직접 실행한 로컬 JavaScript다. 파일에 적은 정적 import와 위 PowerShell의 한 줄 명령을 모두 실행했다. some-package/feature처럼 패키지 이름으로 가져올 때의 exports 공개 경로는 별도 규칙이다. 번들러, TypeScript 경로 별칭, 다른 운영체제에서의 동작은 이번에 실행하지 않았다.

← 전체 글 보기