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

빌드노트

편집기는 찾는데 Node는 못 찾는 경로 별칭, # imports로 확인하기

TypeScript paths에 둔 별칭은 Node의 런타임 매핑이 아니다. 같은 파일을 일반 별칭·# imports·상대 경로로 요청한 결과를 비교하고, 별도 package.json을 가진 패키지에서 # 이름이 어디까지 적용되는지 확인한다.

편집기에서 @app/message.js로 파일을 찾아갈 수 있는데 node로 실행하면 ERR_MODULE_NOT_FOUND가 난다면, 별칭을 어디에 설정했는지 확인한다. 편집기가 아는 경로 매핑과 Node가 실행할 때 쓰는 경로 매핑은 다르다. Node로 직접 실행할 패키지 내부의 별칭은 package.json의 imports에 #message 같은 이름으로 정의할 수 있다.

먼저 오류의 imported from에 나온 파일에서 실제 import 문자열을 읽는다. TypeScript를 빌드한 앱이라면 편집 중인 .ts뿐 아니라 실행하는 .js도 확인한다. 그 문자열이 여전히 @app/...이고 매핑은 tsconfig.json의 compilerOptions.paths에만 있다면, Node에 같은 매핑이 전달됐다고 볼 수 없다.

TypeScript의 paths 문서 (새 탭에서 열림)는 이 설정이 TypeScript의 모듈 검색 위치를 알려 주며, tsc가 출력하는 import 경로를 바꾸지는 않는다고 설명한다. 따라서 편집기에서 오류가 사라진 것만으로 직접 실행도 성공한다고 판단할 수 없다. 개발 서버에서는 되는데 node에서만 실패한다면 개발 서버나 번들러가 별칭을 처리하는지도 확인할 대상이다.

# 이름과 실제 파일을 연결한다

자신이 관리하는 Node 패키지에서 내부 별칭을 쓰려면, 그 코드를 포함하는 package.json의 imports를 확인한다. 아래 설정에서 왼쪽 #message는 코드에서 쓸 이름이고, 오른쪽 ./lib/message.js는 이 package.json이 있는 폴더를 기준으로 한 파일 경로다. 기존 프로젝트에는 필요한 필드를 합치고, 다른 설정을 통째로 덮어쓰지 않는다.

{
  "name": "alias-boundary-lab",
  "private": true,
  "type": "module",
  "imports": {
    "#message": "./lib/message.js"
  }
}

Node의 subpath imports 문서 (새 탭에서 열림)는 이 매핑의 키가 #로 시작해야 하며, 해당 패키지 안의 import에 적용된다고 설명한다. @app/message.js를 그대로 두고 위 설정만 추가하면 #message를 요청한 것이 아니므로 수정이 끝나지 않는다. 가져오는 쪽도 import { message } from '#message';로 맞춘다.

별칭 없이 가까운 파일을 참조하면 충분한 곳은 import { message } from './lib/message.js';처럼 상대 경로를 쓸 수도 있다. 이 상대 경로의 기준은 import를 작성한 파일이다. 반면 위 imports의 대상은 패키지 기준이므로, 같은 패키지의 다른 하위 폴더에서도 #message라는 이름을 쓸 수 있다.

같은 파일을 세 이름으로 요청했다

Windows 11, PowerShell 7.6.6, Node.js v22.23.2에서 외부 패키지 설치 없이 새 ESM 패키지를 만들어 비교했다. 실제 서비스 장애 기록이 아닌 별도 실험이다. 직접 비교하려면 Node.js가 설치된 환경의 새 빈 폴더에 위 package.json을 저장하고, 다음 파일들을 만든다. lib는 하위 폴더다.

  • lib/message.js: export const message = 'outer-ok';
  • plain.js: import { message } from '@app/message.js'; console.log(message);
  • hash.js: import { message } from '#message'; console.log(message);
  • relative.js: import { message } from './lib/message.js'; console.log(message);

