최근 프론트엔드 분야에서 React와 Vue에 이어 Svelte가 종종 거론되고 있습니다. 흥미롭게도, 구글 검색에서 ‘React Vue’를 입력하면, 늘 같이 거론되었던 Angular 대신 Svelte가 상단에 추천되는 것을 볼 수 있습니다.
Svelte의 핵심은 효율적인 컴파일에 있습니다.
DOM을 조작하는 JavaScript 코드를 최적화하여 생성하는 것이죠.
React를 이해하기 위해 Virtual DOM을 알아야하는 것과 같이, Svelte를 제대로 이해하기 위해서는 Svelte Compiler가 어떻게 동작하는지 알아야 합니다.
본 글은 Svelte@5.0.0-next.25 소스코드를 기준으로 설명할 예정입니다.
v5부터 내부 코드 베이스가 많이 달라지긴 했지만 전반적 흐름은 v3, v4 모두 비슷하니 참고하시는데 무방할 것 같습니다.
Svelte Compiler의 동작 방식
Svelte의 컴파일 단계는 크게 3가지 단계로 나눌 수 있습니다.
- Parse (분해)
- Analyze (분석)
- Transform (변환)
Svelte Compiler 소스코드(버전 3 기준은 여기)에서 직관적으로 힌트를 얻을 수 있는데요.
전체적인 흐름을 정리하면 아래와 같습니다.
const source = fs.readFileSync('App.svelte');
// 1. 분해, Svelte 코드를 AST로 변환
const ast = parse(source);
// 2. 분석, 컴포넌트 내부 의존성 추적
const analysis = analyze_component(ast);
// 3. 변환, 코드 블록 및 조각 생성
const compiled = transform_component(analysis, source);
fs.writeFileSync('App.js', compiled.js.code);
fs.writeFileSync('App.css', compiled.css.code);
App.svelte
소스코드를 읽고 일련의 과정을 거쳐서 최종적으로 App.js
, App.css
파일을 생성합니다. 이 과정은 마치 마법처럼 느껴지는데요, 도대체 어떤 일이 벌어지고 있는지 한 단계씩 자세히 살펴보도록 합시다!
1. 분해, Svelte 코드를 AST로 변환
const ast = parse(source);
추상 구문 트리(abstract syntax tree, AST)는 컴파일러에서 널리 쓰이는 자료구조입니다.
간단하게 설명하자면, 코드 간의 관계를 표현하기 위한 트리 구조입니다.
Svelte 소스코드를 AST로 변환함으로써 컴파일러는 코드간의 연관관계를 알 수 있게 됩니다.
Svelte 파일에 작성된 script, html, css 코드를 분리하여 하나의 컴포넌트 객체(root)를 만드는 것이죠. 아래 그림을 보면 쉽게 이해할 수 있을 겁니다.
컴파일러 소스코드 단에서는 어떤 일이 일어나는지 살펴봅시다.
parse(source)
가 실행하면, Parser
객체가 생성되면서 AST를 반환합니다.
const ast = new Parser(source).root;
Parser
생성자 내부에는 fragment
함수를 활용해 스스로를 반복적으로 파싱하게 됩니다.
/** @type {ParserState} */
let state = fragment;
while (this.index < this.template.length) {
state = state(this) || fragment;
}
fragment
내부에서는 element
, tag
, text
함수가 조건에 따라 실행되면서 소스코드 정보를 추출합니다.
import element from './element.js';
import tag from './tag.js';
import text from './text.js';
export default function fragment(parser) {
if (parser.match('<')) {
return element;
}
if (parser.match('{')) {
return tag;
}
return text;
}
script
태그로 감싸진 영역은 read_script
를 통해 파싱되는데 내부적으로는 acorn
를 사용합니다. acorn
은 ”A tiny, fast JavaScript parser“로 webpack
, eslint
같이 JS 소스코드를 다뤄야하는 라이브러리에서 널리 쓰이는 오픈소스 라이브러리입니다.
이외의 영역은 모두 Svelte Compiler 자체 파싱로직을 사용합니다.
style
태그를 만나면 read_style
이 활용되고, 이외 여러 유틸을 통해서 div
같은 일반적인 HTML 문법을 넘어서, {#each list as item}
<svelte:component>
같은 Svelte만의 문법을 해석하게 됩니다. 과정에서 유효하지 않는 코드, 웹 접근성(a11y)에 위배된 warning과 error를 기록해 알려주기도 하죠.
최종적으론 아래와 같은 JSON 형태의 데이터가 만들어집니다.
{
html: { type, start, end, children }
css: { type, start, end, attributes, children, content }
instance: { type, start, end, context, content }
module: { type, start, end, context, content }
}
더 자세한 내용은 Svelte REPL의 AST output
탭에서 확인할 수 있습니다.
2. 분석, 컴포넌트 내부 의존성 추적
const analysis = analyze_component(ast);
다음 단계로 만들어진 AST에서 컴포넌트의 다양한 동작을 수행하기 위한 정보를 추출합니다.
참고로 하나의 Svelte 파일이 곧 하나의 컴포넌트입니다.
컴포넌트가 갖는 주요 속성을 정리하면 아래와 같습니다. 원본 코드 참고
const analysis = {
root: scope_root,
module,
instance,
template,
stylesheet: new Stylesheet({...}),
// 다양한 컴퍼일 옵션
runes,
warnings,
reactive_statements: new Map(),
binding_groups: new Map(),
slot_names: new Set(),
...
};
a. scope_root
const scope_root = new ScopeRoot();
ScopeRoot
는 이름 그대로 컴포넌트의 최상위 스코프으로서 역할을 하는 객체입니다. 내부적으로 Set
자료구조를 활용하여 변수, 함수 등의 식별자의 고유성을 보장합니다.
b. module
, instance
function js(...) {
const { scope, scopes } = create_scopes(...);
return { ast, scope, scopes };
}
const module = js(root.module, scope_root, false, null);
const instance = js(root.instance, scope_root, true, module.scope);
instance script와 module script의 AST을 순회하면서 변수가 참조되는 모든 영역을 파악합니다. 이로써 변수의 변경이 발생될 수 있는 모든 상황을 알 수 있게 됩니다. 과정에서 scope_root
를 참고하여 스크립트의 하위 스코프를 생성하면서 변수들의 고유 식별자를 부여합니다.
여기서 Svelte에서 module과 instance의 차이도 간단하게 짚고 넘어갑시다.
- module
- 컴포넌트 간에 공유되는 상태와 로직을 정의합니다.
<script context="module">
로 해당 영역을 선언할 수 있습니다.- 간단하게 전역 변수가 선언되는 영역이라고 생각하면 됩니다.
- 영역 안에서
reactive
코드를 작성할 수 없습니다.
- instance
- 컴포넌트의 고유 상태와 로직을 정의합니다.
- 일반적으로 작성되는 스크립트 영역입니다.
c. template
const { scope, scopes } = create_scopes(root.fragment, ...);
const template = { ast: root.fragment, scope, scopes };
root.fragment
는 HTML AST를 지칭하는 변수로 이를 순회하면서 마크업 영역에서의 스코프를 파악합니다. {data}
{#if}
{#each}
{@const}
class:
등 문법이 이에 해당 됩니다.
d. stylesheet
const stylesheet = new Stylesheet({
ast: root.css,
filename: options.filename ?? '<unknown>',
component_name,
get_css_hash: options.cssHash,
});
Stylesheet
객체는 CSS AST를 순회하면서 컴포넌트 스코프 안에서 사용되는 CSS selector를 선별합니다. 만약 :global(...)
, -global-
로 CSS selector가 선언되어 있다면 별도로 기록해둡니다. 이러한 문법에 대해 더 알고 싶다면 공식문서 참고 바랍니다.
이후에 전반적으로 수집된 analysis
정보 바탕으로 여러 최적화 작업을 진행합니다.
analysis.stylesheet.validate(analysis);
for (const element of analysis.elements) {
analysis.stylesheet.apply(element);
}
analysis.stylesheet.reify(options.generate === 'client');
- 중복된 전역 CSS selector와 컴포넌트 스코프 안에서 사용되지 않는 CSS selector를 제거합니다.
- 컴포넌트 스코프 안에서 사용되는 CSS selector를
.svelte-xxx
형태로 해싱합니다. 이로써 같은 이름의 selector가 충돌되지 않게 됩니다.
e. 최종 순회
이렇게 얻게된 analysis
정보를 바탕으로 AST를 다시 순회하면서 컴포넌트의 상태를 최적화합니다.
walk(
/** @type {import('#compiler').SvelteNode} */ (ast),
/** @type {import('./types').AnalysisState} */ (state),
merge(
set_scope(scopes),
validation_runes,
runes_scope_tweaker,
common_visitors,
),
);
- validation_runes
- 잘못된 할당이나 업데이트 표현식을 검사합니다.
- 변수의 선언과 내보내기 관련 유효성을 검사합니다.
- 신규
reactive
문법인runes
구문의 유효성을 검사합니다.
- runes_scope_tweaker
- 스코프 조정을 수행합니다.
- 특정 패턴을 사용하는 변수 선언에 대해 스코프 및 바인딩의 종류를 설정합니다.
- 상태 변동이 없는 변수나 함수를 instance 외부로 옮깁니다.
- common_visitors
directive
,binding
관련 지시어를 처리합니다.- 일반 HTML 및 Svelte 특정 엘리먼트의 처리를 수행합니다.
- 이벤트 관련 속성을 처리하고, 이벤트 위임 또는 호이스팅 여부를 결정합니다.
3. 변환, 코드 블록 및 조각 생성
const compiled = transform_component(analysis, source);
마지막으로, Svelte 컴파일러는 변환 과정을 거쳐 렌더링 코드을 생성합니다.
이 과정에서 SSR(Server-Side Rendering), CSR(Client-Side Rendering)를 위한 코드 생성 로직이 각각 다릅니다. 컴파일러는 server_component
, client_component
두 함수를 사용하여 각각의 상황에 최적화된 코드를 생성합니다.
const program =
options.generate === 'server'
? server_component(analysis, options)
: client_component(source, analysis, options);
SSR의 특성
SSR에서는 컴포넌트가 단 한 번만 렌더링되며, 컴포넌트의 생명주기가 존재하지 않습니다.
따라서 server_component
는 template literals
을 생성하는 것에 초점이 맞춰져 있습니다. javascript_visitors
, template_visitors
등 visitor를 활용해 코드 블럭을 추가합니다.
CSR의 특성
CSR에서는 DOM과 지속적인 상호작용이 요구되며 컴포넌트의 생명주기를 갖춰야 합니다.
따라서 client_component
은 더 다양하고 복잡한 visitor로 구성되어 있습니다. 같은 이름의 javascript_visitors
라도 함수에 대한 처리 로직이 추가되어 순회합니다.
코드 생성
return {
js: print(program, { sourceMapSource: options.filename }),
css:
analysis.stylesheet.has_styles && !analysis.inject_styles
? analysis.stylesheet.render(options.filename, source, options.dev)
: null,
// ...
};
최종적으로, print
render
함수를 실행하여 js, css 코드를 생성합니다.
이렇게 생성된 코드는 번들러에게 전달되며 vite-plugin-svelte
, svelte-loader
같은 플러그인을 통해 브라우저에서 사용될 번들로 변환됩니다.
맺으면서
Svelte는 이렇게 컴파일러를 통해서 빌드 단계에서 코드 간의 의존성을 파악합니다.
소스코드를 여러 번 순회하면서 html, js, css간의 의존성을 분석하고 효율적으로 DOM을 다루는 코드를 생성합니다. 이것이 런타임에서 의존성을 파악 해야 하는 VDOM 기반 프레임워크인 React, Vue 다음으로 주목을 받게되는 요인인 것 같습니다.
컴파일러 소스코드를 열심히 팔로업해서 글을 작성했지만 사실 컴파일러 동작 원리의 겉 핥기한 것에 불과했던 것 같습니다. 기회가 된다면 컴파일된 Svelte 코드가 어떻게 동작하는지 더 파헤쳐보고 싶네요.