JavaScript 모듈
JavaScript 모듈 (JavaScript modules)
이 가이드는 JavaScript 모듈 문법을 시작하는 데 필요한 모든 것을 제공한다.
본문
모듈에 대한 배경 (A background on modules)
JavaScript 프로그램은 처음에는 아주 작게 시작했다. 초기에는 대부분 격리된 스크립팅 작업을 하고, 필요할 때 웹페이지에 약간의 상호작용성을 제공하는 수준이었으므로 큰 스크립트는 일반적으로 필요하지 않았다. 몇 년이 지난 지금 우리는 많은 JavaScript로 브라우저에서 실행되는 완전한 애플리케이션은 물론, 다른 컨텍스트(예: Node.js)에서 사용되는 JavaScript도 보유하게 되었다.
복잡한 프로젝트는 JavaScript 프로그램을 필요할 때 가져올 수 있는 별도의 모듈로 분할하는 메커니즘을 필요로 한다. Node.js는 오랫동안 이 능력을 가져왔고, 모듈 사용을 가능하게 하는 많은 JavaScript 라이브러리와 프레임워크가 있다(예: RequireJS, webpack, Babel 같은 다른 CommonJS 및 AMD 기반 모듈 시스템).
모든 현대 브라우저는 트랜스파일링 없이 모듈 기능을 기본적으로 지원한다. 이것은 좋은 일일 뿐이다 — 브라우저가 모듈 로딩을 최적화할 수 있어, 라이브러리를 사용하고 추가적인 클라이언트 측 처리와 왕복(round trip)을 하는 것보다 더 효율적이다. 그러나 webpack 같은 번들러를 쓸모없게 만들지는 않는다 — 번들러는 여전히 코드를 합리적인 크기의 청크로 분할하는 일을 잘 하며, minification, dead code elimination, tree-shaking 같은 다른 최적화도 할 수 있다.
예제 소개 (Introducing an example)
모듈 사용을 보여주기 위해 GitHub에서 찾을 수 있는 일련의 예제를 만들었다. 이 예제들은 웹페이지에 <canvas> 요소를 만들고 캔버스에 서로 다른 도형을 그리는(그리고 그것에 대한 정보를 보고하는) 모듈 집합을 보여준다. 이것들은 다소 단순하지만, 모듈을 명확히 보여주기 위해 의도적으로 단순하게 유지했다.
참고: 예제를 다운로드해 로컬에서 실행하려면 로컬 웹 서버를 통해 실행해야 한다.
기본 예제 구조 (Basic example structure)
첫 번째 예제(basic-modules)에서 파일 구조는 다음과 같다.
index.html
main.js
modules/
canvas.js
square.js
참고: 이 가이드의 모든 예제는 기본적으로 같은 구조를 가진다. 위 구조는 곧 매우 익숙해질 것이다.
modules 디렉터리의 두 모듈은 다음과 같다.
- canvas.js — 캔버스 설정에 관련된 함수를 포함한다.
create()— 지정된 ID를 가진 래퍼<div>안에 지정된 너비와 높이로 캔버스를 만든다. 그 래퍼는 지정된 부모 요소 안에 추가된다. 캔버스의 2D 컨텍스트와 래퍼의 ID를 포함한 객체를 반환한다.createReportList()— 지정된 래퍼 요소 안에 추가되는 순서 없는 목록을 만든다. 보고 데이터를 출력하는 데 사용할 수 있다. 목록의 ID를 반환한다.
- square.js — 다음을 포함한다.
name—'square'문자열을 포함하는 상수.draw()— 지정된 캔버스에 지정된 크기·위치·색으로 사각형을 그린다. 사각형의 크기·위치·색을 포함한 객체를 반환한다.reportArea()— 변의 길이가 주어지면 사각형의 넓이를 특정 보고 목록에 쓴다.reportPerimeter()— 변의 길이가 주어지면 사각형의 둘레를 특정 보고 목록에 쓴다.
참고 — .mjs 대 .js (Aside — .mjs versus .js)
이 글 전체에서 모듈 파일에 .js 확장자를 사용했지만, 다른 자료에서는 .mjs 확장자를 사용하는 것을 볼 수 있다. 예를 들어 V8의 문서는 이것을 권장한다. 그 이유는:
- 명확성에 좋다. 즉 어떤 파일이 모듈이고 어떤 것이 일반 JavaScript인지 명확하게 해준다.
- 모듈 파일이 Node.js 같은 런타임과 Babel 같은 빌드 도구에서 모듈로 파싱되도록 보장한다.
그러나 우리는 적어도 지금은 .js를 계속 사용하기로 결정했다. 브라우저에서 모듈이 올바르게 동작하려면 서버가 text/javascript 같은 JavaScript MIME 타입을 포함한 Content-Type 헤더로 그것들을 서빙해야 한다. 그렇지 않으면 "The server responded with a non-JavaScript MIME type" 같은 엄격한 MIME 타입 검사 오류가 나고 브라우저는 JavaScript를 실행하지 않을 것이다. 대부분의 서버는 .js 파일에는 올바른 타입을 이미 설정하지만 .mjs 파일에는 아직 그렇지 않다. .mjs 파일을 이미 올바르게 서빙하는 서버에는 GitHub Pages와 Node.js용 http-server가 있다.
이미 그런 환경을 사용하고 있거나, 그렇지 않아도 알고 있고 접근 권한이 있다면(즉 .mjs 파일에 올바른 Content-Type을 설정하도록 서버를 구성할 수 있다면) 괜찮다. 그러나 우리처럼 파일을 서빙하는 서버를 통제할 수 없거나 공개용으로 파일을 게시한다면 혼란을 일으킬 수 있다.
학습과 이식성 목적을 위해 우리는 .js를 유지하기로 했다. 모듈에 .mjs를, "일반" JavaScript 파일에 .js를 사용하는 명확성을 정말 중시하지만 위에서 설명한 문제를 겪고 싶지 않다면, 개발 중에는 .mjs를 사용하고 빌드 단계에서 .js로 변환할 수도 있다.
또한 알아둘 만한 점:
- 어떤 도구는
.mjs를 결코 지원하지 않을 수 있다. <script type="module">속성은 모듈이 가리켜지고 있음을 나타내는 데 사용된다. 이는 "Applying the module to your HTML"에서 설명한다.
모듈 기능 내보내기 (Exporting module features)
모듈 기능에 접근하기 위해 가장 먼저 하는 일은 그것들을 **export(내보내기)**하는 것이다. 이것은 export 문장으로 수행된다.
가장 쉬운 사용법은 모듈에서 내보내고 싶은 항목들 앞에 배치하는 것이다. 예:
export const name = "square";
export function draw(ctx, length, x, y, color) {
ctx.fillStyle = color;
ctx.fillRect(x, y, length, length);
return { length, x, y, color };
}
함수, var, let, const, 그리고 — 나중에 보듯 — 클래스를 내보낼 수 있다. 그것들은 최상위 항목이어야 한다. 예를 들어 함수 안에서 export를 사용할 수는 없다.
내보내고 싶은 모든 항목을 내보내는 더 편리한 방법은 모듈 파일 끝에 단일 export 문장을 두고, 내보내려는 기능들의 쉼표로 구분된 목록을 중괄호로 감싸는 것이다. 예:
export { name, draw, reportArea, reportPerimeter };
스크립트로 기능 가져오기 (Importing features into your script)
모듈에서 어떤 기능을 내보냈으면 그것들을 사용하기 위해 스크립트로 가져와야 한다. 가장 간단한 방법은 다음과 같다.
import { name, draw, reportArea, reportPerimeter } from "./modules/square.js";
import 문장 다음에 가져올 기능들의 쉼표로 구분된 목록을 중괄호로 감싸고, 그다음 키워드 from, 그다음 **모듈 지정자(module specifier)**를 사용한다.
모듈 지정자는 JavaScript 환경이 모듈 파일의 경로로 해석할 수 있는 문자열을 제공한다. 브라우저에서 이것은 사이트 루트에 상대적인 경로일 수 있다. 예제에서는 점(.) 문법으로 "현재 위치"를 의미하고 그 뒤에 찾으려는 파일의 상대 경로를 붙인다. 매번 전체 절대 경로를 쓰는 것보다 훨씬 좋다. 상대 경로는 더 짧고 URL을 이식 가능하게 만들기 때문이다 — 예제는 사이트 계층의 다른 위치로 옮겨도 여전히 동작한다.
예를 들어 /js-examples/module-examples/basic-modules/modules/square.js는 ./modules/square.js가 된다. 이런 줄을 main.js에서 볼 수 있다.
참고: 일부 모듈 시스템에서는 상대 경로나 절대 경로가 아니고 파일 확장자가 없는
modules/square같은 모듈 지정자를 사용할 수 있다. 이런 종류의 지정자는 먼저 import map을 정의하면 브라우저 환경에서 사용할 수 있다.
스크립트에 기능을 가져온 후에는 같은 파일 안에 정의된 것처럼 사용할 수 있다. 다음은 main.js의 import 줄 아래에 있는 내용이다.
const myCanvas = create("myCanvas", document.body, 480, 320);
const reportList = createReportList(myCanvas.id);
const square = draw(myCanvas.ctx, 50, 50, 100, "blue");
reportArea(square.length, reportList);
reportPerimeter(square.length, reportList);
참고: 가져온 값은 내보낸 기능의 읽기 전용 뷰다.
const변수와 유사하게 가져온 변수를 재할당할 수는 없지만, 객체 값의 속성은 여전히 수정할 수 있다. 그 값은 내보내는 모듈에 의해서만 재할당될 수 있다. 예는 import 참조를 보라.
import map으로 모듈 가져오기 (Importing modules using import maps)
위에서 브라우저가 절대 URL이거나 문서의 base URL로 해석되는 상대 URL인 모듈 지정자로 모듈을 가져올 수 있는 방법을 봤다.
import { name as circleName } from "https://example.com/shapes/circle.js";
import { name as squareName, draw } from "./shapes/square.js";
import map(import maps)은 개발자가 모듈을 가져올 때 모듈 지정자에 원하는 거의 모든 텍스트를 지정할 수 있게 해준다. map은 모듈 URL이 해석될 때 그 텍스트를 대체할 해당 값을 제공한다.
예를 들어 아래 import map의 imports 키는 "모듈 지정자 map" JSON 객체를 정의한다. 속성 이름을 모듈 지정자로 사용할 수 있고, 브라우저가 모듈 URL을 해석할 때 해당 값이 대체된다. 값은 절대 또는 상대 URL이어야 한다. 상대 URL은 import map을 포함한 문서의 base URL을 사용해 절대 URL 주소로 해석된다.
<script type="importmap">
{
"imports": {
"shapes": "./shapes/square.js",
"shapes/square": "./modules/shapes/square.js",
"https://example.com/shapes/square.js": "./shapes/square.js",
"https://example.com/shapes/": "/shapes/square/",
"../shapes/square": "./shapes/square.js"
}
}
</script>
import map은 type 속성이 importmap으로 설정된 <script> 요소 안의 JSON 객체로 정의된다. import map은 문서에만 적용된다는 점에 주목하라 — 사양은 worker 또는 worklet 컨텍스트에서 import map을 적용하는 방법을 다루지 않는다.
이 map으로 위의 속성 이름들을 모듈 지정자로 사용할 수 있다. 모듈 지정자 키에 후행 슬래시가 없으면 전체 모듈 지정자 키가 일치·대체된다. 예를 들어 아래에서 베어 모듈 이름을 일치시키고 URL을 다른 경로로 재매핑한다.
// 베어 모듈 이름을 모듈 지정자로 사용
import { name as squareNameOne } from "shapes";
import { name as squareNameTwo } from "shapes/square";
// URL을 다른 URL로 재매핑
import { name as squareNameThree } from "https://example.com/shapes/square.js";
모듈 지정자에 후행 슬래시가 있으면 값에도 있어야 하며, 키는 "경로 접두사(path prefix)"로 일치된다. 이는 전체 URL 클래스의 재매핑을 허용한다.
// URL을 접두사로 재매핑 ( https://example.com/shapes/ )
import { name as squareNameFour } from "https://example.com/shapes/moduleshapes/square.js";
import map의 여러 키가 모듈 지정자에 대해 유효한 일치가 될 수 있다. 예를 들어 shapes/circle/ 모듈 지정자는 shapes/와 shapes/circle/ 키 모두와 일치할 수 있다. 이 경우 브라우저는 가장 구체적인(가장 긴) 일치 모듈 지정자 키를 선택한다.
import map은 (Node.js처럼) 베어 모듈 이름으로 모듈을 가져올 수 있게 하고, 파일 확장자 유무와 상관없이 패키지에서 모듈을 가져오는 것을 시뮬레이션할 수도 있다. 위에서 보여주지 않았지만, 모듈을 가져오는 스크립트의 경로에 따라 라이브러리의 특정 버전을 가져올 수도 있다. 일반적으로 import map은 개발자가 더 인체공학적인 import 코드를 작성하고, 사이트가 사용하는 모듈의 서로 다른 버전과 종속성을 관리하기 쉽게 만든다. 이는 같은 JavaScript 라이브러리를 브라우저와 서버에서 모두 사용하기 위한 노력을 줄일 수 있다.
다음 절들은 위에서 개요를 설명한 다양한 기능을 확장한다.
기능 감지 (Feature detection)
HTMLScriptElement.supports() 정적 메서드(그 자체로 널리 지원됨)로 import map 지원을 확인할 수 있다.
if (HTMLScriptElement.supports?.("importmap")) {
console.log("Browser supports import maps.");
}
모듈을 베어 이름으로 가져오기 (Importing modules as bare names)
Node.js 같은 일부 JavaScript 환경에서는 모듈 지정자에 베어 이름을 사용할 수 있다. 이는 환경이 모듈 이름을 파일 시스템의 표준 위치로 해석할 수 있기 때문에 동작한다. 예를 들어 "square" 모듈을 가져오는 데 다음 문법을 사용할 수 있다.
import { name, draw, reportArea, reportPerimeter } from "square";
브라우저에서 베어 이름을 사용하려면 브라우저가 모듈 지정자를 URL로 해석하는 데 필요한 정보를 제공하는 import map이 필요하다. (JavaScript는 모듈 위치로 해석할 수 없는 모듈 지정자를 가져오려 하면 TypeError를 던진다.)
아래에 square 모듈 지정자 키를 정의하는 map이 보인다. 이 경우 상대 주소 값으로 매핑된다.
<script type="importmap">
{
"imports": {
"square": "./shapes/square.js"
}
}
</script>
이 map으로 모듈을 가져올 때 베어 이름을 사용할 수 있다.
import { name as squareName, draw } from "square";
모듈 경로 재매핑 (Remapping module paths)
지정자 키와 관련 값 모두에 후행 슬래시(/)가 있는 모듈 지정자 map 항목은 경로 접두사로 사용될 수 있다. 이는 한 위치에서 다른 위치로의 전체 import URL 집합의 재매핑을 허용한다. Node 생태계에서 볼 수 있는 "패키지와 모듈" 작업의 에뮬레이션에도 사용될 수 있다.
참고: 후행
/는 모듈 지정자 키가 모듈 지정자의 일부로 대체될 수 있음을 나타낸다. 이것이 없으면 브라우저는 전체 모듈 지정자 키만 일치(및 대체)한다.
모듈 패키지 (Packages of modules)
다음 JSON import map 정의는 lodash를 베어 이름으로, lodash/ 모듈 지정자 접두사를 /node_modules/lodash-es/ 경로(문서 base URL로 해석)로 매핑한다.
{
"imports": {
"lodash": "/node_modules/lodash-es/lodash.js",
"lodash/": "/node_modules/lodash-es/"
}
}
이 매핑으로 베어 이름으로 전체 "패키지"를 가져오는 것과, (경로 매핑을 사용해) 그 안의 모듈을 가져오는 것 모두 할 수 있다.
import _ from "lodash";
import fp from "lodash/fp.js";
위의 fp를 .js 파일 확장자 없이 가져오는 것도 가능하지만, 경로를 사용하는 대신 해당 파일에 대한 베어 모듈 지정자 키(예: lodash/fp)를 만들어야 한다. 이것은 모듈 하나에 대해서는 합리적일 수 있지만, 많은 모듈을 가져오려 한다면 확장성이 떨어진다.
일반적인 URL 재매핑 (General URL remapping)
모듈 지정자 키가 경로일 필요는 없다 — 절대 URL(또는 ./, ../, / 같은 URL형 상대 경로)일 수도 있다. 절대 경로를 가진 모듈을 자신의 로컬 리소스로 재매핑하고 싶다면 유용할 수 있다.
{
"imports": {
"https://www.unpkg.com/moment/": "/node_modules/moment/"
}
}
버전 관리를 위한 범위 있는 모듈 (Scoped modules for version management)
Node 같은 생태계는 npm 같은 패키지 관리자를 사용해 모듈과 그 종속성을 관리한다. 패키지 관리자는 각 모듈이 다른 모듈과 종속성과 분리되도록 보장한다. 결과적으로 복잡한 애플리케이션이 모듈 그래프의 서로 다른 부분에서 여러 버전의 같은 모듈을 여러 번 포함할 수 있지만, 사용자는 이 복잡성에 대해 생각할 필요가 없다.
참고: 상대 경로를 사용해서도 버전 관리를 달성할 수 있지만, 이것은 무엇보다도 프로젝트에 특정 구조를 강제하고 베어 모듈 이름을 사용하지 못하게 하므로 열등하다.
import map은 마찬가지로 애플리케이션에 여러 버전의 종속성을 두고 같은 모듈 지정자로 그것들을 참조할 수 있게 한다. 이것은 scopes 키로 구현한다. scopes는 import를 수행하는 스크립트의 경로에 따라 사용될 모듈 지정자 map을 제공할 수 있게 한다. 아래 예제가 이것을 보여준다.
{
"imports": {
"cool-module": "/node_modules/cool-module/index.js"
},
"scopes": {
"/node_modules/dependency/": {
"cool-module": "/node_modules/some/other/location/cool-module/index.js"
}
}
}
이 매핑으로 /node_modules/dependency/를 포함한 URL을 가진 스크립트가 cool-module을 가져오면 /node_modules/some/other/location/cool-module/index.js의 버전이 사용된다. imports의 map은 범위 있는 map에서 일치하는 범위가 없거나, 일치하는 범위에 일치하는 지정자가 없으면 폴백으로 사용된다. 예를 들어 일치하지 않는 범위 경로를 가진 스크립트에서 cool-module을 가져오면 imports의 모듈 지정자 map이 대신 사용되어 /node_modules/cool-module/index.js의 버전으로 매핑된다.
범위를 선택하는 데 사용되는 경로는 주소가 해석되는 방식에 영향을 주지 않는다는 점에 주목하라. 매핑된 경로의 값이 범위 경로와 일치할 필요는 없으며, 상대 경로는 여전히 import map을 포함한 스크립트의 base URL로 해석된다.
모듈 지정자 map과 마찬가지로 많은 범위 키를 가질 수 있고, 이들은 겹치는 경로를 포함할 수 있다. 여러 범위가 referrer URL과 일치하면, 가장 구체적인 범위 경로(가장 긴 범위 키)가 일치하는 지정자에 대해 먼저 검사된다. 브라우저는 일치하는 지정자가 없으면 다음으로 가장 구체적인 일치 범위 경로로 폴백하고, 이런 식으로 계속된다. 일치하는 범위 어디에도 일치하는 지정자가 없으면 브라우저는 imports 키의 모듈 지정자 map에서 일치를 확인한다.
해시된 파일명을 매핑해 캐싱 개선하기 (Improve caching by mapping away hashed filenames)
웹사이트가 사용하는 스크립트 파일은 캐싱을 단순화하기 위해 해시된 파일명을 가지는 경우가 많다. 이 접근 방식의 단점은 모듈이 변경되면 해시된 파일명으로 그것을 import하는 모듈들도 갱신/재생성되어야 한다는 것이다. 이는 잠재적으로 갱신의 연쇄를 초래해 네트워크 리소스를 낭비한다.
import map은 이 문제에 대한 편리한 해결책을 제공한다. 특정 해시된 파일명에 의존하는 대신 애플리케이션과 스크립트는 모듈 이름(주소)의 해시되지 않은 버전에 의존한다. 아래 같은 import map이 실제 스크립트 파일에 대한 매핑을 제공한다.
{
"imports": {
"main_script": "/node/srcs/application-fg7744e1b.js",
"dependency_script": "/node/srcs/dependency-3qn7e4b1q.js"
}
}
dependency_script가 변경되면 파일명에 포함된 그 해시도 변경된다. 이 경우 모듈의 변경된 이름을 반영하기 위해 import map만 갱신하면 된다. import 문장의 지정자는 변경되지 않으므로 그것에 의존하는 JavaScript 코드의 소스를 갱신할 필요가 없다.
비-JavaScript 리소스 로딩 (Loading non-JavaScript resources)
통합된 모듈 아키텍처가 가져오는 흥미로운 기능 중 하나는 비-JavaScript 리소스를 모듈로 로드하는 능력이다. 예를 들어 JSON을 JavaScript 객체로 import하거나 CSS를 CSSStyleSheet 객체로 import할 수 있다.
가져오는 리소스의 종류를 명시적으로 선언해야 한다. 기본적으로 브라우저는 리소스가 JavaScript라고 가정하고, 해석된 리소스가 다른 것이면 오류를 던진다. JSON, CSS, 또는 다른 타입의 리소스를 import하려면 import attributes 문법을 사용한다.
import colors from "./colors.json" with { type: "json" };
import styles from "./styles.css" with { type: "css" };
브라우저는 모듈 타입에 대한 검증도 수행하며, 예를 들어 ./data.json이 JSON 파일로 해석되지 않으면 실패한다. 이는 데이터를 import하려는 의도로 실수로 코드를 실행하지 않도록 보장한다. 성공적으로 import되면 가져온 값을 일반 JavaScript 객체나 CSSStyleSheet 객체처럼 사용할 수 있다.
console.log(colors.map((color) => color.value));
document.adoptedStyleSheets = [styles];
모듈을 HTML에 적용하기 (Applying the module to your HTML)
이제 main.js 모듈을 HTML 페이지에 적용하기만 하면 된다. 이것은 일반 스크립트를 페이지에 적용하는 것과 매우 유사하지만 몇 가지 주목할 만한 차이가 있다.
먼저 이 스크립트를 모듈로 선언하려면 <script> 요소에 type="module"을 포함해야 한다. main.js 스크립트를 import하려면 다음을 사용한다.
<script type="module" src="main.js"></script>
<script> 요소 본문 안에 JavaScript 코드를 배치해 모듈의 스크립트를 HTML 파일에 직접 임베드할 수도 있다.
<script type="module">
/* JavaScript module code here */
</script>
import와 export 문장은 일반 스크립트가 아니라 모듈 안에서만 사용할 수 있다. <script> 요소에 type="module" 속성이 없는데 다른 모듈을 import하려 하면 오류가 던져진다. 예:
<script>
import _ from "lodash"; // SyntaxError: import declarations may only appear at top level of a module
// …
</script>
<script src="a-module-using-import-statements.js"></script>
<!-- SyntaxError: import declarations may only appear at top level of a module -->
일반적으로 모든 모듈을 별도의 파일에 정의해야 한다. HTML에 인라인으로 선언된 모듈은 다른 모듈을 import만 할 수 있고, 내보낸 것은 (URL이 없으므로) 다른 모듈이 접근할 수 없다.
참고: 모듈과 그 종속성은
rel="modulepreload"를 가진<link>요소에 지정해 미리 로드할 수 있다. 이는 모듈이 사용될 때 로드 시간을 크게 줄일 수 있다.
모듈과 클래식 스크립트의 다른 차이점 (Other differences between modules and classic scripts)
로컬 테스트에 주의해야 한다 — HTML 파일을 로컬에서(즉 file:// URL로) 로드하려 하면 JavaScript 모듈 보안 요구사항 때문에 CORS 오류가 발생한다. 서버를 통해 테스트해야 한다.
또한 모듈 안에 정의된 스크립트 부분이 클래식 스크립트와 다른 동작을 보일 수 있다는 점에 주목하라. 모듈이 자동으로 strict mode를 사용하기 때문이다.
모듈 스크립트를 로드할 때 defer 속성(참조: <script> 속성)을 사용할 필요가 없다. 모듈은 자동으로 지연(defer)되기 때문이다.
모듈은 여러 <script> 태그에서 참조되어도 한 번만 실행된다.
마지막으로 분명히 하자 — 모듈 기능은 단일 스크립트의 범위로 가져와진다. 전역 범위에서 사용할 수 없다. 따라서 가져온 기능은 그것이 가져와진 스크립트에서만 접근할 수 있고, 예를 들어 JavaScript 콘솔에서는 접근할 수 없다. DevTools에는 여전히 문법 오류가 표시되지만, 기대했을 수 있는 일부 디버깅 기법은 사용할 수 없다.
모듈 정의 변수는 명시적으로 전역 객체에 붙지 않는 한 모듈로 범위가 한정된다. 반면 전역적으로 정의된 변수는 모듈 안에서 사용할 수 있다. 예를 들어 다음 코드가 있다.
<!doctype html>
<html lang="en-US">
<head>
<meta charset="UTF-8" />
<title>Example page</title>
<link rel="stylesheet" href="" />
</head>
<body>
<div id="main"></div>
<script>
// var 문장은 전역 변수를 만든다.
var text = "Hello";
</script>
<script type="module" src="./render.js"></script>
</body>
</html>
/* render.js */
document.getElementById("main").innerText = text;
전역 변수 text와 document가 모듈에서 사용 가능하므로 페이지는 여전히 Hello를 렌더링한다. (또한 이 예제에서 모듈이 반드시 import/export 문장을 가질 필요는 없다는 점에 주목하라 — 필요한 것은 진입점(entry point)이 type="module"을 가지는 것뿐이다.)
default export 대 named export (Default exports versus named exports)
지금까지 내보낸 기능은 **named export(이름 있는 내보내기)**로 구성되었다. 각 항목(함수든 const든)이 내보낼 때 그 이름으로 참조되었고, 그 이름이 가져올 때도 참조하는 데 사용되었다.
**default export(기본 내보내기)**라는 타입의 내보내기도 있다. 이것은 모듈이 제공하는 기본 함수를 쉽게 가지도록 설계되었고, JavaScript 모듈이 기존 CommonJS와 AMD 모듈 시스템과 상호 운용하도록 돕는다. (이것은 ES6 In Depth: Modules에서 잘 설명된다. "Default exports"를 검색하라.)
예를 보면서 어떻게 동작하는지 설명하자. basic-modules의 square.js에서 임의의 색·크기·위치로 사각형을 만드는 randomSquare()라는 함수를 찾을 수 있다. 이것을 default로 내보내려면 파일 하단에 다음과 같이 쓴다.
export default randomSquare;
중괄호가 없다는 점에 주목하라.
대신 export default를 함수 앞에 붙이고 익명 함수로 정의할 수도 있다.
export default function (ctx) {
// …
}
main.js 파일에서 다음 줄로 default 함수를 가져온다.
import randomSquare from "./modules/square.js";
다시, 중괄호가 없다는 점에 주목하라. 모듈당 default export는 하나만 허용되고 randomSquare가 그것임을 우리가 알기 때문이다. 위 줄은 기본적으로 다음의 축약이다.
import { default as randomSquare } from "./modules/square.js";
참고: 내보낸 항목의 이름을 바꾸는
as문법은 아래 "Renaming imports and exports" 절에서 설명한다.
이름 충돌 피하기 (Avoiding naming conflicts)
지금까지 캔버스 도형 그리기 모듈은 잘 동작하는 것 같다. 그러나 원이나 삼각형 같은 다른 도형을 그리는 모듈을 추가하려 하면 어떻게 될까? 그런 도형들은 아마 draw(), reportArea() 등과 같은 관련 함수를 가질 것이다. 같은 이름의 서로 다른 함수를 같은 최상위 모듈 파일로 가져오려 하면 충돌과 오류가 발생할 것이다.
다행히 이를 해결하는 방법이 여러 가지 있다. 다음 절들에서 살펴보자.
import와 export 이름 바꾸기 (Renaming imports and exports)
import와 export 문장의 중괄호 안에서 키워드 as와 함께 새 기능 이름을 사용해 최상위 모듈 안에서 기능에 사용할 식별 이름을 바꿀 수 있다.
예를 들어 다음 두 가지는 조금 다른 방식으로 같은 일을 한다.
// -- module.js --
export { function1 as newFunctionName, function2 as anotherNewFunctionName };
// -- main.js --
import { newFunctionName, anotherNewFunctionName } from "./modules/module.js";
// -- module.js --
export { function1, function2 };
// -- main.js --
import {
function1 as newFunctionName,
function2 as anotherNewFunctionName,
} from "./modules/module.js";
실제 예를 보자. renaming 디렉터리에서 이전 예제와 같은 모듈 시스템을 볼 수 있는데, 원과 삼각형을 그리고 보고하는 circle.js와 triangle.js 모듈이 추가됐다.
이 각 모듈 안에는 같은 이름의 기능들이 내보내지고 있으므로, 각각 하단에 같은 export 문장이 있다.
export { name, draw, reportArea, reportPerimeter };
이것들을 main.js로 가져올 때 다음과 같이 사용하려 하면:
import { name, draw, reportArea, reportPerimeter } from "./modules/square.js";
import { name, draw, reportArea, reportPerimeter } from "./modules/circle.js";
import { name, draw, reportArea, reportPerimeter } from "./modules/triangle.js";
브라우저는 (Firefox에서) "SyntaxError: redeclaration of import name" 같은 오류를 던질 것이다.
대신 import를 고유하도록 이름을 바꿔야 한다.
import {
name as squareName,
draw as drawSquare,
reportArea as reportSquareArea,
reportPerimeter as reportSquarePerimeter,
} from "./modules/square.js";
import {
name as circleName,
draw as drawCircle,
reportArea as reportCircleArea,
reportPerimeter as reportCirclePerimeter,
} from "./modules/circle.js";
import {
name as triangleName,
draw as drawTriangle,
reportArea as reportTriangleArea,
reportPerimeter as reportTrianglePerimeter,
} from "./modules/triangle.js";
문제를 대신 모듈 파일에서 해결할 수도 있다는 점에 주목하라. 예:
// square.js에서
export {
name as squareName,
draw as drawSquare,
reportArea as reportSquareArea,
reportPerimeter as reportSquarePerimeter,
};
// main.js에서
import {
squareName,
drawSquare,
reportSquareArea,
reportSquarePerimeter,
} from "./modules/square.js";
이것도 똑같이 동작한다. 어떤 스타일을 사용할지는 당신의 선택이지만, 모듈 코드는 그대로 두고 import에서 변경하는 것이 논리적으로 더 타당하다. 특히 통제할 수 없는 제3자 모듈에서 가져올 때 그렇다.
모듈 객체 만들기 (Creating a module object)
위의 방법은 잘 동작하지만 약간 지저분하고 장황하다. 더 나은 해결책은 각 모듈의 기능을 모듈 객체 안으로 가져오는 것이다. 다음 문법 형태가 그것을 한다.
import * as Module from "./modules/module.js";
이것은 module.js 안에서 사용 가능한 모든 export를 가져와 객체 Module의 멤버로 만든다. 사실상 고유한 네임스페이스를 준다. 예:
Module.function1();
Module.function2();
다시 실제 예를 보자. module-objects 디렉터리로 가면 같은 예제가 이 새로운 문법을 활용하도록 다시 쓰여진 것을 볼 수 있다. 모듈에서 export는 모두 다음과 같은 간단한 형태다.
export { name, draw, reportArea, reportPerimeter };
반면 import는 다음과 같다.
import * as Canvas from "./modules/canvas.js";
import * as Square from "./modules/square.js";
import * as Circle from "./modules/circle.js";
import * as Triangle from "./modules/triangle.js";
각 경우에 이제 지정된 객체 이름 아래에서 모듈의 import에 접근할 수 있다. 예:
const square = Square.draw(myCanvas.ctx, 50, 50, 100, "blue");
Square.reportArea(square.length, reportList);
Square.reportPerimeter(square.length, reportList);
따라서 이제 (필요한 곳에 객체 이름을 포함하기만 하면) 이전과 똑같이 코드를 쓸 수 있고, import는 훨씬 깔끔하다.
모듈과 클래스 (Modules and classes)
앞서 암시했듯 클래스도 export·import할 수 있다. 이것은 코드에서 충돌을 피하는 또 다른 옵션이며, 이미 모듈 코드를 객체 지향 스타일로 작성했다면 특히 유용하다.
classes 디렉터리에서 ES 클래스로 다시 쓰여진 도형 그리기 모듈의 예를 볼 수 있다. 예를 들어 square.js 파일은 이제 모든 기능을 단일 클래스에 담는다.
class Square {
constructor(ctx, listId, length, x, y, color) {
// …
}
draw() {
// …
}
// …
}
그리고 그것을 export한다.
export { Square };
main.js에서는 다음과 같이 import한다.
import { Square } from "./modules/square.js";
그런 다음 클래스를 사용해 사각형을 그린다.
const square = new Square(myCanvas.ctx, myCanvas.listId, 50, 50, 100, "blue");
square.draw();
square.reportArea();
square.reportPerimeter();
모듈 집계하기 (Aggregating modules)
모듈을 함께 집계하고 싶을 때가 있을 것이다. 여러 수준의 종속성이 있고, 여러 서브모듈을 하나의 부모 모듈로 결합해 단순화하고 싶을 수 있다. 이것은 부모 모듈에서 다음 형태의 export 문법을 사용해 가능하다.
export * from "x.js";
export { name } from "x.js";
예는 module-aggregation 디렉터리를 보라. 이 예제(이전 classes 예제 기반)에는 shapes.js라는 추가 모듈이 있는데, circle.js, square.js, triangle.js의 모든 기능을 함께 집계한다. 또한 서브모듈을 modules 디렉터리 안의 shapes라는 서브디렉터리로 옮겼다. 이 예제의 모듈 구조는 다음과 같다.
modules/
canvas.js
shapes.js
shapes/
circle.js
square.js
triangle.js
각 서브모듈에서 export는 같은 형태다. 예:
export { Square };
다음은 집계 부분이다. shapes.js 안에 다음 줄을 포함한다.
export { Square } from "./shapes/square.js";
export { Triangle } from "./shapes/triangle.js";
export { Circle } from "./shapes/circle.js";
이것들은 개별 서브모듈에서 export를 가져와 사실상 shapes.js 모듈에서 사용 가능하게 만든다.
참고: shapes.js에서 참조되는 export는 기본적으로 그 파일을 통해 리다이렉트되며 그곳에 실제로 존재하지 않으므로, 같은 파일 안에서 유용한 관련 코드를 작성할 수 없다.
따라서 main.js 파일에서 이제 세 모듈 클래스에 접근하려면 다음을:
import { Square } from "./modules/square.js";
import { Circle } from "./modules/circle.js";
import { Triangle } from "./modules/triangle.js";
다음 단일 줄로 바꿀 수 있다.
import { Square, Circle, Triangle } from "./modules/shapes.js";
동적 모듈 로딩 (Dynamic module loading)
JavaScript 모듈 기능의 최근 추가 사항은 **동적 모듈 로딩(dynamic module loading)**이다. 이를 통해 모든 것을 앞서 로드할 필요 없이 모듈이 필요할 때만 동적으로 로드할 수 있다. 이것은 명백한 성능 이점이 있다.
이 새로운 기능은 import()를 함수로 호출하고, 모듈의 경로를 매개변수로 전달할 수 있게 한다. 그것은 Promise를 반환하며, 해당 객체의 export에 접근할 수 있게 해주는 모듈 객체로 fulfill된다. 예:
import("./modules/myModule.js").then((module) => {
// 모듈로 무언가 수행
});
참고: 동적 import는 브라우저 메인 스레드와 shared·dedicated 워커에서 허용된다. 그러나 service worker나 worklet에서
import()를 호출하면 throw된다.
예를 보자. dynamic-module-imports 디렉터리에는 classes 예제에 기반한 또 다른 예제가 있다. 그러나 이번에는 예제가 로드될 때 캔버스에 아무것도 그리지 않는다. 대신 "Circle", "Square", "Triangle" 세 버튼을 포함하는데, 누르면 필수 모듈을 동적으로 로드하고 이를 사용해 관련 도형을 그린다.
이 예제에서는 index.html과 main.js 파일만 변경했다 — 모듈 export는 이전과 같다.
main.js에서 document.querySelector() 호출로 각 버튼에 대한 참조를 얻었다. 예:
const squareBtn = document.querySelector(".square");
그런 다음 각 버튼에 이벤트 리스너를 붙여, 누르면 관련 모듈이 동적으로 로드되어 도형을 그리는 데 사용되게 한다.
squareBtn.addEventListener("click", () => {
import("./modules/square.js").then((Module) => {
const square = new Module.Square(
myCanvas.ctx,
myCanvas.listId,
50,
50,
100,
"blue",
);
square.draw();
square.reportArea();
square.reportPerimeter();
});
});
promise fulfillment가 모듈 객체를 반환하므로 클래스는 이제 객체의 서브기능이 되어, 생성자에 Module.을 앞에 붙여 접근해야 한다는 점에 주목하라. 예: Module.Square( /* … */ ).
동적 import의 또 다른 이점은 스크립트 환경에서도 항상 사용 가능하다는 것이다. 따라서 HTML에 type="module"이 없는 기존 <script> 태그가 있어도, 모듈로 배포된 코드를 동적으로 import해 재사용할 수 있다.
<script>
import("./modules/square.js").then((module) => {
// 모듈로 무언가 수행
});
// 전역 범위에서 동작하고 아직 모듈로 리팩터링할 준비가 되지 않은 다른 코드
var btn = document.querySelector(".square");
</script>
최상위 await (Top level await)
**최상위 await(top level await)**는 모듈 안에서 사용 가능한 기능이다. 이는 await 키워드를 사용할 수 있게 해준다. 모듈이 큰 비동기 함수처럼 동작하게 하여, 코드가 부모 모듈에서 사용되기 전에 평가될 수 있게 하면서도 형제 모듈의 로딩을 차단하지 않게 한다.
예를 보자. 이 절에 설명된 모든 파일과 코드를 top-level-await 디렉터리에서 찾을 수 있다. 이것은 이전 예제들에서 확장된다.
먼저 별도의 colors.json 파일에서 색상 팔레트를 선언한다.
{
"yellow": "#F4D03F",
"green": "#52BE80",
"blue": "#5499C7",
"red": "#CD6155",
"orange": "#F39C12"
}
그런 다음 fetch 요청을 사용해 colors.json 파일을 로드하고 데이터를 객체로 반환하는 getColors.js라는 모듈을 만든다.
// fetch 요청
const colors = fetch("../data/colors.json").then((response) => response.json());
export default await colors;
마지막 export 줄에 주목하라. export할 상수 colors를 지정하기 전에 키워드 await를 사용하고 있다. 이는 이 모듈을 포함하는 다른 모든 모듈이 colors가 다운로드되고 파싱될 때까지 기다린 후에 그것을 사용한다는 뜻이다.
이 모듈을 main.js 파일에 포함하자.
import colors from "./modules/getColors.js";
import { Canvas } from "./modules/canvas.js";
const circleBtn = document.querySelector(".circle");
// …
도형 함수를 호출할 때 이전에 사용한 문자열 대신 colors를 사용할 것이다.
const square = new Module.Square(
myCanvas.ctx,
myCanvas.listId,
50,
50,
100,
colors.blue,
);
const circle = new Module.Circle(
myCanvas.ctx,
myCanvas.listId,
75,
200,
100,
colors.green,
);
const triangle = new Module.Triangle(
myCanvas.ctx,
myCanvas.listId,
100,
75,
190,
colors.yellow,
);
이것은 main.js 안의 코드가 getColors.js의 코드가 실행될 때까지 실행되지 않기 때문에 유용하다. 그러나 다른 모듈의 로딩을 차단하지는 않는다. 예를 들어 colors가 fetch되는 동안 canvas.js 모듈은 계속 로드된다.
import 선언은 호이스팅된다 (Import declarations are hoisted)
import 선언은 호이스팅된다. 이 경우 가져온 값이 그것을 선언하는 위치보다도 앞서 모듈의 코드에서 사용 가능하고, 가져온 모듈의 부작용(side effects)이 모듈의 나머지 코드가 실행되기 전에 생성된다는 뜻이다.
예를 들어 main.js에서 코드 중간에 Canvas를 import해도 여전히 동작한다.
// …
const myCanvas = new Canvas("myCanvas", document.body, 480, 320);
myCanvas.create();
import { Canvas } from "./modules/canvas.js";
myCanvas.createReportList();
// …
그래도 종속성을 분석하기 쉽게 하기 위해 모든 import를 코드 맨 위에 두는 것이 좋은 관행으로 여겨진다.
순환 import (Cyclic imports)
모듈은 다른 모듈을 import할 수 있고, 그 모듈들은 또 다른 모듈들을 import할 수 있으며 이렇게 계속된다. 이것은 "종속성 그래프(dependency graph)"라고 하는 방향 그래프를 형성한다. 이상적인 세계에서 이 그래프는 비순환적이다. 이 경우 그래프는 깊이 우선 순회(depth-first traversal)로 평가될 수 있다.
그러나 순환은 종종 불가피하다. 모듈 a가 모듈 b를 import하는데 b가 직접 또는 간접적으로 a에 의존하면 **순환 import(cyclic import)**가 발생한다. 예:
// -- a.js --
import { b } from "./b.js";
// -- b.js --
import { a } from "./a.js";
// 순환:
// a.js ───> b.js
// ^ │
// └──────────┘
순환 import가 항상 실패하는 것은 아니다. import된 변수의 값은 변수가 실제로 사용될 때만 검색되며(따라서 라이브 바인딩을 허용), 그 시점에 변수가 초기화되지 않은 채 남아 있을 때만 ReferenceError가 던져진다.
// -- a.js --
import { b } from "./b.js";
setTimeout(() => {
console.log(b); // 1
}, 10);
export const a = 2;
// -- b.js --
import { a } from "./a.js";
setTimeout(() => {
console.log(a); // 2
}, 10);
export const b = 1;
이 예제에서 a와 b 모두 비동기적으로 사용된다. 따라서 모듈이 평가될 때 b도 a도 실제로 읽히지 않으므로 나머지 코드가 정상적으로 실행되고, 두 export 선언이 a와 b의 값을 생성한다. 그런 다음 타임아웃 후 a와 b 모두 사용 가능해지므로 두 console.log 문장도 정상적으로 실행된다.
코드를 a를 동기적으로 사용하도록 바꾸면 모듈 평가가 실패한다.
// -- a.js (진입 모듈) --
import { b } from "./b.js";
export const a = 2;
// -- b.js --
import { a } from "./a.js";
console.log(a); // ReferenceError: Cannot access 'a' before initialization
export const b = 1;
JavaScript가 a.js를 평가할 때 먼저 a.js의 종속성인 b.js를 평가해야 하기 때문이다. 그러나 b.js는 아직 사용할 수 없는 a를 사용한다.
반면 b는 동기적으로 사용하되 a는 비동기적으로 사용하도록 코드를 바꾸면 모듈 평가가 성공한다.
// -- a.js (진입 모듈) --
import { b } from "./b.js";
console.log(b); // 1
export const a = 2;
// -- b.js --
import { a } from "./a.js";
setTimeout(() => {
console.log(a); // 2
}, 10);
export const b = 1;
b.js의 평가가 정상적으로 완료되어 a.js가 평가될 때 b의 값을 사용할 수 있기 때문이다.
프로젝트에서 순환 import는 코드를 더 오류가 나기 쉽게 만들므로 보통 피해야 한다. 일반적인 순환 제거 기법은:
- 두 모듈을 하나로 병합한다.
- 공유 코드를 세 번째 모듈로 옮긴다.
- 어떤 코드를 한 모듈에서 다른 모듈로 옮긴다.
그러나 라이브러리들이 서로 의존하면 순환 import도 발생할 수 있는데, 이것은 고치기 더 어렵다.
"아이소모픽(isomorphic)" 모듈 작성하기 (Authoring "isomorphic" modules)
모듈의 도입은 JavaScript 생태계가 코드를 모듈식으로 배포하고 재사용하도록 장려한다. 그러나 그것이 JavaScript 코드 한 조각이 모든 환경에서 실행될 수 있다는 뜻이 되지는 않는다. 사용자 비밀번호의 SHA 해시를 생성하는 모듈을 발견했다고 하자. 브라우저 프론트엔드에서 사용할 수 있을까? Node.js 서버에서 사용할 수 있을까? 대답은: 상황에 따라 다르다.
모듈은 여전히 전역 변수에 접근할 수 있다(앞서 보여줌). 모듈이 window 같은 전역을 참조하면 브라우저에서 실행될 수 있지만, window가 없으므로 Node.js 서버에서는 오류를 던질 것이다. 마찬가지로 코드가 process에 접근해야 한다면 Node.js에서만 사용할 수 있다.
모듈의 재사용성을 최대화하려면 코드를 "아이소모픽(isomorphic)"으로 만드는 것이 자주 권장된다 — 즉 모든 런타임에서 같은 동작을 보이는 것. 이것은 일반적으로 세 가지 방법으로 달성된다.
- 모듈을 "core"와 "binding"으로 분리한다. "core"의 경우 해시 계산 같은 순수 JavaScript 로직에 집중하고, DOM·네트워크·파일시스템 접근 없이 유틸리티 함수를 노출한다. "binding" 부분의 경우 전역 컨텍스트에서 읽고 쓸 수 있다. 예를 들어 "browser binding"은 입력 상자에서 값을 읽는 것을 선택할 수 있고, "Node binding"은
process.env에서 읽을 수 있지만, 어느 곳에서 읽든 값은 같은 core 함수로 파이프되어 같은 방식으로 처리된다. core는 모든 환경에서 import되어 같은 방식으로 사용될 수 있고, 보통 가벼운 binding만 플랫폼 특이적이면 된다. - 사용 전에 특정 전역이 존재하는지 감지한다. 예를 들어
typeof window === "undefined"를 검사하면 아마 Node.js 환경에 있다는 것을 알 수 있고 DOM을 읽으면 안 된다.
두 분기가 실제로 같은 동작("isomorphic")으로 끝난다면 이 방법이 바람직하다. 같은 기능을 제공하는 것이 불가능하거나, 그렇게 하는 것이 많은 코드를 로드하는데 큰 부분이 사용되지 않는다면, 차라리 다른 "binding"을 사용하라.// myModule.js let password; if (typeof process !== "undefined") { // Node.js에서 실행 중; `process.env`에서 읽는다 password = process.env.PASSWORD; } else if (typeof window !== "undefined") { // 브라우저에서 실행 중; 입력 상자에서 읽는다 password = document.getElementById("password").value; } - 폴리필(polyfill)을 사용해 누락된 기능에 대한 폴백을 제공한다. 예를 들어 Node.js에서 v18부터만 지원되는
fetch함수를 사용하고 싶다면node-fetch가 제공하는 것 같은 유사 API를 사용할 수 있다. 동적 import를 통해 조건부로 그렇게 할 수 있다.// myModule.js if (typeof fetch === "undefined") { // Node.js에서 실행 중; node-fetch를 사용 globalThis.fetch = (await import("node-fetch")).default; } // …globalThis변수는 모든 환경에서 사용 가능한 전역 객체로, 모듈 안에서 전역 변수를 읽거나 만들고 싶을 때 유용하다.
이러한 관행은 모듈에만 고유한 것은 아니다. 그래도 코드 재사용성과 모듈화의 추세와 함께 코드를 크로스 플랫폼으로 만들어 가능한 한 많은 사람이 즐길 수 있도록 하는 것이 권장된다. Node.js 같은 런타임도 웹과의 상호 운용성을 개선하기 위해 웹 API를 적극적으로 구현하고 있다.
문제 해결 (Troubleshooting)
모듈이 동작하게 하는 데 어려움이 있다면 도움이 될 수 있는 몇 가지 팁이 있다. 더 발견하면 목록에 추가해도 좋다.
- 앞서 언급했지만 다시 강조하자:
.mjs파일은text/javascript(또는 다른 JavaScript 호환 MIME 타입이지만text/javascript가 권장됨)의 MIME 타입으로 로드되어야 한다. 그렇지 않으면 "The server responded with a non-JavaScript MIME type" 같은 엄격한 MIME 타입 검사 오류가 발생한다. - HTML 파일을 로컬에서(즉
file://URL로) 로드하려 하면 JavaScript 모듈 보안 요구사항 때문에 CORS 오류가 발생한다. 서버를 통해 테스트해야 한다. GitHub pages는.mjs파일도 올바른 MIME 타입으로 서빙하므로 이상적이다. .mjs는 비표준 파일 확장자이므로 일부 운영체제는 그것을 인식하지 못하거나 다른 것으로 대체하려 할 수 있다. 예를 들어 macOS가.mjs파일 끝에.js를 조용히 추가한 다음 파일 확장자를 자동으로 숨기는 것을 발견했다. 그래서 모든 파일이 실제로x.mjs.js로 나왔다. 자동 파일 확장자 숨기기를 끄고.mjs를 받아들이도록 하자 정상이었다.
더 알아보기
- JavaScript modules on v8.dev (2018)
- ES modules: A cartoon deep-dive (2018)
- ES6 in Depth: Modules (2015)
- import 참조
- export 참조
- 키워드: 모듈, export, import, module specifier, import map, default export, named export, 동적 import, top level await, 순환 import, import attributes