빌드노트
파일은 있는데 ERR_PACKAGE_PATH_NOT_EXPORTED가 난다
node_modules에 파일이 있어도 exports가 공개하지 않은 하위 경로는 import할 수 없다. 같은 파일을 둔 로컬 패키지에서 오류를 비교하고, 공개 진입점을 쓰는 수정과 소유 패키지에 경로를 명시하는 수정을 구분한다.
Node.js에서 demo-kit/lib/format.mjs를 import했는데 ERR_PACKAGE_PATH_NOT_EXPORTED가 나면, 파일을 다시 설치하기 전에 패키지의 exports를 확인한다. 디스크에 있는 파일과 패키지 이름으로 가져올 수 있는 공개 경로는 다르다. 이 오류는 요청한 하위 경로가 공개되지 않았다는 뜻이다. Node.js 오류 설명 (새 탭에서 열림)
오류에 나온 package.json부터 읽는다
Node.js가 설치된 Windows의 PowerShell에서 확인한다. 아래 $pkgDir는 오류 메시지에 나온 package.json의 상위 폴더로 바꾼다. 예시의 demo-kit은 별도 실험용 패키지 이름이다. lib/format.mjs도 자신의 오류에 나온 하위 경로로 바꾼다. 의존성이 중첩 설치돼 있다면 최상위 node_modules를 추측하지 말고 오류가 가리킨 설치본을 읽는다. 이 조회는 설정과 실제 파일 존재 여부를 함께 확인한다.
$pkgDir = 'D:/my-app/node_modules/demo-kit'
Get-Content -Raw -LiteralPath "$pkgDir/package.json"
Test-Path -LiteralPath "$pkgDir/lib/format.mjs" -PathType Leaf
파일 조회가 True여도 exports가 {".": "./index.mjs"}뿐이면 공개한 것은 demo-kit 진입점 하나다. 여기서 .은 패키지 이름 자체에 대응한다. main에 파일을 적어 둬도 하위 경로를 추가로 열어 주지는 않는다. 공식 문서는 두 필드가 함께 있으면 exports가 우선한다고 설명한다. main과 exports의 적용 범위 (새 탭에서 열림)
Get-Content가 실패하면 잘못된 경로나 읽기 오류부터 확인한다. Test-Path가 False면 파일 경로·설치본도 조사해야 한다. 반대로 True이고 요청한 이름이 공개 경로에 없다면, 파일 복사나 재설치만으로 해결할 근거는 없다. 설정을 보려고 require("demo-kit/package.json")을 쓰는 것도 주의한다. ./package.json 자체가 공개되지 않았다면 그 조회 역시 차단될 수 있다. package.json도 공개 제한 대상 (새 탭에서 열림)
같은 파일을 두고 exports만 바꿨다
외부 의존성 없이 로컬 node_modules/demo-kit을 새로 만들어 비교했다. main은 세 구성 모두 ./index.mjs다. 내부 파일 lib/format.mjs의 내용은 export const format = value => 'formatted:' + value; 한 줄이다. index.mjs는 export { format } from './lib/format.mjs';로 그 함수를 다시 내보낸다. 실제 프로젝트 장애 기록이 아닌 합성 패키지 실험이다.
소비자 앱의 package.json이 있는 폴더에서 다음 명령으로 하위 경로를 요청했다. 자신의 앱에서는 패키지 이름·함수 이름·입력을 맞춰야 한다. --input-type=module은 이 한 줄을 ES 모듈로 실행하며, 마지막 줄은 바로 앞 Node 명령의 종료 코드를 보여 준다.
node --input-type=module -e "import { format } from 'demo-kit/lib/format.mjs'; console.log(format('A'));"
"exit=$LASTEXITCODE"
exports없음:demo-kit과demo-kit/lib/format.mjs모두formatted:A, 종료 코드 0이었다.exports가{".": "./index.mjs"}:demo-kit은 같은 출력과 종료 코드 0이었다. 하위 경로만ERR_PACKAGE_PATH_NOT_EXPORTED, 종료 코드 1이었다.- 두 구성의
index.mjs와lib/format.mjs는 각각 같은 바이트였다. 내부 파일 확인도 모두True였다.
실패 메시지에는 Package subpath './lib/format.mjs' is not defined by "exports"가 나왔다. 루트 import가 내부 함수를 정상 호출하므로 파일 내용이나 내부의 상대 import가 깨진 상황과 구분할 수 있었다. 같은 파일에 도달하려 해도 소비자가 요청한 패키지 하위 경로는 공개 목록을 통과해야 했다.
파일이 없을 때도 별도로 확인했다. lib/missing.mjs는 만들지 않았다. exports가 없을 때 이 경로는 ERR_MODULE_NOT_FOUND였지만, 루트만 공개한 구성에서는 ERR_PACKAGE_PATH_NOT_EXPORTED였다. 비공개라는 오류만 보고 파일이 반드시 존재한다고 역으로 판단할 수도 없다. 파일 조회를 따로 하는 이유다.
소비자는 공개 API로, 작성자는 공개 경로를 추가한다
남이 관리하는 패키지라면 그 버전의 문서에서 지원하는 진입점을 찾는다. 이 실험은 루트가 format을 다시 내보내므로 import를 다음처럼 바꿀 수 있었다. 루트에서 같은 함수를 제공하는지 먼저 확인해야 하며, 모든 패키지에서 하위 경로만 지우면 되는 것은 아니다.
node --input-type=module -e "import { format } from 'demo-kit'; console.log(format('A'));"
"exit=$LASTEXITCODE"
루트만 공개한 상태에서도 이 명령은 formatted:A와 exit=0을 출력했다. 공식 하위 진입점이 따로 있다면 그 이름을 사용한다. 필요한 기능이 어느 공개 API에도 없다면 내부 파일 경로에 기대기보다 패키지 작성자에게 지원 경로가 필요한 상황이다.
자신이 관리하는 패키지에서 그 기능을 공개하기로 했다면, 소스의 package.json에 하위 경로를 명시한다. 아래는 진입점 관련 필드이며 이름·버전 등 기존 필드는 유지한다. 실험에서는 기존 파일을 그대로 두고 ./format 매핑을 추가했다.
{
"main": "./index.mjs",
"exports": {
".": "./index.mjs",
"./format": "./lib/format.mjs"
}
}
왼쪽 ./format은 소비자가 쓸 이름이고 오른쪽은 패키지 안의 실제 파일이다. 따라서 소비자는 다음 이름으로 가져온다. 하위 경로 매핑 설명 (새 탭에서 열림)
node --input-type=module -e "import { format } from 'demo-kit/format'; console.log(format('A'));"
"exit=$LASTEXITCODE"
이름을 공개한 구성에서는 formatted:A, exit=0이었다. 같은 구성에서도 demo-kit/lib/format.mjs와 demo-kit/format.mjs는 계속 ERR_PACKAGE_PATH_NOT_EXPORTED였다. 실제 파일의 확장자를 공개 이름 뒤에 붙인다고 다른 이름까지 허용되지는 않았다.
공개 경로를 추가하는 것은 파일을 생성하는 작업도 아니다. 별도 대조로 ./missing을 없는 ./lib/missing.mjs에 연결했더니 이번에는 ERR_MODULE_NOT_FOUND가 났다. 이 오류는 ES 모듈 로더가 모듈 파일을 해결하지 못했다는 뜻이므로 메시지가 가리킨 대상 경로를 확인한다. 오류 코드 하나를 곧바로 패키징 누락의 증거로 삼지 않는다. 모듈 파일 해결 실패 설명 (새 탭에서 열림)
설치된 외부 패키지의 node_modules를 직접 고치는 대신, 소비자 import를 지원 경로로 바꾸거나 소유한 패키지의 소스에서 공개 계약을 고친 뒤 배포본으로 다시 확인한다. 기존 패키지에 exports를 새로 도입할 때는 사용자가 쓰던 지원 경로가 빠지지 않는지도 검토해야 한다. exports 도입 시 호환성 (새 탭에서 열림)
직접 실행한 범위는 Windows 11, PowerShell 7.6.6, Node.js 22.23.2의 일반 ES 모듈 import다. 파일 포함 목록을 고치는 npm pack 문제와 달리, 이번 관찰은 존재하는 파일의 공개 이름을 고르는 문제였다. 레지스트리 발행·재설치, 번들러·TypeScript 해석, CommonJS 및 조건별 exports 분기는 실행하지 않았다. 실제 설정이 조건별 매핑이라면 현재 실행 방식에 해당하는 경로까지 확인해야 한다. 조건별 진입점 설명 (새 탭에서 열림)