본문 바로가기

FrontEnd

ECMAScript(ESM)의 module resolution(모듈 해석)에 대해 알아보자

반응형

프론트엔드 개발자들은 주로 webpack, rollup(vite)에 의존한 모듈 resolution을 자연스럽게 사용하기에,

브라우저에서 ESM을 직접 사용하면, 생각했던 대로 동작하지 않는다는 사실을 알 수 있습니다.

ECMAScript의 모듈 해석 알고리즘을 알아봅시다.

node.js

specifier(지정자)를 사용한 모듈 참조

다른 ECMAScript 모듈의 코드는 import 문(라인 A 및 라인 B)을 통해 액세스됩니다.
// Static import
import {namedExport} from 'https://example.com/some-module.js'; // (A)
console.log(namedExport);

// Dynamic import
import('https://example.com/some-module.js') // (B)
.then((moduleNamespace) => {
  console.log(moduleNamespace.namedExport);
});
정적 가져오기(import)와 동적 가져오기 모두 module specifier를 사용하여 모듈을 참조합니다.
  • 라인 A의 from 이후 문자열
  • 라인 B의 문자열 인수
세 가지 종류의 module specifier가 있습니다.
  • 절대 지정자(absolute specifier) : 전체 URL
  • 상대 지정자(relative specifier) : 상대 URL('/', './' 또는 '../'로 시작)입니다.
    • 상대 지정자는 대부분 동일한 코드 베이스 내의 다른 모듈에 액세스하는 데 사용됩니다. 
      • ./sibling-module.js
      • ../module-in-parent-dir.mjs
      • ../../dir/other-module.js
모든 모듈에는 프로토콜이 해당 리소스의 위치(file:, https: 등)에 따라 달라지는 URL이 있습니다.
상대 지정자를 사용하는 경우 JavaScript는 해당 지정자를 사용해 모듈의 URL을 확인하여 프로토콜을 포함한 전체 URL로 바꿉니다.
  • 기본 지정자(bear specifier) :
    • 슬래시나 점으로 시작하지 않는 경로(프로토콜 및 도메인 없음)입니다.
    • 패키지 이름으로 시작합니다.
    • 해당 이름 뒤에는 선택적으로 하위 경로가 올 수 있습니다.
      • some-package
      • some-package/sync
      • some-package/util/files/hello-world.js
    • 기본 지정자는 scoped name(범위가 지정된 이름을 가진 패키지 명)을 참조할 수 있습니다.
      • @some-scope/scoped-name
      • @some-scope/scoped-name/async
각 기본 지정자는 정확히 패키지 내의 하나의 모듈을 지시합니다.
하위 경로가 없으면 해당 패키지의 지정된 "main" 모듈을 참조합니다.
기본 지정자는 절대 직접 사용되지 않으며, 항상 resolve 되어 사용됩니다 - 절대 지정자로 변환됩니다.
기본 지정자의 resolution 동작 방식은 플랫폼에 따라 다릅니다.

파일 확장자와 모듈 지정자

절대 지정자와 상대 지정자는 항상 파일 이름 확장자를 가집니다(일반적으로 .js 또는 .mjs).
기본 지정자(bear specifier)에는 세 가지 스타일이 있습니다.

  • 스타일 1: 하위 경로 없음
  • 스타일 2: 파일 이름 확장자가 없는 하위 경로. 이 경우 하위 경로는 패키지 이름에 대한 수정자(modifier)처럼 작동합니다.
    • my-parser/sync
    • my-parser/async
  • 스타일 3: 파일 이름 확장자가 있는 하위 경로. 이 경우 패키지는 모듈 컬렉션으로 표시되며 하위 경로는 그 중 하나를 가리킵니다.
    • large-package/misc/util.js
    • large-package/main/parsing.js
    • large-package/main/printing.js
