들어가며
안녕하세요. 잡채입니다.
요새 사내 스터디로 javascript bundler 를 직접 구현해보는 경험을 하면서 그동안 아무 생각 없이 사용했던 번들러에 대해 알아볼 기회가 생겼습니다.
그냥 javascript 코드 하나로 압축해주는거 아냐? 라고 생각했는데 들여다보니 많은 일을 하고 있는 친구였습니다.
이번에는 bundler 에 대해 알게 된 내용들을 간단하게 정리해보겠습니다.
번들러가 뭔데?
모듈 번들러를 떠올리면 많은 분들이 webpack, rollup 등 을 떠올릴 것 같습니다.
저도 webpack
이 먼저 떠올랐거든요.
모듈 번들러는 한 문장으로 설명하자면 javascript 모듈들을 브라우저에서 실행할 수 있도록 단일 javascript 파일로 만들어주는 도구입니다.
물론 webpack
, rollup
뿐만 아니라 parcel
, vite
같은 다양한 번들러들이 존재합니다.
그렇다면 모듈 번들러는 왜 필요할까요? 답은 아래와 같습니다.
- (지금은 아니지만) 브라우저가 javascript 모듈 시스템을 지원하지 않음
- dependency(종속성) 관리 - 모듈을 종속성 순서에 맞게 로드하기 위해
보다 직관적인 예시입니다.
<html>
<script src="/src/abc.js"></script>
<script src="/src/def.js"></script>
<script src="/src/ghi.js"></script>
<script src="/src/jkl.js"></script>
</html>
src
내부에 있는 4개의 javascript 파일을 사용하기 위해 이렇게 4번 script 파일을 로드합니다.
하지만 번들러를 사용한다면
<html>
<script src="/dist/bundle.js"></script>
</html>
이렇게 1개의 script 파일로 모든 파일을 로드할 수 있습니다.
번들러가 4개의 javascript 파일을 순서에 맞게 단일 파일로 만들었기 때문입니다.
당연히 4개의 파일을 로드할 때 보다 더 적은 비용이 들 것 입니다.
또한, abc.js
에게 의존하는 다른 파일들의 순서에 신경을 쓰지 않아도 되죠.
물론 현재 브라우저에서는 javascript 모듈 시스템을 사용할 수 있습니다.
많은 아티클에서 해당 글을 참조하고 있어 한번 읽어보시면 좋을 것 같아요.
번들러는 어떻게 동작하는데?
번들러가 무슨 일을 하는지, 왜 필요한지는 대충 알게 된 것 같습니다.
그럼 번들러가 여러개의 js 파일을 어떻게 종속성 순서를 맞춰서 단일 파일로 만들어준다는 걸까요?
이 부분을 이해하려면 dependency graph
(종속성 그래프)를 알아야 합니다.
번들러가 동작하는 기본적인 흐름은 아래와 같습니다.
(물론 흔히 사용하는 rollup
과 webpack
이 번들링하는 방식엔 약간의 차이는 있겠지만)
1. 모든 자바스크립트 파일을 읽어온다.
2. 모든 자바스크립트 파일간의 종속성 그래프를 만든다.
3. 종속성 그래프를 통해 순서에 맞게 단일 파일로 모은다. (번들링)
단순한 흐름이지만 종속성 그래프가 많이 보입니다. 뭐길래 이렇게 매번 등장하는 걸까요?
dependency graph (종속성 그래프)
종속성 그래프란 정점이 모듈이고, 간선은 모듈간의 관계인 방향 그래프입니다.
javascript 모듈 시스템에 따라 각 파일에서 연결되는 다른 모듈들에 대한 모든 정보를 담은 그래프라 생각하면 됩니다.
그렇담 종속성 그래프를 만들기 위해서 각 파일들이 가지는 종속성과 순서를 어떻게 알 수 있을까요?
바로 AST
(Abstract Syntax Tree, 추상 구문 트리) 를 사용해서 파악할 수 있습니다.
AST (추상 구문 트리)
AST
는 컴퓨터 과학에서 구문 분석에 사용되는 트리입니다. 보통 소스코드 컴파일 시 parser
에 의해 만들어집니다.
AST 의 각 노드에는 소스 코드의 item 에 해당됩니다.
AST explorer 를 사용해보시면 여러가지 구문들에 대한 AST 를 시각적으로 확인해볼 수 있습니다.
const a = 5 + 3;
이런 아주 단순한 구문이 있을 때 AST 로 변환하면 아래와 같이 무시무시한(?) 결과가 나옵니다.
캡쳐본은 너무 길어서 잘랐습니다. 여러분도 직접 구문을 작성해보세요!
보시다 싶이 a
, 5
, 3
, +
라는 모든 아이템들이 트리로 구성되었습니다.
위의 할당 구문을 보다 직관적이게 시각적으로 나타내면 이러한 트리가 만들어집니다.
각 노드에는 a
, =
, +
, 5
, 3
같은 항목들이 들어가게 됩니다.
개념적인 내용은 다른 좋은 글들이 많아 글 하단에 첨부하겠습니다.
위에서 말했듯 AST 는 parser 라는 친구에 의해 만들어지다 보니 parser 도 여러가지가 존재합니다.
javascript parser 에는 babel/parser
, acorn
, esprima
등 다양한 parser
가 있습니다.
다시 돌아와서 그렇다면 AST 로 종속성을 어떻게 알 수 있을까요?
babel parser 로 AST 만들어보기
저는 사용법이 비교적 간단한 babel/parser
를 사용해봤습니다.
먼저 parser
를 사용하기 위해 @babel/core
를 설치합니다.
npm install --save-dev @babel/core
AST 를 만들어보기 위한 타겟 파일은 아래와 같습니다.
index.js
에서 sum.js
를 import 해 호출하는 간단한 함수입니다.
// examples/index.js
import sum from "./sum.js";
console.log(`sum of 4 + 3 = ${sum(4, 3)}`);
// examples/sum.js
function sum(a, b) {
return a + b;
}
export default sum;
babel parser를 사용해 examples 의 AST 를 생성할 main 코드를 작성했습니다.
import fs from 'fs';
import { fileURLToPath } from "url";
import path from 'path';
import babel from '@babel/core';
function main(entry) {
const content = fs.readFileSync(entry, 'utf-8');
const ast = babel.parseSync(content);
console.log(entry);
console.log(ast);
}
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const entry = path.join(__dirname, 'examples/index.js');
main(entry);
해당 코드에서는 입력된 entry path 에 있는 파일을 읽어오고, babel.parseSync
메서드를 통해 AST 를 생성하고 있습니다.
/Users/jobchae/Desktop/jobchae/bundler-test/examples/index.js
Node {
type: 'File',
start: 0,
end: 69,
loc: SourceLocation {
start: Position { line: 1, column: 0, index: 0 },
end: Position { line: 3, column: 43, index: 69 },
filename: undefined,
identifierName: undefined
},
errors: [],
program: Node {
type: 'Program',
start: 0,
end: 69,
loc: SourceLocation {
start: [Position],
end: [Position],
filename: undefined,
identifierName: undefined
},
sourceType: 'module',
interpreter: null,
body: [ [Node], [Node] ],
directives: []
},
comments: []
}
실제로 생성한 AST 를 출력해보면 이런 구조가 나옵니다. 여기서 종속성을 어떻게 확인할까요?
지금은 안보이지만, program -> body 를 타고 들어가보면 새로운 친구가 보일겁니다.
function main(entry) {
const content = fs.readFileSync(entry, 'utf-8');
const ast = babel.parseSync(content);
console.log(entry);
console.log(ast.program.body);
}
메인 함수에서 AST 내부를 더 출력해봅니다.
[
Node {
type: 'ImportDeclaration',
start: 0,
end: 27,
loc: SourceLocation {
start: [Position],
end: [Position],
filename: undefined,
identifierName: undefined
},
specifiers: [ [Node] ],
source: Node {
type: 'StringLiteral',
start: 16,
end: 26,
loc: [SourceLocation],
extra: [Object],
value: './sum.js'
}
},
Node {
type: 'ExpressionStatement',
start: 29,
end: 71,
loc: SourceLocation {
start: [Position],
end: [Position],
filename: undefined,
identifierName: undefined
},
expression: Node {
type: 'CallExpression',
start: 29,
end: 71,
loc: [SourceLocation],
callee: [Node],
arguments: [Array]
}
}
]
이렇게 새로운 노드가 나타납니다!
맞습니다. babel 에서는 ImportDeclaration
이란 type 을 통해 종속성을 표현하고 있습니다.
value에는 './sum.js' 란 상대 경로도 명시되어있습니다.
bundler는 이렇게 AST 를 만들어 내부 노드로 존재하는 ImportDeclaration
을 통해 종속성을 파악하고 있습니다.
아주 간단하게는 아래 코드처럼 body 를 순회하며 ImportDeclaration
을 찾아내면 해당 path 를 그래프의 새로운 노드로 만들어주는 구조로 종속성 그래프를 만들어갈 수 있습니다.
this.ast.program.body
.filter(node => node.type === 'ImportDeclaration')
.map(node => node.source.value)
.map(relativePath => resolveRequest(this.filePath, relativePath))
.map(absolutePath => createModule(absolutePath));
물론 실제 번들러에서는 이렇게 간단하게 그래프를 만든다고 끝은 아닙니다.
위에 명시된 코드(resolveRequeset
메서드)처럼 AST 에 있는 상대 경로를 절대 경로로 만들어주는 resolve 과정도 필요합니다.
js 생태계에는 node\_modules
라는 종속성 킹이 존재하기 때문에 해당 파일이 참조한 파일의 절대 경로를 찾아내는 resolve 과정이 필요한 것입니다.
또한, js 에서는 Circular Dependency
같은 문제를 언제든 만날 수 있기 때문에 이런 문제도 염두하고 그래프를 만들어야합니다.
순환 종속성을 염두하지 않고 막무가내로 그래프의 노드를 만든다면 번들러가 많이 아파할거예요.
하나로 합치기
이제 번들러의 기본 흐름도 이해했고, 번들러가 대체 종속성을 어떻게 찾아가는지도 정말 대충 안 것 같습니다.
사실 번들러의 결과물은 1개의 파일이잖아요. 그렇다면 1개의 파일로 대체 어떻게 합친다는 걸까요?
하나로 합치는 방식도 번들러마다 약간의 차이가 있습니다.
여러 자료를 참고하며 2가지 방식을 알아봤는데요.
첫번째 방식은 webpack
이 접근하는 방식입니다.
이해를 돕기 위해 수정이 있어 실제 webpack이 번들링한 결과물과는 다를 수 있습니다.
const moduleMap = {
"/Users/jobchae/Desktop/jobchae/bundler-test/examples/index.js":
function (exports, require) {
const _imported = require("/Users/jobchae/Desktop/jobchae/bundler-test/examples/sum.js");
console.log(`sum of 4 + 3 = ${_imported["default"](4, 3)}`);
},
"/Users/jobchae/Desktop/jobchae/bundler-test/examples/sum.js":
function (exports, require) {
function sum(a, b) {
return a + b;
}
exports.default = sum;
},
}
이 방식은 각각의 모듈 자체가 모듈 팩토리라고 불리는 함수에 싸여진 module map 을 만듭니다.
module map 에서 각 모듈의 path 는 각 모듈 팩토리 함수에 매핑됩니다.
exports, require 라는 함수 자체의 파라미터를 통해 각 모듈을 내보내고 불러올 수 있습니다.
이렇게 만들어진 module map 은 runtime 함수를 통해 실행됩니다.
function start({ modules, entry }) {
const moduleCache = {};
const require = moduleName => {
// cache 에 해당 모듈이 이미 존재한다면 return 합니다.
if (moduleCache[moduleName]) {
return moduleCache[moduleName];
}
// circular dependencies 방지하기 위해 캐시를 저장합니다.
const exports = {};
moduleCache[moduleName] = exports;
// module map 에서 매칭되는 module factory 함수를 실행합니다.
modules[moduleName](exports, require);
return moduleCache[moduleName];
};
// require 함수를 실행합니다.
require(entry);
}
start({ modules, entry });
webpack 은 이런식으로 module map 에서 매핑된 함수를 실행하고,
내보내진 exports 를 캐시에 저장해두는 런타임 함수를 만들어냅니다.
해당 예제에서 exports 는 { default: function sum() ... }
이라고 보면 됩니다.
캐시에 내보낸 모듈을 저장해둠으로 순환 종속성 문제가 발생해도 캐시된 모듈만 리턴해 반복적인 함수 호출문제를 해결합니다.
두번째는 rollup
이 접근하는 방식입니다.
마찬가지로 이해를 돕기 위해 약간의 수정이 있어 실제 rollup 이 번들링한 결과물과는 다를 수 있습니다.
rollup 이 webpack 과 다른 특징은 각 모듈들을 함수로 래핑한 module map 을 사용하지 않는 것 입니다.
위의 예제를 rollup 이 접근하는 방식대로 번들링 하면 아래와 같은 결과물이 나옵니다.
function sum(a, b) {
return a + b;
}
console.log(`sum of 4 + 3 = ${sum(4, 3)}`)
rollup 방식의 번들링에서 모든 함수와 변수들은 전역 범위에서 작성됩니다.
이렇게 합쳐지기 때문에 한눈에 봐도 webpack 번들링에 비해 훨씬 간결하고 작아졌습니다.
다만 모든 변수와 함수를 전역 범위로 배치하기 때문에 배치 순서가 매우 중요해졌고,
다른 파일에 존재하는 같은 이름의 함수, 변수를 참조할 때 문제를 해결해야합니다.
이 문제를 해결하기 위해 rollup 은 참조하는 이름이 충돌 할 시 약간의 변형을 가해 해결하고 있습니다.
예를 들어 다음과 같이 a.js
, b.js
에 같은 이름에 foo 함수가 있는 예제를 생각해봅시다.
// index.js
import fooA from 'a.js'
import fooB from 'b.js'
fooA();
fooB();
// a.js
function foo() {
}
export default foo;
// b.js
function foo() {
}
export default foo;
이런 충돌 상황에서 rollup 은 아래처럼 충돌하는 이름 자체를 변형하여 해결합니다. (예시입니다)
function foo$a() {
}
function foo$b() {
}
foo$a();
foo$b();
각 함수의 이름을 변형했기 때문에 번들링 후 같은 스코프에 있어도 정상적으로 동작할 수 있습니다.
마치며
이렇게 javascript 번들러에 대해 아주 간략하게 알아보았습니다.
사실 여기에 다 적진 못했지만 더 파고들다 보면 AST
원리 라던가 Temporal Dead Zone
같은 깊은 개념들도 많이 만났는데요.
단순히 여러 javascript 모듈을 하나로 합치는 일을 하기 위해 javascript 의 다양한 개념과 컴퓨터 과학의 많은 부분을 알아야 한다는게 신기했습니다.
한편으로는 그동안 아무 생각 없이 설치해서 썼던 번들러가 참 많은 일을 하고 있다는 생각도 들었습니다 ^^..
이번 스터디를 하면서 스터디원들과 같이 토이 번들러를 만들고 있는데요.
번들러의 기본 개념들을 공부하다보면 여러분도 간단한 토이 번들러도 직접 만들어 볼 수 있습니다!
구글에도 다양한 번들러 자료들이 존재하니 javascript 를 좋아하는 개발자라면 이번 기회에 깊게 공부해보시길 추천드립니다.
저는 아직 부족한 개발자라 잘못된 개념이나 수정하면 좋을 내용이 있다면 언제든 댓글로 알려주세요.