Svelte Compiler는 어떻게 동작할까?

Dec 24, 2023

최근 프론트엔드 분야에서 React와 Vue에 이어 Svelte가 종종 거론되고 있습니다. 흥미롭게도, 구글 검색에서 ‘React Vue’를 입력하면, 늘 같이 거론되었던 Angular 대신 Svelte가 상단에 추천되는 것을 볼 수 있습니다.

231210-211816

Svelte의 핵심은 효율적인 컴파일에 있습니다.
DOM을 조작하는 JavaScript 코드를 최적화하여 생성하는 것이죠.

React를 이해하기 위해 Virtual DOM을 알아야하는 것과 같이, Svelte를 제대로 이해하기 위해서는 Svelte Compiler가 어떻게 동작하는지 알아야 합니다.

본 글은 Svelte@5.0.0-next.25 소스코드를 기준으로 설명할 예정입니다.
v5부터 내부 코드 베이스가 많이 달라지긴 했지만 전반적 흐름은 v3, v4 모두 비슷하니 참고하시는데 무방할 것 같습니다.

Svelte Compiler의 동작 방식

Svelte의 컴파일 단계는 크게 3가지 단계로 나눌 수 있습니다.

  1. Parse (분해)
  2. Analyze (분석)
  3. 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)를 만드는 것이죠. 아래 그림을 보면 쉽게 이해할 수 있을 겁니다.

231213-030554

컴파일러 소스코드 단에서는 어떤 일이 일어나는지 살펴봅시다.
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 REPLAST output 탭에서 확인할 수 있습니다.

2. 분석, 컴포넌트 내부 의존성 추적

const analysis = analyze_component(ast);

다음 단계로 만들어진 AST에서 컴포넌트의 다양한 동작을 수행하기 위한 정보를 추출합니다.
참고로 하나의 Svelte 파일이 곧 하나의 컴포넌트입니다.

231222-173628

컴포넌트가 갖는 주요 속성을 정리하면 아래와 같습니다. 원본 코드 참고

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 컴파일러는 변환 과정을 거쳐 렌더링 코드을 생성합니다.

231222-173647

이 과정에서 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_componenttemplate 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 코드가 어떻게 동작하는지 더 파헤쳐보고 싶네요.

참고