빌드노트
NODE_PATH로 찾는 패키지, require는 되고 ESM import는 실패한다
NODE_PATH에만 둔 패키지는 CommonJS require로 읽혀도 ESM import로는 찾지 못한다. 실제 해석 위치를 조회하고 같은 로컬 패키지를 node_modules에 놓은 대조로 배치 문제를 구별한다.
Node.js에서 require('패키지명')은 성공하는데 같은 이름의 ESM import가 ERR_MODULE_NOT_FOUND로 실패한다면, 그 패키지가 NODE_PATH에만 놓여 있는지 확인한다. NODE_PATH는 두 모듈 로더가 공유하는 검색 경로가 아니다. CommonJS는 이 추가 경로를 검색하지만 ESM import의 경로 해석에는 쓰이지 않는다. 환경변수를 똑같이 전달해도 결과가 달라질 수 있다. CommonJS의 추가 검색 경로 (새 탭에서 열림), ESM의 NODE_PATH 미사용 (새 탭에서 열림)
require가 실제로 찾은 위치부터 본다
오류의 imported from에 나온 JavaScript 파일과 같은 폴더에서, 앱을 실행할 때와 같은 Node 실행 파일·환경변수로 다음 조회를 실행한다. node-path-probe는 자신이 요청한 정확한 패키지 이름으로 바꾼다. 이 명령은 패키지를 실행하지 않고 CommonJS가 찾는 파일 위치를 확인한다.
node --input-type=commonjs -e "console.log('NODE_PATH=' + process.env.NODE_PATH); console.log(require.resolve('node-path-probe'));"
뒤의 실험에서는 NODE_PATH에 지정한 shared 폴더의 절대 경로와, 그 아래 node-path-probe/index.cjs의 절대 경로가 출력됐다. 이런 결과라면 require의 성공은 ESM에서도 찾을 수 있다는 증거가 아니다. require.resolve의 반환값 (새 탭에서 열림)
조회도 MODULE_NOT_FOUND로 실패한다면 같은 환경과 기준 위치에서 require가 성공한다는 전제부터 다시 확인한다. 반대로 경로가 이미 가까운 node_modules를 가리킨다면 NODE_PATH 의존만으로 실패를 설명할 수 없다. 그 설치본의 진입점과 오류가 가리키는 대상을 확인한다.
호출 방식도 확인해야 한다. .cjs 파일 안에서 쓰더라도 import()는 ESM 로더를 사용한다. CommonJS 파일이라는 이유만으로 그 안의 동적 import까지 NODE_PATH를 검색하지는 않는다. 호출과 로더의 관계 (새 탭에서 열림)
같은 패키지를 두 위치에서 비교했다
Windows 11, PowerShell 7.6.6, Node.js v22.23.2에서 외부 패키지 설치 없이 새 로컬 패키지로 비교했다. 실제 서비스 장애 기록이 아닌 별도 실험이다. 상대 import의 확장자 생략이나 조건부 exports를 섞지 않고, 두 호출 모두 node-path-probe라는 패키지 이름만 요청했다.
직접 비교하려면 Node.js와 PowerShell 7을 준비한다. 새 빈 실습 폴더 안에 shared/node-path-probe와 consumer 폴더를 만든다. 첫 폴더는 패키지 보관 위치이고, 처음의 consumer에는 이 패키지나 node_modules를 두지 않는다. 다음 내용을 shared/node-path-probe/package.json으로 저장해 진입 파일을 명시한다.
{
"name": "node-path-probe",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"main": "./index.cjs"
}
이어서 아래 세 파일을 저장한다. 패키지는 문자열 하나를 내보내고, 두 소비 파일은 그 값을 출력한다.
shared/node-path-probe/index.cjs:module.exports = 'package-ok';consumer/require.cjs:console.log(require('node-path-probe'));consumer/import.mjs:import value from 'node-path-probe'; console.log(value);
.cjs와 .mjs로 소비 파일의 형식을 명시했다. ESM에서도 CommonJS의 module.exports를 default import로 받을 수 있으므로, 여기서 패키지 자체가 CommonJS인 것은 실패 이유가 아니다. CommonJS를 import할 때의 default 값 (새 탭에서 열림)
새 실습용 PowerShell 7 세션에서 실습 폴더로 이동한 뒤 다음 명령을 실행한다. NODE_PATH에는 패키지 폴더 자체가 아니라 그 이름을 자식으로 가진 shared의 절대 경로를 넣는다. 변경은 이 셸과 자식 프로세스에만 적용되며 사용자·시스템 환경변수로 저장하지 않는다. exit는 바로 앞 Node 명령의 종료 코드다.
$env:NODE_PATH = (Resolve-Path ./shared).Path
node ./consumer/require.cjs
"exit=$LASTEXITCODE"
node ./consumer/import.mjs
"exit=$LASTEXITCODE"
require는 package-ok, exit=0을 출력했다. import는 exit=1이었고 오류에 다음 줄이 있었다. 절대 경로와 스택은 생략했다.
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'node-path-probe' imported from ...
이 버전의 오류에는 shared 아래 파일을 가리키는 Did you mean to import 안내도 나왔다. 후보 파일을 안내한 것과 요청한 패키지 이름을 실제로 해석해 로딩한 것은 달랐다. 실행은 여전히 실패했다.
초기 대조에서는 NODE_PATH도 주지 않았을 때 두 호출 모두 실패했다. require는 MODULE_NOT_FOUND, import는 ERR_MODULE_NOT_FOUND였고 종료 코드는 모두 1이었다. 따라서 require의 성공을 기존 설치본이나 내장 모듈을 읽은 결과로 해석하지 않았다. 별도의 .cjs 파일에서 import('node-path-probe')를 호출한 보조 실험도 NODE_PATH만으로는 ERR_MODULE_NOT_FOUND와 종료 코드 1이었다.
패키지를 node_modules에 놓으면 달라진다
같은 실습 폴더와 셸에서 다음 명령으로 패키지를 consumer/node_modules/node-path-probe에 복사한다. 이름, package.json, 소스는 그대로 두고 위치만 추가하는 대조다. 처음 두 호출에는 앞서 지정한 NODE_PATH가 유지되고, 뒤의 두 호출에서는 제거된다.
New-Item -ItemType Directory -Path ./consumer/node_modules | Out-Null
Copy-Item -LiteralPath ./shared/node-path-probe -Destination ./consumer/node_modules -Recurse
node ./consumer/require.cjs
node ./consumer/import.mjs
Remove-Item Env:NODE_PATH
node ./consumer/require.cjs
node ./consumer/import.mjs
네 호출 모두 package-ok를 출력하고 종료 코드 0으로 끝났다. 복사 전후의 두 패키지 파일은 바이트도 같았다. require의 해석 경로는 이제 consumer/node_modules/node-path-probe/index.cjs였고, ESM import도 같은 패키지의 값을 읽었다. 이 대조에서 바꾼 것은 import 문법이나 패키지의 모듈 형식이 아니라 패키지를 찾을 수 있는 위치였다. 기본 ESM 해석기도 패키지 이름은 node_modules를 통해 찾는다. ESM 기본 해석기의 검색 범위 (새 탭에서 열림)
실제 앱에서는 공유 패키지를 소비 프로젝트의 의존성으로 선언하고, 배포본에서도 호출 파일이 찾을 수 있는 node_modules에 설치되게 한다. 위의 수동 복사는 위치 차이만 확인하기 위한 실험이다. 프로젝트 내부 파일을 직접 가져오는 목적이라면 호출 파일을 기준으로 한 실제 상대 파일 경로를 선택할 수도 있다. NODE_PATH 값을 늘리는 것으로 ESM의 검색 경로가 추가되지는 않는다.
직접 실행한 범위는 위 Windows·Node 버전의 기본 로더다. Linux, 다른 Node 버전, 번들러·사용자 정의 로더·심볼릭 링크 배치는 실행하지 않았다. 상대 경로 import와 조건별 exports 선택도 이 실험의 대상이 아니다. 자신의 실행이 번들 결과나 별도 로더를 거친다면 최종 JavaScript의 호출 방식과 패키지 배치를 함께 확인해야 한다.