모듈 소개
- 모듈이 있기 전까진
<script src="xxx.js"></script>
와 같은 태그를 통해 모든 스크립트를 불러왔다.- 이러한 방식은 이름 충돌, 복잡한 종속성, 파일 크기 문제 등의 이슈를 불러오게 되었다.
<head>
<!-- 아래 스크립트들은 전역을 공유하게 된다. -->
<script src="a.js"></script>
<script src="b.js"></script>
<script src="c.js"></script>
</head>
모듈이 있기 전의 해법
- 모듈이 있기 전에도 글로벌 영역을 더럽히지 않기 위해 네임스페이스를 나누고자 하는 여러 노력이 있었다.
네임스페이스 객체 이용하기
- 이런 객체를 선언하거나 이런 객체를 반환함으로써 스코프를 나눴다.
var MyNamespace = {
someVariable: "value",
someFunction: function () {
// function code here
},
};
IIFE 이용하기
- 덩치가 큰 함수 하나를 즉시 실행시켜 이용하는 방식이다.
(function () {
var privateVariable = "secret";
function privateFunction() {
// code goes here
}
privateFunction(privateVariable);
})();
모듈의 역사와 현재 JS 모듈 시스템 표준
- 과거에 Common JS, AMD 와 같은 모듈 시스템이 존재했다.
- 현재 표준이 된 것은 ES6 에 도입된 ES Modules 이다.
모듈 기본 개념
- JS 모듈은 보통 1개의
.js
파일이 단위이다. - 자체 스코프가 있다.
- 모듈을 가져오는
import
와 모듈을 내보내는export
를 제공한다.
모듈 트리
- 개발하다보면 각 모듈은 서로 의존하는 복잡한 관계를 갖는다.
- 이 과정에서 '모듈 트리' 라고 하는 그래프가 형성된다.
영역과 모듈 캐싱
- 자바스크립트 엔진은
영역
당 한 번만 모듈을 로드한다.영역
이란 것은 브라우저 창(탭, 전체 창, iframe) 등을 말한다.
- 한번
영역
에 모듈이 로드되면 캐싱되어 로드한 모듈을 계속 재사용한다.
모듈 지정자 (Module specifier)
import { example } from "./module.js";
- 위 소스코드에서
"./module.js"
부분을 모듈 지정자라고 한다. - 모듈의 위치를 식별 (identifies the location) 하는 문자열을 모듈 지정자라고 한다.
- 로컬의 모듈을 가져와 사용할 때는 반드시 확장자인
.js
를 붙여야 한다.nodejs
환경 등에서fs
같은 기본 모듈을 사용할 땐 필요 없다.
- 모듈 시스템마다 사용하는 모듈 식별자의 형태가 약간씩 다를 수 있다.
- 모듈 지정자는 리터럴 이어야 한다.
- 변수같은 것이 오면 안된다.
- 선언은 정적 양식이기 때문이다.
- 최적화를 위해 JS 엔진은 코드를 실행하지 않고도 이를 해석할 수 있어야 한다.
브라우저에서 ESM 모듈을 사용하는 방법
- 스크립트 태그에서
type="module"
을 속성으로 주면 된다. - 참고로 이 속성을 가지면 자동으로
defer
속성을 가진 태그와 동일하게 처리된다. - 만일 HTML 파싱과의 의존성이 없어 비동기적으로 불러오고 싶다면,
async
속성을 추가해주면 된다.
<script src="main.js" type="module"></script>
Node.js 에서 ESM 모듈을 사용하는 방법
- 기본 모듈 시스템이 Common JS 로 설정되어 있다.
package.json
파일의 내용을 약간 수정하면 된다.- 모듈을
import
할 때는 마찬가지로 기본 확장자가 없으므로 꼭.js
혹은.mjs
를 붙여주어야 한다.- 단, 내장 모듈이나
node_modules
에 있는 패키지를 임포트할 때는 필요 없다.
- 단, 내장 모듈이나
{
"type": "module"
}
import { something } from "./module.js";
import fs from "fs"; // fs 는 내장 모듈
Common JS 를 동시에 사용하려면?
- Common JS 형식의 모듈을 동시에 사용하는 것도 가능하다.
- 다만, ESM 모듈처럼 사용하는 것은 불가능하고, 객체 네임스페이스 같은 형식으로 사용해야 한다.
- CJS 모듈은 정적이 아니라 동적으로 작동하기 때문이다.
import mod from "./mod.cjs";
const { something } = mod;
모듈 레코드
- 모듈
import
,export
가 소스코드에 존재하면, 이 내용이 읽히고 JS 엔진 내부에 모듈 레코드라는 것이 만들어진다.
Import
모듈 레코드
Import
모듈 레코드에는[[ModuleRequest]]
,[[ImportName]]
,[[LocalName]]
이 들어있다.[[ModuleRequest]]
: 모듈 지정자 (Module specifier) 문자열을 가진다. 어떤 모듈이 들어왔는지 식별한다.[[ImportName]]
: 이름처럼import
되는 이름을 가진다.[[LocalName]]
: 로컬 식별자의 이름을 가진다. 별칭이 있다면 당연히 별칭을 갖는다.
- 모듈 레코드는 모듈이 로드되기 위해 무엇이 필요한지를 JS 엔진에게 알려주는 것이다.
예제로 살펴보기
import v from "mod";
[[ModuleRequest]]
: "mod"[[ImportName]]
: "default"[[LocalName]]
: "v"
import * as ns from "mod";
[[ModuleRequest]]
: "mod"[[ImportName]]
: "*"[[LocalName]]
: "ns"
import { x as v } from "mod";
[[ModuleRequest]]
: "mod"[[ImportName]]
: "x"[[LocalName]]
: "v"
import "mod";
사이드이펙트를 위한
import
에는 모듈 레코드가 생성되지 않는다.
Export
모듈 레코드
- 이 모듈 레코드를 통해 모듈이 로드될 때 모듈에서 어떤 내용을 제공하는지를 알려준다.
Export
모듈 레코드에는[[ExportName]]
,[[ModuleRequest]]
,[[ImportName]]
,[[LocalName]]
이 들어있다.[[ExportName]]
:export
이름이다.[[ModuleRequest]]
:export x from ...
을 이용해 바로 다시 내보내는 경우에만 생긴다.[[ImportName]]
: 마찬가지로export x from ...
을 이용해 바로 다시 내보내는 경우에만 생긴다.[[LocalName]]
:export
하는 로컬 식별자의 이름이다. 단, 별칭을 쓰면 다를 수 있다.
예제로 살펴보기
export var v;
[[ExportName]]
: "v"[[ModuleRequest]]
: null[[ImportName]]
: null[[LocalName]]
: "v"
export default function f() {}
[[ExportName]]
: "default"[[ModuleRequest]]
: null[[ImportName]]
: null[[LocalName]]
: "f"
export default function () {}
[[ExportName]]
: "default"[[ModuleRequest]]
: null[[ImportName]]
: null[[LocalName]]
: "default"
export { x as v } from "mod";
[[ExportName]]
: "v"[[ModuleRequest]]
: "mod"[[ImportName]]
: x[[LocalName]]
: null
Import
는 간접 라이브 바인딩된다.
import
구문을 통해 가져온 변수는 사실 읽기전용으로 가져온 것이다.- 이를 간접 바인딩이라고 한다.
- 값을 복사한 것이 아니다.
- 해당 모듈 공간에서 읽기전용 참조만 가져오는 것이다.
- 각 모듈은
모듈 환경 객체
라는 것을 가져 완벽히 분리된다.
예제로 알아보기
// mod.js
let c = 0;
export { c as counter };
export function increment() {
++c;
}
// entry.js
import { counter, increment as inc } from "./mod.js";
console.log(counter); // 0
inc();
console.log(counter); // 1
counter = 42; // Uncaught TypeError: Assignment to constant variable.
- 읽기 전용 간접 바인딩이라
counter
에 직접 값을 할당하여 변경하면 변경이 불가능하다. - 다만
inc()
함수를 실행해 간접적으로 값을 바꾸는 것은 가능하다. - 겉보기엔 변수 값을 복사해온 것처럼 보이지만 사실
getter
를 가져온 개념이다. - 모듈로 가져온 참조의
descriptor
를 뜯어보면 아래와 같다.configurable
과writable
이true
인 이유는 JS 스펙상false
라면 항상 동일한 값을 반환해야 한다는 특성이 있기 때문이다.setter
에 의해 변환될 수 있기 때문에true
여야 한다.
descriptor
객체 예시
{
"configurable": true,
"enumerable": true,
"value": 1,
"writable": true
}
모듈을 읽어오는 3단계
- 1단계:
import
와 구문 분석: 모듈 소스코드의import
와export
를 분석하고 결정한다.- 이 과정에서 이전에 학습했던 모듈 레코드인
[[ModuleRequest]]
같은 것들이 사용된다.
- 이 과정에서 이전에 학습했던 모듈 레코드인
- 2단계: 인스턴스화 (instantiated):
import
및export
에 대한 모듈 환경 바인딩을 생성한다. - 3단계: 평가 (evaluation): 모듈의 코드를 실행한다.
1단계: import
와 구문 분석 (parsing)
- 정적 분석이 수행된다.
- 브라우저는 모듈 엔트리 파일의 내용을 읽고
모듈 레코드
를 생성한다. 모듈 레코드
에는 모듈을 아직 실행하지 않고 정적으로 파싱했을 때 알 수 있는 모든 정보가 들어간다.[[ECMAScriptCode]]
: 모듈 코드[[RequestedModules]]
: 코드에서 요청한 모듈들[[ImportEntries]]
:[[ModuleSpecifier]]
,[[ImportName]]
,[[LocalName]]
[[LocalExportEntries]]
: 모듈에서 로컬export
한 것들의 엔트리[[IndirectExportEntries]]
:import
직후export
한 것들의 엔트리
- 생성된
모듈 레코드
는모듈 맵
에 매핑된다. 모듈 레코드
를 생성할 때는 매번모듈 맵
에 해당모듈 레코드
가 존재하는지 확인한다.모듈 레코드
는 일단 생성되면, JS 엔진에 의해 탐색되고 재사용된다.- 마침내 모든
모듈 레코드
정보가 완성되면, JS 엔진은 인스턴스화와 평가의 대상이 되는 모듈 트리를 결정할 수 있다.
시작지점인 entry.js
모듈 레코드 생성
entry.js
, mod1.js
, mod2.js
모듈 레코드 생성
모듈 트리 생성 (인스턴스화가 될 준비가 됨)
2단계: 인스턴스화 (instantiation)
- JS 엔진은 각 모듈의 환경 객체와 최상위 바인딩을 만든다.
- 인스턴스화는 최하위 모듈이 먼저 인스턴스화 되도록 깊이 우선 탐색을 이용해 진행된다.
- 모듈 환경 객체와 바인딩이 생성되지만 해당 코드는 실행되지 않는다.
- 호이스팅 가능한 선언이 처리되고 함수가 생성되지만 어휘 바인딩은 초기화되지 않은 상태로 남는다.
- 함수 호출 시 환경을 만들고 모든 호이스트 가능한 작업을 수행하는 것과 같다.
- JS 엔진은 모듈 레코드의 의존관계에 따라 내부 값이나 함수를 연결("wires")한다.
- 연결 후에
Execution-ready
상태가 되면 평가(Evaluation)를 수행할 수 있다.
인스턴스화 이후
someString
은mod1.js
에서 사용하는 로컬 변수이다.- 아직 초기화되지 않았다.
3단계: 평가 (evaluation)
- 최상위 코드를 깊이 우선 순서(depth-first, post-order)로 실행하여 상태를 "평가됨(evaluated)" 으로 변경한다.
- 이전에 초기화되지 않았던 바인딩
someString
도 이 단계에서 초기화된다. - 모듈의 최상위 코드는 단 1번씩 실행된다.
- 사이드 이펙트가 있는 모듈의 경우 이 부분이 중요하다.
- 그러나 사이드 이펙트는 없는 것이 모범사례다.
- 평가에서 이뤄지는 각 단계를 정리하면 아래와 같다.
- 실행 (Execution): 모듈의 코드가 실행된다. export 된 값이 메모리에 할당된다. import 된 바인딩이 연결된다. 코드에서 사용될 준비가 완료된다.
- 사이드 이펙트 (Side Effects): 사이드이펙트가 있는 경우 수행된다.
- 모듈 실행 완료 (Module Execution Complete): 모듈의 상태가 "평가됨(evaluated)" 으로 변경된다.
모듈의 순환 종속성과 TDZ
- 모듈에는 순환 종속성이 걸릴 수 있다.
- 순환 종속성이란
mod1.js
가mod2.js
를 참조하고,mod2.js
는mod1.js
를 참조하는 것이다. - 이 과정에서 변수를
export
한 경우, TDZ 에러가 발생할 수 있다. - 아래는 그 코드 예시이다.
- 순환 종속성이란
- 순환 종속성에 대한 이해가 있다면 TDZ 에러를 충분히 막을 수 있다.
- 그러나 모듈에서 변수는
export
하지 않는 것이 좋다.getter
로도 충분히 똑같은 코드를 작성할 수 있다.
- 그러나 모듈에서 변수는
recursive-dependency.html
<!DOCTYPE html>
<html lang="en">
<head>
<script src="./entry.js" type="module"></script>
<title>recursive dependency</title>
</head>
<body></body>
</html>
entry.js
import { func1 } from "./mod1.js";
import def, { func2 } from "./mod2.js";
console.log(`func1: ${func1()}`);
console.log(`func2: ${func2()}`);
console.log(`def: ${def()}`);
mod1.js
import def from "./mod2.js";
export const func1 = () => {
return `${def()} func1`;
};
export const someString = "someString";
mod2.js
import { func1, someString } from "./mod1.js";
console.log(`someString: ${someString}`); // TDZ 에러
export const func2 = () => {
return `${func1()} func2`;
};
export default () => {
return "default";
};
반응형
'자바스크립트 > 모던 자바스크립트' 카테고리의 다른 글
모던 자바스크립트, 프록시 (Proxy) (0) | 2023.03.29 |
---|---|
모던 자바스크립트, 모듈 3 - 트리 셰이킹과 번들링 (0) | 2023.03.26 |
모던 자바스크립트, 모듈 1 - import 와 export 방식 (0) | 2023.03.26 |
모던 자바스크립트, 위크 맵(WeakMap) 과 위크 셋(WeakSet) (0) | 2023.03.24 |
모던 자바스크립트, 셋 혹은 세트 (Set) (0) | 2023.03.23 |