2023. 8. 6. 18:57ㆍ카테고리 없음
ESM의 동작 방식을 매우 잘 설명한 글이 있다. 아래의 이 글을 번역하였다. (토스 기술 블로그에 더 잘 번역한 글이 있다.)
https://ui.toast.com/weekly-pick/ko_20180402
ESM의 동작 방식
모듈을 사용하여 개발할 때 import문에 의하여 의존성 그래프가 생성되게 된다.
entry point로 지정한 파일로부터 import 문을 쭉 따라가면 나머지 모든 코드들을 찾을 수 있게 된다.
하지만 파일 자체는 브라우저가 해석할 수 없다. 따라서 파일들을 Moudle record라고 하는 자료구조로 변경해야 한다.
그 후에 Module record를 Module instance로 전환하게 된다.
Module instance는 코드와 상태라는 두 가지를 결합하게 된다. 코드는 명령어 목록을 의미하며, 상태는 모든 변수의 값을 의미한다.
모듈 로딩 과정은 entry point로 지정한 파일이 전체 Module instance의 그래프를 가지는 것으로 진행된다.
ESM에서 이는 세단계로 진행된다
1. Construction - 모든 파일을 찾아서 다운로드하고 그 파일들을 module record로 파싱한다.
2. Instanitation - export된 값들을 넣을 메모리 공간을 찾고(아직 실제 값을 저장하진 않는다.), exports와 imports가 해당 메모리를 가리키도록 한다. 이를 linking이라고 부른다.
3. Evaluation - 코드를 실행하여 메모리 공간에 실제 값을 저장한다.
ESM이 흔히들 비동기로 동작한다고 말한다. 이 이유는 모듈 로딩 과정이 위와 같이 세 단계로 구성이 되어있고, 이 세 단계는 개별적으로 진행할 수 있기 때문이다.
이 spec은 CJS에는 없던 일종의 비동기성을 도입하는 것을 의미하는데, 이는 CJS에서는 모듈과 dependencies들이 동기적으로 (중단 없이) 한번에 Construction, Instanitation, Evaluation되기 때문이다.
하지만 각 단계 자체가 반드시 비동기일 필요는 없다. 로딩을 수행하는 항목에 따라 다른데, 모든 것이 ESM의 스펙에 의해 제어되는 것은 아니기 때문이다.
ESM의 스펙에는 파일을 module record로 분석하는 방법과 해당 모듈을 Instanitation 및 Evaluation하는 방법이 나와있다. 하지만 맨 처음, 파일을 찾고 가져오는 방법에 대해서는 말하고 있지 않다.
파일을 가져오는 것은 loader인데, 브라우저의 경우 HTML 스펙에서 loader에 대해 specified 되어있다. 물론 사용 중인 플랫폼에 따라 다른 loader를 사용할 수 있다.
loader는 모듈이 어떻게 불러와 지는지도 제어한다. 이는 ESM 메서드(ParseModule, Module.Instantiate, Module.Evaluate)라고 불린다.
이제 각 단계에 대해 자세히 살펴보자.
Finding the file and fetching
로더는 파일을 찾아서 다운로드 하기 위해 entry point 파일을 찾아야한다. HTML에 script tag를 이용해서 로더에게 entry point 파일을 말해준다.
<script src="main.js" type="module">
main.js가 직접적으로 의존하는 모듈인 다음 모듈은 어떻게 찾을 수 있을까?바로 import 문을 이용해 찾는다. import 문의 한 부분(from 뒤의 부분)을 module specifier라고 한다.module specifier는 로더에게 다음 모듈을 찾을 수 있는 위치를 말해준다.
module specifer에 대해 한 가지 주의할 점은 브라우저와 노드 간에 다르게 처리해야 하는 경우가 있다는 것이다.
각 호스트는 module specifier를 해석하는 고유한 module resolution algorithm을 가지고 있다. 현재 일부 module specifier는 노드에서는 잘 작동하지만 브라우저에서는 잘 작동하지 않는다.
이 문제가 해결될 때까지 브라우저는 오직 URL만을 module specifier로 인정한다. 브라우저는 해당 URL에서 모듈 파일을 로드한다.
하지만 전체 그래프에서 동시에 발생하지는 않는다. 파일을 파싱하기 전까진 모듈이 어떤 dependencies를 필요로 하는지 알수 없고, 파일을 가져오기 전까진 파싱을 할 수도 없다.
즉 하나의 파일을 파싱 한다음, dependencies를 파악하고, 해당 dependencies들을 찾아 로드하는 식으로 트리를 계층별로 살펴봐야 한다.
메인 스레드가 이러한 각 파일이 다운로드 될때까지 기다린다면 대기열에 다른 많은 작업이 쌓이게 된다.
이것이 바로 브라우저에서 작업할 때 다운로드하는 데 시간이 오래 걸리는 이유이다.
이처럼 메인 스레드를 block 하게 되면 모듈을 사용하는 앱의 속도가 너무 느려질 수 있다.
이것이 ESM 스펙이 알고리즘을 여러 단계로 나눈 이유중 하나이다. Construction단계가 개별적으로 분리하게 되면 Instantiatation 작업을 동기적으로 처리하기 전에, 브라우저가 파일을 불러오고 모듈 그래프를 구성할 수 있다.
각 단계를 나누는 알고리즘을 사용하는 접근법은 ESM과 CJS간의 주요 차이점 중 하나이다.
CJS는 파일 시스템에서 파일을 로드하므로 인터넷을 통해 fetch 해오는 것보다 시간이 훨씬 적게 든다. 이는 노드가 파일을 불러오는 동안 메인 스레드를 block할 수 있다는 것을 의미한다. 그리고 파일이 이미 로드되었기 때문에 Instanitation 및 evaluation을 진행하면된다. (CJS에서는 별도의 단계가 아니다.) 또한 module instance를 반환하기 전에 트리 구조를 따라 내려가면서 모든 dependencies들을 Construction, instantiating하고 evaluating 해야 하는것을 의미한다.
이는 CJS모듈시스템을 채택한 노드에서는 module specifier에서 변수를 사용할 수 있다는 점을 말한다. 다음 모듈을 찾기 전에 이 모듈의 모든 코드를 실행하는 것이다. 즉 모듈 확인을 수행할 때 변수에 값을 가진다.
하지만 ESM에서는 어떤 evaluation 이전에 전체 모듈 그래프를 미리 작성해야 한다. 즉 변수는 아직 값이 없기 때문에 module specifier에 변수를 넣을 수 없다.
ESM에서 이를 가능하게 하기 위해 dynamic import에 대한 제안이 있다.
dynamic import가 동작하는 방식은 import()를 통해 불러온 파일은 별개의 그래프의 entry point로 취급된다. 동적으로 import한 모듈은 새로운 그래프를 시작하게 되는 것이다.
한 가지 주의할 점은 이 두 그래프에 모두 있는 모듈은 모듈 인스턴스를 공유한다는 것이다.
이는 loader가 모듈 인스턴스를 캐시하기 때문이다. 특정 글로벌 스코프의 각 모듈에는 모듈 인스턴스가 하나만 존재한다.
이는 엔진의 작업을 줄여준다. 예를 들어 여러 모듈이 해당 모듈에 의존하고 있어도 모듈 파일은 한번만 fetch한다.
loader는 module map을 통해서 캐시를 관리하게 된다. 각 글로벌은 별도의 module map에서 해당 모듈을 추적한다.
loader가 URL에서 fetch 해올때, module map에 해당 URL을 저장하고 현재 파일을 가져오고 있다는 메모를 남긴다. 그런 다음 요청을 보내고 다음 파일을 fetch하기 시작한다.
다른 모듈이 동일한 파일을 depends 하고 있으면 어떻게 될까? loader는 모듈 맵에서 각 URL을 룩업한다. 거기에서 가져오고 있다는 메모를 발견하면 다음 URL로 넘어간다.
하지만 module map은 단순히 어떤 파일이 fetching 중인지만 추적하지 않는다. module map은 모듈에 대한 캐시 역할도 한다.
Parsing
이제 파일을 모두 fetch해왔으니 module record로 파싱해야 한다. 이를 통해 브라우저가 모듈의 다른 부분이 무엇인지 이해하게 해준다.
module record가 생성되면 module map에 배치된다. 즉 앞으로 요청이 있을 때마다 loader가 해당 module map에서 가져올 수 있다.
parsing에는 사소해보일 수 있지만 실제로는 매우 큰 영향을 미치는 세부사항이 하나 있다. 모든 모듈은 상단에 use strict가 있는 것처럼 파싱된다. 또 다른 차이점들도 있다. 예를 들어 await 문은 모듈의 최상위 레벨의 코드의 예약어라는 점과 this의 값은 undefined 라는 점이다.
이러한 다른 종류의 파싱 방법을 parse goal이라고 부른다. 만약 같은 파일을 파싱하지만 다른 parsing goal을 이용하면 다른 결과를 얻을 수 있다. 따라서 파싱을 시작하기 전에 어떤 종류의 파일(모듈인지 아닌지)을 파싱할지 알아야 한다.
브라우저에서는 매우 쉽다. script 태그에 type="module"을 입력하기만 하면 된다. 이렇게 하면 브라우저에 이 파일이 모듈로 파싱되어야 함을 알린다. 또한 모듈만 imports해올 수 있으므로 브라우저는 어떤 imports라도 모듈임을 알 수 있다.
하지만 노드의 경우 HTML 태그를 사용하지 않는다. 커뮤니티에서 이 문제를 해결하기 위해 시도한 한 가지 방법은 .mjs 확장자를 사용하는 것이다. 이 확장자를 사용하면 노드에 이 파일이 모듈임을 알려준다. 사람들은 이것을 parsing goal의 신호로 이야기하는 것을 알 수있다. 현재는 논의가 진행 중이므로 어떤 신호가 최종적으로 결정될지는 불분명하다.
어느 쪽이든, loader는 파일을 모듈로 구문 분석할지 여부를 결정한다. 만약 모듈이고 imports문이 있다면 모든 파일들이 fetch되고 파싱될때까지 프로세스를 반복한다.
이제 로딩 프로세스가 끝나면 entry point 파일만 있던 상태에서 module record가 가득 채워진 상태로 바뀌게 된다.
Instantiation
인스턴스는 코드와 상태를 결합한다. 상태는 메모리에 존재하므로 Instantiation 단계는 모든 것을 메모리에 연결 하는 것이라고 할 수 있다.
가장 먼저 JS 엔진이 module environment record를 생성한다. 이는 module record에 대한 변수를 관리한다. 그런 다음 메메모리에서 모든 exports에 대한 공간을 찾는다. module environment record는 각 export에 대한 메모리 공간을 추적한다.
이 메모리 공간은 아직 값을 가지지 않는다. evaluation 후에야 실제 값이 채워진다. 이 규칙에는 한 가지 주의할 점이 있다. export된 함수 선언은 이 단계에서 초기화 된다. 이렇게 하면 evaluation 단계가 좀 더 쉬워진다.
모듈 그래프를 instantiation 하기 위해 엔진은 depth first post-order 탐색을 수행한다. 즉, 그래프의 맨 아래, 다른 것에 depend 하지 않는 dependencies까지 내려가서 exports를 설정한다.
JS 엔진은 모듈 아래의 모든 exports들 즉, 모듈이 depends on하는 모든 모듈의 exports들을 메모리 공간에 할당한다. 그 후에 그 모듈의 import가 해당 메모리 공간을 가리키도록 한다.
한 모듈에 대한 export와 import는 같은 메모리의 주소를 가리키는 점을 주목하자. export들을 먼저 연결함으로써 모든 impot들이 export와 매칭되도록 보장한다.
이것은 CJS와 다르다. CJS에서는 전체 export 객체가 복사된다. 이는 export하는 모든 값은 사본임을 의미한다.
이는 또한 export하고 있는 모듈이 나중에 그 값을 바꿔도 import하는 모듈은 변경 사항을 알아채지 못함을 의미한다.
이와 대조적으로 ESM은 live binding이라고 불리는 것을 사용한다. 두 모듈 모두 메모리에서 동일한 위치를 가리킨다. 즉 export한 모듈에서 값을 변경하면 해당 변경 사항이 import한 모듈에 반영된다.
export한 모듈은 할당된 메모리 주소의 값을 변경할 수 있지만, import하는 모듈은 값을 변경할 수 없다. 만약 모듈이 객체를 import하는 경우에는 해당 객체에 있는 property 값은 변경할 수 있다.
이처럼 live binding을 사용하는 이유는 코드를 실행하지 않고 모든 모듈을 linking할 수 있기 때문이다.
Evaluation
마지막 단계는 메모리 공간에 값을 채우는 것이다. JS 엔진은 top-level의 코드(함수 외부의 코드)를 실행하여 이 작업을 수행한다.
메모리 공간에 값을 할당하는 것 외에도 코드를 evaluation하는 것은 사이드 이펙트를 발생시킬 수 있다.
예를 들어 모듈이 서버를 호출할 수 있다.
사이드 이펙트가 발생할 수 있으므로 모듈은 한번만 evaluation 되어야 한다. Instantiation에서 발생하는 linking은 여러번 수행해도 같은 결과를 보장하지만, evaluation은 매 실행마다 다른 결과를 가져올 수 있다.
이것이 module map이 필요한 이유 중 하나이다. module map은 각 모듈에 대해 하나의 module record만 존재하도록 기준이 되는 URL로 모듈을 캐시한다. 이는 각 모듈이 한 번만 실행되는 것을 보장한다. Instantiation와 마찬가지로 depth first post-order 탐색으로 수행된다.
순환 참조에 대해서는 어떨까?
// main.js
let count = require("./counter.js").count;
console.log(count);
exports.message = "Eval complete";
// couner.js
let message = require("./main.js").message;
exports.count = 5;
setTimeout(()=>console.log(messgae), 0);
CJS에서 위 같은 코드가 어떻게 동작하는지 살펴보자. 먼저 main 모듈은 require 문까지 실행한다. 그런 다음 카운터 모듈을 로드한다.
그러면 카운터 모듈이 export object에서 message에 접근하려고 시도한다. 하지만 아직 메인 모듈에서 evaluation 되지 않았기 때문에 undefined를 반환한다. JS 엔진은 로컬 변수를 위한 메모리 공간을 할당하고 값을 undefined로 설정한다.
Evalutation은 counter 모듈의 최상위 코드 끝까지 계속된다. main.js가 평가된 후 message에 대한 올바른 값을 얻을 수 있는지 확인하고 싶기 때문에 setTimeout을 설정했다. 그 후 main.js에서 다시 evaluation이 시작된다.
message 변수가 초기화되고 메모리에 추가된다. 그러니 두 메모리 간에 어떠한 연결이 없으므로, counter 모듈에서는 여전히 undefined 상태로 남아있게 된다.
하지만 live binding을 사용하여 export되었다면, counter 모듈은 결과적으로 올바른 값을 보게 될 것이다. main.js의 evaluation이 완료 되어 값이 채워졌을 것이다.