스타일 3 기본 지정자에 대한 주의 사항:
파일 이름 확장자가 해석되는 방식은 종속성에 따라 다르며 임포트 하는 패키지와 종속성의 해석 방식이 다를 수 있습니다.
예를 들어 package import 시 ESM 모듈에 .mjs 확장자를 사용하고 CommonJS 모듈에 .js 확장자를 사용할 수 있는 반면,
종속성에서 내보낸 ESM 모듈에는 파일 확장명이 .js인 기본 경로(bare path)가 있을 수 있습니다.
 

모듈 해석 알고리즘

모듈 지정자에 대해 알아보았으니, node.js가 어떻게 모듈을 가져오는지 알아봅시다.

Node.js resolution 알고리즘(Node.js resolution algorithm)은 다음과 같이 작동합니다.
  • 매개변수:
    • import 대상 모듈의 URL
    • 모듈 지정자
  • 해석 결과: 모듈 지정자의 해석된 URL

알고리즘은 다음과 같습니다.

  • 절대 지정자는 이미 해석이 완료된 것입니다. 세 가지 프로토콜이 가장 일반적입니다.
    • file : 로컬 파일
    • https : 리모트 파일
    • node : node.js 빌트인 모듈
  • 상대 지정자의 경우 import 대상 모듈의 URL을 이용해 해석합니다.
  • 기본 지정자의 경우 package export의 약어입니다
    • '#'으로 시작하는 경우 패키지 가져오기(Package imports)를 이용해 해석합니다
    • 그렇지 않으면 기본 지정자는 다음 중 하나입니다(하위 경로는 선택 사항임).
      • «package»/sub/path
      • @«scope»/«scoped-package»/sub/path
    • 해석 알고리즘은 기본 지정자의 시작 부분과 일치하는 하위 디렉터리가 있는 node_modules 디렉터리를 찾을 때까지 현재 디렉터리와 상위 디렉터리를 순회합니다. 즉, 다음 중 하나입니다.
      • node_modules/«package»/
      • node_modules/@«scope»/«scoped-package»/
      • 기본적으로 패키지 ID 뒤의 (잠재적으로 비어 있는) 하위 경로는 패키지 디렉토리에 상대적인 것으로 해석됩니다.
    • 기본값은 패키지 내보내기(package export)를 통해 재정의할 수 있습니다.
    • 기본 지정자의 경우 파일이 아닌 패키지 단위의 export를 의미합니다.
모듈 해석 알고리즘의 결과는 파일을 나타내야 합니다.
이는 절대 지정자와 상대 지정자가 항상 파일 이름 확장자를 갖는 이유를 설명합니다.
기본 지정자는 패키지 export에서 조회되는 약어이기 때문에 대부분 그렇지 않습니다.
 
모듈 파일은 일반적으로 다음과 같은 파일 이름 확장자를 갖습니다.
  • 모듈 파일은 일반적으로 다음과 같은 파일 이름 확장자를 갖습니다.
  • 이름 확장명이 .js인 파일은 가장 가까운 package.json에 다음 항목이 있는 경우 ES 모듈입니다.
    • "type": "module"

Node.js가 stdin, --eval 또는 --print를 통해 제공된 코드를 실행하는 경우
ES 모듈로 해석되도록 다음 명령줄 옵션(the following command-line option)을 사용합니다.

--input-type=module

패키지 내보내기 : 다른 패키지가 바라볼 것 컨트롤하기

이 하위 섹션에서는 다음 파일 레이아웃이 있는 패키지로 작업합니다.
my-lib/
  dist/
    src/
      main.js
      util/
        errors.js
      internal/
        internal-module.js
    test/
패키지 내보내기(Package exports)는 package.json의 "exports" 속성을 통해 지정되며 두 가지 중요한 기능을 지원합니다.
  • 패키지 내부 숨기기:
    • "exports" 속성이 없으면 my-lib 패키지의 모든 모듈은 패키지 이름 뒤의 상대 경로를 통해 액세스할 수 있습니다. 예:
      • my-lib/dist/src/internal/internal-module.js
    • 속성이 존재하면 속성에 나열된 지정자만 사용할 수 있습니다. 다른 모든 것은 외부에 숨겨져 있습니다.
  • 더 나은 모듈 지정자: 패키지 내보내기를 통해 더 짧거나 더 나은 이름을 가진 모듈의 기본 지정자 하위 경로를 정의할 수 있습니다.
