Svelte 5: 컴파일러로 만드는 가벼운 UI 프레임워크

React로 화면을 만들다 보면 useMemo를 써야 할지, useCallback으로 감싸야 할지, 의존성 배열에 뭘 빠뜨리지는 않았는지 고민하는 시간이 꽤 길어지는데요.
값이 바뀌면 화면이 바뀐다는 단순한 아이디어를 위해 이렇게 많은 걸 신경 써야 하는지 의문이 들 때가 있습니다. 🤔
Svelte는 이 문제를 런타임이 아니라 컴파일러로 풀어 온 프레임워크입니다. 그리고 2024년 10월 19일, 반응성(reactivity) 모델을 처음부터 다시 설계한 Svelte 5가 정식으로 출시됐는데요. 이번 글에서는 Svelte가 다른 프레임워크와 무엇이 다른지 살펴보고, Svelte 5의 핵심인 룬(rune)으로 실제 컴포넌트를 만들어 보겠습니다.
Svelte가 다른 점
React나 Vue.js는 브라우저에서 가상 DOM(virtual DOM)을 만들고, 상태가 바뀔 때마다 이전 가상 DOM과 비교해서 실제 DOM에 반영합니다. 이 비교 작업을 담당하는 코드가 프레임워크 런타임에 들어 있어서 애플리케이션과 함께 브라우저로 내려가죠.
Svelte는 접근 방식이 반대입니다. 빌드 시점에 컴포넌트를 분석해서 “이 값이 바뀌면 이 텍스트 노드만 고친다”는 명령형 자바스크립트로 미리 바꿔 둡니다. 브라우저에서는 비교할 것이 없으니 가상 DOM이 필요 없고, 런타임도 그만큼 작아집니다.
실제로 버튼 하나짜리 카운터 컴포넌트를 컴파일러에 넣어 보면 다음과 같은 코드가 나옵니다.
<script>
let count = $state(0);
</script>
<button onclick={() => count++}>
클릭: {count}
</button>
import "svelte/internal/disclose-version";
import * as $ from "svelte/internal/client";
var root = $.from_html(`<button> </button>`);
export default function Counter($$anchor) {
let count = $.state(0);
var button = root();
var text = $.only_child(button);
$.template_effect(() => $.set_text(text, `클릭: ${$.get(count) ?? ""}`));
$.delegated("click", button, () => $.update(count));
$.append($$anchor, button);
}
$.delegate(["click"]);
count가 바뀌면 버튼 안의 텍스트 노드 하나만 set_text로 갱신하는 코드가 만들어진 것을 볼 수 있습니다.
컴포넌트 함수가 매번 다시 실행되는 React와 달리, Svelte 컴포넌트 함수는 처음 한 번만 실행되고 이후에는 바뀐 부분만 정확히 고칩니다.
그래서 Svelte에서는 리렌더링을 막으려고 메모이제이션(memoization)을 할 일이 거의 없습니다.
프로젝트 만들기
Svelte만 단독으로 써 보려면 Vite의 Svelte 템플릿이 가장 간단합니다. Vite가 처음이라면 Vite 처음 시작하기를 먼저 읽어 보셔도 좋습니다. Bun으로 다음과 같이 프로젝트를 만들고 개발 서버를 띄웁니다.
bun create vite svelte-app --template svelte-ts
cd svelte-app
bun install
bun run dev
npm을 쓴다면 다음과 같이 시작합니다.
npm create vite@latest svelte-app -- --template svelte-ts
생성된 프로젝트의 진입점을 열어 보면 mount() 함수로 루트 컴포넌트를 DOM에 붙이고 있는데요.
Svelte 4까지는 new App({ target })처럼 클래스로 생성했지만 Svelte 5부터는 함수로 바뀌었습니다.
import { mount } from "svelte";
import "./app.css";
import App from "./App.svelte";
const app = mount(App, {
target: document.getElementById("app")!,
});
export default app;
라우팅이나 서버 사이드 렌더링까지 필요하다면 Svelte 팀이 만든 메타 프레임워크인 SvelteKit을 쓰는 것이 일반적입니다. 메타 프레임워크가 무엇인지는 메타 프레임워크 - 프레임워크를 위한 프레임워크에서 다룬 적이 있습니다.
.svelte 파일의 구조
Svelte 컴포넌트는 .svelte 확장자 파일 하나에 자바스크립트, 마크업, CSS를 함께 담습니다.
HTML 파일을 쓰듯이 작성하면 되니 처음 봐도 어렵지 않은데요.
<script lang="ts">
let count = $state(0);
</script>
<button onclick={() => count++}>
클릭: {count}
</button>
<style>
button {
font-size: 1.25rem;
}
</style>
<script> 안에는 컴포넌트 로직을, 그 아래에는 마크업을, <style> 안에는 CSS를 씁니다.
lang="ts"를 붙이면 별도 설정 없이 TypeScript를 쓸 수 있습니다.
<style>에 쓴 CSS는 자동으로 이 컴포넌트에만 적용됩니다.
컴파일러가 요소마다 svelte-xyz123 같은 고유 클래스를 붙여서 선택자 범위를 좁혀 주기 때문에, 위 button 선택자가 다른 컴포넌트의 버튼까지 바꾸는 일은 없습니다.
이벤트 핸들러는 onclick처럼 HTML 속성과 같은 이름으로 넘깁니다.
Svelte 4에서 쓰던 on:click 지시어 대신 일반 속성이 되면서, 핸들러를 다른 속성처럼 전달하거나 펼쳐서 넘기기도 쉬워졌습니다.
$state로 상태 선언하기
Svelte 5의 가장 큰 변화는 룬입니다.
룬은 $로 시작하는 특수한 함수로, 컴파일러에게 “이 값은 반응형으로 다뤄 달라”고 알려 주는 키워드 역할을 합니다.
import하지 않아도 .svelte 파일과 .svelte.js, .svelte.ts 파일 안에서 바로 쓸 수 있습니다.
상태는 $state로 선언합니다.
<script lang="ts">
let count = $state(0);
</script>
<button onclick={() => count++}>클릭: {count}</button>
React의 useState 훅이라면 const [count, setCount] = useState(0)처럼 값과 setter를 따로 받아야 했을 텐데요.
Svelte에서는 평범한 변수처럼 count++라고 쓰면 끝입니다.
읽는 쪽도 count 그대로 쓰면 되죠.
$state에 배열이나 객체를 넘기면 깊은 반응성(deep reactivity)이 적용됩니다.
내부적으로 자바스크립트 프록시(Proxy)로 감싸기 때문에, 배열에 push를 하거나 중첩된 속성 하나만 바꿔도 화면이 갱신됩니다.
<script lang="ts">
type Todo = { id: number; text: string; done: boolean };
let todos = $state<Todo[]>([
{ id: 1, text: "Svelte 5 설치하기", done: true },
{ id: 2, text: "룬 익히기", done: false },
]);
function addTodo(text: string) {
// 새 배열을 만들 필요 없이 그대로 push해도 반응합니다
todos.push({ id: Date.now(), text, done: false });
}
</script>
React에서 불변성을 지키려고 스프레드 연산자로 배열을 복사하던 습관이 있다면 처음에는 어색할 수 있어요.
물론 todos = todos.filter(...)처럼 새 배열을 대입하는 방식도 그대로 동작하니 상황에 맞게 고르면 됩니다.
$derived로 파생 상태 만들기
다른 상태에서 계산되는 값은 $derived로 선언합니다.
<script lang="ts">
let count = $state(0);
let doubled = $derived(count * 2);
</script>
<button onclick={() => count++}>클릭: {count}</button>
<p>두 배: {doubled}</p>
$derived 안에서 읽은 상태가 바뀔 때만 다시 계산되고, 아무도 그 값을 읽지 않으면 계산을 미룹니다.
의존성 배열을 직접 적지 않아도 컴파일러와 런타임이 어떤 상태를 읽었는지 알아서 추적하기 때문에, useMemo처럼 의존성을 빠뜨려서 생기는 버그가 없습니다.
계산이 한 줄로 끝나지 않는다면 $derived.by에 함수를 넘깁니다.
let remaining = $derived.by(() => {
const left = todos.filter((todo) => !todo.done);
return left.length;
});
$effect로 부수 효과 실행하기
상태가 바뀔 때 DOM 밖의 무언가를 건드리는 부수 효과(side effect)가 필요하다면 $effect를 씁니다.
문서 제목을 바꾸거나, localStorage에 저장하거나, 외부 라이브러리를 호출하는 경우가 여기에 해당합니다.
<script lang="ts">
let count = $state(0);
$effect(() => {
document.title = `클릭 ${count}번`;
});
</script>
$effect도 실행 중에 읽은 상태를 자동으로 추적합니다.
컴포넌트가 화면에 붙은 뒤 한 번 실행되고, 이후에는 count가 바뀔 때마다 다시 실행되죠.
함수를 반환하면 다음 실행 직전이나 컴포넌트가 사라질 때 정리(cleanup) 함수로 호출됩니다.
$effect(() => {
const id = setInterval(() => count++, 1000);
return () => clearInterval(id);
});
여기서 흔히 하는 실수가 하나 있는데요.
$effect 안에서 다른 상태를 계산해서 대입하는 코드입니다.
// 이렇게 쓰지 마세요
let doubled = $state(0);
$effect(() => {
doubled = count * 2;
});
이 코드는 동작은 하지만, 화면이 한 번 그려진 뒤에 값이 따라 바뀌기 때문에 불필요한 갱신이 생기고 흐름도 추적하기 어려워집니다.
다른 상태로부터 계산할 수 있는 값이라면 항상 $derived를 쓰고, $effect는 Svelte 바깥 세계와 동기화할 때만 쓰는 것이 원칙입니다.
React의 useEffect에서 배운 같은 교훈이 Svelte에도 그대로 적용됩니다.
$props로 속성 받기
부모 컴포넌트가 넘긴 속성(props)은 $props로 받습니다.
구조 분해 할당(destructuring)으로 받으면서 기본값을 지정하고, TypeScript 타입도 그대로 붙입니다.
<script lang="ts">
type Props = {
text: string;
done?: boolean;
onremove: () => void;
};
let { text, done = $bindable(false), onremove }: Props = $props();
</script>
<li>
<label>
<input type="checkbox" bind:checked={done} />
<span class:done>{text}</span>
</label>
<button onclick={onremove}>삭제</button>
</li>
<style>
.done {
text-decoration: line-through;
opacity: 0.6;
}
</style>
이 컴포넌트에는 눈여겨볼 문법이 몇 가지 있습니다.
먼저 bind:checked는 양방향 바인딩(two-way binding)입니다.
체크박스를 누르면 done 변수가 바뀌고, done이 바뀌면 체크박스 상태도 따라 바뀝니다.
<input>에서는 bind:value를 같은 방식으로 쓸 수 있습니다.
또한 class:done은 done이 참일 때만 done 클래스를 붙이는 축약 문법입니다.
변수 이름과 클래스 이름이 같으면 class:done={done}을 이렇게 줄여 씁니다.
그리고 $bindable은 이 prop을 부모도 bind:로 묶도록 열어 줍니다.
Svelte에서 props는 기본적으로 부모에서 자식으로만 흐르는데, $bindable로 표시한 prop만 자식의 변경이 부모 상태에 반영됩니다.
어떤 값이 양방향으로 흐르는지 컴포넌트 선언만 보고 알 수 있게 만든 장치입니다.
마지막으로 onremove처럼 콜백 함수를 prop으로 받아 부모에게 알리는 방식이 Svelte 5의 이벤트 전달 방법입니다.
Svelte 4의 createEventDispatcher는 더 이상 필요하지 않습니다.
템플릿 블록으로 조건과 반복 표현하기
마크업 안에서 조건과 반복은 {#if}와 {#each} 블록으로 표현합니다.
앞에서 만든 TodoItem을 써서 할 일 목록을 그려 보겠습니다.
<script lang="ts">
import TodoItem from "./lib/TodoItem.svelte";
type Todo = { id: number; text: string; done: boolean };
let todos = $state<Todo[]>([
{ id: 1, text: "Svelte 5 설치하기", done: true },
{ id: 2, text: "룬 익히기", done: false },
]);
let newText = $state("");
let remaining = $derived(todos.filter((todo) => !todo.done).length);
function addTodo(event: SubmitEvent) {
event.preventDefault();
if (!newText.trim()) return;
todos.push({ id: Date.now(), text: newText, done: false });
newText = "";
}
function removeTodo(id: number) {
todos = todos.filter((todo) => todo.id !== id);
}
</script>
<h2>할 일 ({remaining}개 남음)</h2>
<form onsubmit={addTodo}>
<input bind:value={newText} placeholder="할 일을 입력하세요" />
<button>추가</button>
</form>
{#if todos.length === 0}
<p>할 일이 없어요 🎉</p>
{:else}
<ul>
{#each todos as todo (todo.id)}
<TodoItem
text={todo.text}
bind:done={todo.done}
onremove={() => removeTodo(todo.id)}
/>
{/each}
</ul>
{/if}
{#each todos as todo (todo.id)}에서 괄호 안의 todo.id는 각 항목을 구분하는 키입니다.
React의 key prop과 같은 역할로, 항목을 삭제하거나 순서를 바꿀 때 엉뚱한 DOM이 재사용되지 않게 해 줍니다.
bind:done={todo.done}도 주목해 볼 만한데요.
자식이 $bindable로 열어 둔 done을 배열 안 객체의 속성에 직접 묶었습니다.
체크박스를 누르면 todos 배열의 해당 객체가 바뀌고, $derived로 만든 remaining도 자동으로 다시 계산됩니다.
상태 끌어올리기(lifting state up)를 위해 콜백을 여러 단계 내려보내지 않아도 되는 거죠.
스니펫으로 마크업 조각 전달하기
컴포넌트에 텍스트가 아닌 마크업을 넘기고 싶을 때가 있습니다.
Svelte 4에서는 <slot>을 썼지만, Svelte 5에서는 스니펫(snippet)으로 바뀌었습니다.
<script lang="ts">
import type { Snippet } from "svelte";
let { title, children }: { title: Snippet; children: Snippet } = $props();
</script>
<section class="card">
<h2>{@render title()}</h2>
{@render children()}
</section>
스니펫은 {@render} 태그로 원하는 위치에 그립니다.
컴포넌트 태그 사이에 넣은 내용은 자동으로 children prop이 되고, 이름이 있는 스니펫은 {#snippet} 블록으로 만들어 넘깁니다.
<Card>
{#snippet title()}
할 일 <small>({remaining}개 남음)</small>
{/snippet}
<p>여기 쓴 내용은 children으로 전달됩니다.</p>
</Card>
스니펫은 함수처럼 인자도 받습니다. 그래서 목록 컴포넌트가 각 항목을 어떻게 그릴지 부모에게 맡기는 패턴도 간단히 만들어지죠. React의 렌더 프롭(render prop)을 템플릿 문법으로 옮겨 온 셈입니다.
Svelte 4에서 달라진 점
Svelte 4를 써 본 분이라면 문법이 꽤 많이 바뀌었다고 느끼실 텐데요. 주요 변경 사항을 표로 정리하면 다음과 같습니다.
| 용도 | Svelte 4 | Svelte 5 |
|---|---|---|
| 상태 선언 | let count = 0 | let count = $state(0) |
| 파생 값 | $: doubled = count * 2 | let doubled = $derived(...) |
| 부수 효과 | $: { ... } | $effect(() => { ... }) |
| props 받기 | export let name | let { name } = $props() |
| 이벤트 핸들러 | on:click={...} | onclick={...} |
| 이벤트 전달 | createEventDispatcher | 콜백 prop |
| 마크업 전달 | <slot /> | {@render children()} |
| 마운트 | new App({ target }) | mount(App, { target }) |
Svelte 4의 let 선언은 컴포넌트 최상위에 있을 때만 반응형이었고, $: 레이블은 파생 값과 부수 효과를 구분하지 못했습니다.
그래서 컴포넌트가 커지면 어떤 코드가 언제 다시 실행되는지 파악하기 어려웠죠.
룬은 반응형인 부분을 코드에 명시적으로 드러냅니다.
게다가 컴포넌트 밖에서도 동작해서, .svelte.ts 파일로 상태 로직을 분리해 여러 컴포넌트가 함께 쓰게 만들 수도 있습니다.
다행히 Svelte 5는 Svelte 4 문법도 그대로 지원합니다. 컴포넌트 단위로 하나씩 옮겨 갈 수 있고, 다음 명령어로 자동 마이그레이션을 돌릴 수도 있습니다.
bunx sv migrate svelte-5
마치며
지금까지 컴파일러 기반 프레임워크인 Svelte 5를 살펴보았습니다.
Svelte는 가상 DOM 비교 대신 빌드 시점에 정확한 DOM 갱신 코드를 만들어 내고, Svelte 5는 여기에 $state, $derived, $effect, $props 같은 룬을 더해 어디가 반응형인지 코드만 보고 알 수 있게 만들었습니다.
React의 훅 규칙이나 의존성 배열에 지쳤다면 작은 프로젝트에서 한번 써 보시길 권합니다. 😊
다음 단계로는 라우팅과 서버 렌더링까지 맡아 주는 SvelteKit을 살펴보시길 권합니다.
더 자세한 내용은 Svelte 공식 문서를 참고하세요.
This work is licensed under CC BY 4.0