최근 사내 스터디 에서 직접 Bundler 를 제작해보는 소중한 기회를 얻었다.
그간 Javascript 계열에서 개발을 진행하면서 번들러라는 존재를 숨쉬듯 사용해왔으나, 정작 이 친구들이 어떤 원리를 기반으로 동작하는지는 부끄럽게도 알지 못했다. 하지만 최근 사내에서 이야기를 나누었던 분께서 이번 기회에 번들러를 한번 제작해보자는 의견을 주셨고, 무척이나 좋은 기회를 거절할 수 없어 바로 수락을 하였다.
3주간 열심히 맨땅에서 헤딩하듯이 번들러를 제작해보면서, 내가 그동안 알지 못했던 번들러의 심오한 원리와 구현 과정을 그 편린이나마 파악할 수 있었던 좋은 시간을 보내었다. 오늘은 그 중에서도 번들러에 대한 이야기를 짤막하게 진행할 것이다.
다음 글에는 의존성 트리를 실제로 어떻게 구축했는지에 대한 내용과, 트랜스파일링 및 Module Template 구현 과정에 대한 이야기를 적을 예정이다.
vite
, webpack
, rollup
같은 상용화된 번들러를 프로덕션 레벨에서 사용 중이다.파일 간의 중복된 식별자 사용으로 인한 모듈 간 충돌 가능성 존재
<script>
태그를 통해 js 파일을 받는데, Parser의 구조 상 나중에 받은 js 파일의 컨텍스트가 이전에 받았던 js 파일의 컨텍스트를 Override 하기 때문에 문제가 발생한다.<head>
<script src="./source/hello.js"></script>
<script src="./source/world.js"></script>
</head>
<body>
<h1>Hello, Webpack</h1>
<div id="root"></div>
<script type="module">
document.querySelector("#root").innerHTML = word;
</script>
</body>
hello.js
와 world.js
파일을 불러온다. 이때 hello.js
와 world.js
에서 같은 식별자인 word 를 사용한다고 가정해보자.hello.js
내에 선언된 변수 word 의 값을 먼저 불러온다.world.js
에서 선언된 변수 word 의 값으로 덮어씌워진다.다량의 모듈 파일을 전송함으로서 생기는 딜레이 문제
탐색하고자 하는 모듈의 코드를 Parser 를 통해 AST (Abstract Syntax Tree) 로 변환하는 과정을 거친다.
esprima
, babel/parser
, acorn
가 있다.변환된 AST 내에서 ImportDeclaration
type 을 가진 노드를 찾고, 내부 속성 중 source.value
값을 통해 import 경로를 찾는다.
상단의 이미지를 보면 source.value
속성에 import 된 경로가 저장되었음을 알 수 있다.
이전에 찾은 모듈의 정보를 별도의 자료구조 (Map 등) 에 보관해두지 않으면, 순환 참조가 걸린 구조에서 무한 루프에 빠진다!
따라서 이전에 탐지한 모듈의 정보를 저장하여, 이후 모듈을 탐색할 때 이전에 찾았던 모듈인지를 한번 검증하는 절차가 필요하다.
앞선 과정에서 찾아낸 import 경로를 기반으로, 실제 해당 모듈이 위치한 절대 경로를 탐색한다.
탐색한 절대 경로와 모듈 내부의 코드를 매핑하여 의존성 트리의 노드를 구축한다.
transformFromAstSync
메서드를 사용하여 AST를 코드로 재변환 하는 과정에서babel/preset-env
플러그인으로 CJS + ES5 트랜스파일링을 진행할 수 있다.의존성 트리를 기반으로 각 모듈을 독립적인 함수로 감싸 스코프를 격리시킨 후, 이를 하나의 Module Map 으로 묶어 관리한다.
각 모듈의 절대 경로는 코드를 감산 함수와 매핑되며, 추후 Runtime 과정에서 함수를 통해 실행된다.
const modules = {
// 각 모듈 별로 별도의 함수를 감싸 스코프를 격리시킨 모습이다.
'circle.js': function(exports, require) {
const PI = 3.141;
exports.default = function area(radius) {
return PI * radius * radius;
}
},
'square.js': function(exports, require) {
// 식별자 명이 겹치더라도 서로 다른 스코프에 있기 때문에 문제되지 않는다.
exports.default = function area(side) {
return side * side;
}
},
'app.js': function(exports, require) {
const squareArea = require('square.js').default;
const circleArea = require('circle.js').default;
console.log('Area of square: ', squareArea(5))
console.log('Area of circle', circleArea(5))
}
}
모듈을 실행하는 과정에서 다른 모듈을 실행하는 경우 require
함수를 내부에서 실행하고, 이는 이전에 캐싱된 모듈을 반환할지를 결정하여 순환 참조로 발생하는 문제를 방지한다.
만약 Module Cache 가 없다면 모듈을 참조할 경우 계속해서 서로를 탐색하는 과정을 거치게 되므로 문제가 발생한다.
function webpackStart({ modules, entry }) {
// 이미 실행되어 캐싱된 모듈 목록을 저장하는 Module Cache Object.
const moduleCache = {};
const require = moduleName => {
// 만약 해당 모듈이 이미 캐싱되었다면, 캐싱된 값을 return 한다.
if (moduleCache[moduleName]) {
return moduleCache[moduleName];
}
const exports = {};
// 해당 모듈이 아직 실행되지 않았다면, 새롭게 캐싱을 진행한다.
moduleCache[moduleName] = exports;
// modules 객체에서 특정 경로와 매핑되는 함수를 실행한다.
modules[moduleName](exports, require);
return moduleCache[moduleName];
};
// entry 경로를 기반으로 의존성 관계에 놓인 모든 모듈을 실행한다다.
require(entry);
}
webpackStart({
modules,
entry
})
각 모듈 내부의 코드를 동일한 컨텍스트 상에 위치시키고 (호이스팅) 이를 기반으로 번들 파일을 생성한다.
동일한 컨텍스트 내에서 코드가 실행되기 때문에, Rollup 에서는 실행 케이스에 따라 identifier 를 동적으로 변경하여 이를 방지하고자 한다.
모듈 간의 올바른 선언 및 실행 순서를 보장해야 하기 때문에 Rollup 에서는 의존성 트리를 기반으로 모듈 간의 선언부 위치를 정렬하는 과정이 매우 중요하다.
const PI = 3.141;
// 다른 모듈에서 동일한 이름의 식별자를 사용할 경우, Rollup 에서 이를 동적으로 변경한다.
function circle$area(radius) {
return PI * radius * radius;
}
function square$area(side) {
return side * side;
}
// square$area 가 먼저 선언된 이후 실행되어야 할 코드이기에 실행 순서를 맞춘다.
console.log('Area of square: ', square$area(5));
console.log('Area of circle', circle$area(5));
다음 포스트에서는 직접 구현한 번들러의 구조와 Module Compiler 에 대해 소개할 예정이다.