기본 지정자의 세 가지 스타일을 복습합시다,
  • 스타일 1: 하위 경로가 없는 기본 지정자
  • 스타일 2: 확장자가 없는 하위 경로 포함 기본 지정자
  • 스타일 3: 확장자가 있는 하위 경로 포함 기본 지정자
패키지 내보내기는 세 가지 스타일 모두에 도움이 됩니다.

스타일 1: 하위 경로가 없는 기본 지정자

이전 버전과의 호환성을 위해 "main"만 제공합니다(이전 번들러 및 Node.js 12 이하).
최신 버전은 "."면 충분합니다.

package.json:

{
  "main": "./dist/src/main.js",
  "exports": {
    ".": "./dist/src/main.js"
  }
}
이러한 패키지 내보내기를 통해 다른 패키지는 my-lib에서 다음과 같이 임포트할 수 있습니다.
import {someFunction} from 'my-lib';

이는 my-lib/dist/src/main.js 파일에서 해당 함수를 가져옵니다.

스타일 2 : 확장자가 없는 하위 경로 포함 기본 지정자

package.json:

{
  "exports": {
    "./util/errors": "./dist/src/util/errors.js"
  }
}

지정자 하위 경로 'util/errors'를 모듈 파일에 매핑하고 있습니다.
다음과 같은 임포트가 가능합니다.

import {UserError} from 'my-lib/util/errors';

 

단일 항목을 통해 이러한 매핑을 여러 개 만드는 방법도 있습니다.

package.json:

{
  "exports": {
    "./lib/*": "./dist/src/*.js"
  }
}
./dist/src/의 자손인 모든 파일은 이제 파일 이름 확장자 없이 가져올 수 있습니다.
import {someFunction} from 'my-lib/lib/main';
import {UserError}    from 'my-lib/lib/util/errors';
이 "export" 항목의 별표(*;asterisks)에 유의하십시오.
"./lib/*": "./dist/src/*.js"
하위 경로를 실제 경로에 매핑하는 와일드카드 입니다.

스타일 3 : 확장자가 있는 하위 경로 포함 기본 지정자

package.json:

{
  "exports": {
    "./util/errors.js": "./dist/src/util/errors.js"
  }
}

지정자 하위 경로 'util/errors.js'를 모듈 파일에 매핑하고 있습니다.
그러면 다음과 같은 가져오기가 가능합니다.

import {UserError} from 'my-lib/util/errors.js';
이전처럼 *를 이용한 와일드카드 적용도 가능합니다.

package.json:

{
  "exports": {
    "./*": "./dist/src/*"
  }
}
이는 my-package/dist/src 아래 전체 하위 트리의 모듈 지정자를 의미합니다.
import {InternalError} from 'my-package/util/errors.js';
export가 없으면 import 문은 다음과 같습니다.
import {InternalError} from 'my-package/dist/src/util/errors.js';

이것은 파일 시스템 글롭이 아니라 외부 모듈 지정자를 내부 지정자로 매핑하는 방법임에 유의합니다.

"./*": "./dist/src/*"

와일드카드 + 일부 파일 숨기기

다음 트릭을 사용하여 my-package/dist/src/internal/을 제외하고
my-package/dist/src/ 디렉토리의 모든 항목을 노출합니다.

보다시피 이 트릭은 파일명과 확장자 없이 하위 트리를 내보낼 때도 동작합니다.
"exports": {
  "./*": "./dist/src/*",
  "./internal/*": null
}

조건부 패키지 내보내기

내보내기를 조건부(conditional)로 만들 수도 있습니다.
패키지가 사용되는 컨텍스트에 따라 지정된 경로가 다른 값에 매핑됩니다.

Node.js vs browsers

예를 들어 Node.js와 브라우저에 대해 서로 다른 구현을 제공할 수 있습니다.
"exports": {
  ".": {
    "node": "./main-node.js",
    "browser": "./main-browser.js",
    "default": "./main-browser.js"
  }
}

 