실험에는 다음 tsconfig.json도 함께 두었다. @app/*가 lib를 가리킨다는 설정을 둔 상태에서 Node 실행 결과를 비교하기 위한 입력이다. 이 파일로 편집기 탐색이나 tsc를 실행한 것은 아니다.

{
  "compilerOptions": {
    "allowJs": true,
    "noEmit": true,
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "paths": { "@app/*": ["./lib/*"] }
  },
  "include": ["plain.js"]
}

세 import의 차이는 이 폴더의 PowerShell에서 다음 명령으로 확인한다. 각 exit는 바로 앞 Node 명령의 종료 코드이며, 첫 명령의 의도한 실패 뒤에도 다음 명령을 계속 실행한다.

node plain.js
"exit=$LASTEXITCODE"
node hash.js
"exit=$LASTEXITCODE"
node relative.js
"exit=$LASTEXITCODE"
  • plain.js: ERR_MODULE_NOT_FOUND, exit=1이었다. 오류에는 Cannot find package '@app/message.js'가 나왔다.
  • hash.js: outer-ok, exit=0이었다.
  • relative.js: outer-ok, exit=0이었다.

읽을 파일이 없어서 세 경우가 모두 실패한 상황은 아니었다. 같은 lib/message.js를 #message와 상대 경로로는 읽었고, @app/message.js는 패키지 이름으로 찾다가 실패했다. 이 비교에서는 paths를 둔 것만으로 일반 별칭이 Node의 파일 경로가 되지 않았다. 한편 #만 붙여도 되는 것은 아니다. 등록하지 않은 #unknown을 요청한 별도 실행은 ERR_PACKAGE_IMPORT_NOT_DEFINED, 종료 코드 1이었다.

폴더를 옮길 때보다 package.json을 넘을 때 주의한다

같은 패키지의 tools/hash.js에 hash.js의 두 줄을 복사한 실행은 outer-ok였다. tools에는 별도 package.json이 없었다. 이어서 별도 패키지를 둘 만들고 같은 import를 비교했다. 위 실험 폴더에 다음 파일을 추가하면 이 경계를 확인할 수 있다.

  • worker/package.json: {"name":"worker-without-imports","private":true,"type":"module"}
  • worker/hash.js: 앞의 hash.js와 같은 내용.
  • worker-own/package.json: {"name":"worker-with-own-imports","private":true,"type":"module","imports":{"#message":"./lib/message.js"}}
  • worker-own/lib/message.js: export const message = 'inner-ok';
  • worker-own/hash.js: 앞의 hash.js와 같은 내용.

처음 폴더의 PowerShell에서 다음 두 명령은 부모의 매핑을 가져오는지, 각 패키지의 매핑을 쓰는지 구별한다.

node worker/hash.js
"exit=$LASTEXITCODE"
node worker-own/hash.js
"exit=$LASTEXITCODE"

worker/hash.js는 ERR_PACKAGE_IMPORT_NOT_DEFINED, exit=1이었다. 오류는 부모가 아닌 worker/package.json을 가리켰다. worker-own/hash.js는 inner-ok, exit=0이었다. 같은 #message라도 여기서는 worker-own/lib/message.js를 읽었다. 부모 폴더에서 명령을 실행했다는 이유로 부모의 별칭을 쓰지는 않았다.

Node 문서에서 패키지의 범위 (새 탭에서 열림)는 package.json이 있는 폴더부터 다음 package.json이나 node_modules 경계까지다. 해석 알고리즘 (새 탭에서 열림)도 import를 작성한 파일의 패키지를 찾은 뒤 그 패키지의 imports를 조회한다. 별도 패키지에 매핑이 없다고 부모의 imports를 이어서 찾지는 않는다.

부모 패키지의 파일에서 worker/hash.js를 상대 import해 진입한 경우도 같은 오류였고, worker-own/hash.js로 진입한 경우는 inner-ok였다. 반대로 터미널 위치를 worker로 옮겨 부모의 hash.js를 실행해도 출력은 outer-ok였다. 이번 비교에서 기준을 가른 것은 시작 명령의 위치나 최초 진입 파일이 아니라, 해당 #message import를 작성한 파일이 속한 패키지였다.

자신의 앱에서 # 별칭이 실패하면 오류에 나온 package.json의 imports에 요청한 이름이 있는지 확인한다. 일반 별칭만 실패한다면 그 별칭을 처리하는 도구가 실제 실행 경로에도 있는지 확인하고, 직접 Node 실행을 택한다면 내부 import와 # 매핑을 함께 맞춘다. 별도 패키지에서 써야 하는 기능은 그 패키지에 맞는 의존 관계와 진입점으로 설계해야 한다. imports에 이름을 추가하는 것으로 다른 패키지에 공개한 셈이 되지는 않는다.

직접 확인한 범위는 사용자 정의 로더 없이 실행한 로컬 JavaScript의 ESM import다. 편집기 UI의 경로 탐색, TypeScript 컴파일 결과, 번들러 및 CommonJS 동작은 실행하지 않았다. paths의 출력 경로 관련 설명은 공식 문서에 근거하며 이 실험의 측정값으로 제시하지 않는다. TypeScript를 빌드해 배포한다면 실제 실행할 JavaScript와 그 패키지의 package.json, 매핑 대상 파일이 함께 놓인 상태에서 다시 확인해야 한다.

← 전체 글 보기