새 플랫폼 또는 알려지지 않은 플랫폼을 처리하기 위해 default를 사용합니다.

development vs production

"exports": {
  ".": {
    "development": "./main-development.js",
    "production": "./main-production.js",
  }
}
Node.js에서는 다음과 같이 환경을 지정할 수 있습니다.
node --conditions development app.mjs

패키지 가져오기(Package import)

패키지 가져오기(Package imports)를 사용하면 패키지가 내부적으로 사용할 수 있는 모듈 지정자에 대한 약어를 정의할 수 있습니다.

(주의! : 패키지 내보내기는 다른 패키지를 위한 약어를 정의합니다). 

예를 들면 다음과 같습니다.

package.json:

{
  "imports": {
    "#some-pkg": {
      "node": "some-pkg-node-native",
      "default": "./polyfills/some-pkg-polyfill.js"
    }
  },
  "dependencies": {
    "some-pkg-node-native": "^1.2.3"
  }
}
패키지 가져오기 #는 조건부입니다(조건부 패키지 내보내기와 동일한 기능 포함).
  • 현재 패키지가 Node.js에서 사용되는 경우 모듈 지정자 '#some-pkg'는 패키지 some-pkg-node-native를 나타냅니다.
  • 다른 환경에서 '#some-pkg'는 현재 패키지 내부의 ./polyfills/some-pkg-polyfill.js 파일을 나타냅니다.
패키지 가져오기만 외부 패키지를 참조할 수 있으며 패키지 내보내기는 외부 패키지를 참조할 수 없습니다.
패키지 가져오기의 사용 사례는 무엇입니까?
  • 동일한 모듈 지정자를 통해 다른 플랫폼별 구현 모듈을 참조합니다(위에서 설명한 대로).
    • 같은 npm 패키지인데 일렉트론, 노드, 브라우저에 따라 다른 모듈 제공
  • 현재 패키지 내부의 모듈에 대한 별칭
    • 상대 지정자를 피하기 위해(깊이 중첩된 디렉토리로 인해 복잡해질 수 있음).

번들러와 함께 패키지 가져오기를 사용할 때 주의하세요.
이 기능은 비교적 신기능이며 번들러에서 지원하지 않을 수 있습니다.


node: 프로토콜 가져오기

Node.js에는 'path' 및 'fs'와 같은 많은 내장 모듈이 있습니다.
이들 모두는 ES 모듈과 CommonJS 모듈로 모두 사용할 수 있습니다.
그들과 관련된 한 가지 문제는 node_modules에 설치된 모듈에 의해 재정의될 수 있다는 것입니다.
이는 보안 위험(우발적으로 발생하는 경우)의 원인이 될 수 있으며,
Node.js가 미래에 새로운 내장 모듈을 도입하려고 하지만, 해당 이름이  npm 패키지로 이미 있는 경우 문제가 됩니다. 
 
우리는 node: 프로토콜(the node: protocol)을 사용하여 내장 모듈을 가져오고 싶다는 것을 분명히 할 수 있습니다.
예를 들어, 다음 두 import 문은 대부분 동일합니다(이름이 'fs'인 npm 모듈이 설치되지 않은 경우).
import * as fs from 'node:fs/promises';
import * as fs from 'fs/promises';

node: 프로토콜 사용의 또 다른 이점은 가져온 모듈이 빌트인 모듈임을 즉시 확인할 수 있다는 것입니다.

다른 npm 패키지와 쉽게 구분할 수 있습니다.

 

노드: 프로토콜이 있는 지정자로 인해 절대적인 것으로 간주됩니다.
node_modules에서 조회되지 않는 이유입니다.


참고

https://exploringjs.com/nodejs-shell-scripting/ch_packages.html

 

Packages: JavaScript’s units for software distribution • Shell scripting with Node.js

(Ad, please don’t block.) 5 Packages: JavaScript’s units for software distribution This chapter explains what npm packages are and how they interact with ESM modules. Required knowledge: I’m assuming that you are loosely familiar with the syntax of

exploringjs.com

 

반응형