프롤로그 — 컴파일러 마법이 끝나고, 룬이 시작되었다
Svelte 3은 약속이었다. "리액티브 코드는 컴파일이 처리해야 한다, 런타임은 가벼워야 한다." let count = 0이 반응성을 갖고, $: doubled = count * 2가 자동으로 추적되고, 우리는 React의 useState 의존성 배열 없이도 살 수 있었다.
그러나 그 약속에는 작은 글씨가 있었다.
- 컴포넌트 안에서만 반응성 —
.svelte파일 밖, 일반.ts/.js에서는$:이 동작하지 않는다. $:라벨의 두 얼굴 — 같은 문법이 "파생 값"이기도 하고 "사이드이펙트"이기도 했다. 컴파일러가 알아서 판별했지만, 사람은 종종 헷갈렸다.let이 마법이었다 — 컴포넌트 최상위let은 반응성이고, 함수 안의let은 평범한 변수다. 둘이 같은 문법인 게 문제였다.- TypeScript와의 마찰 — 컴파일러가 의미를 바꾸는 식별자에 타입을 붙이기는 까다로웠다.
2024년 10월 22일, Svelte 5가 GA로 나왔다. 그리고 한 줄로 그 모든 작은 글씨를 지웠다.
"반응성은 더 이상 마법이 아니다. 룬이다."
룬(rune)은 $state·$derived·$effect·$props로 시작하는 네 개의 함수처럼 보이는 컴파일러 키워드다. 컴포넌트 안에서도, 밖에서도, 클래스 안에서도, 모듈 어디에서도 똑같이 동작한다. 의미는 한 줄로 읽힌다.
<script>
// Svelte 5
let count = $state(0)
let doubled = $derived(count * 2)
$effect(() => console.log(count))
</script>
이 글은 그 변화를 처음부터 끝까지 — 왜·무엇을·어떻게·언제 마이그레이션할지 — 한 호흡으로 정리한다. 그리고 그 옆에 Solid의 시그널, Vue의 ref/computed, MobX의 observable, React Compiler의 자동 메모이제이션을 놓고, "2026년 우리는 어디쯤 와 있는가"를 묻는다.
1장 · 왜 룬인가 — 암묵에서 명시로
먼저 Svelte 4와 Svelte 5의 같은 카운터를 나란히 본다.
<!-- Svelte 4 -->
<script>
let count = 0
$: doubled = count * 2
$: if (count > 10) console.log('high')
function increment() {
count += 1
}
</script>
<button on:click={increment}>{count}</button>
<p>doubled: {doubled}</p>
<!-- Svelte 5 -->
<script>
let count = $state(0)
let doubled = $derived(count * 2)
$effect(() => {
if (count > 10) console.log('high')
})
function increment() {
count += 1
}
</script>
<button onclick={increment}>{count}</button>
<p>doubled: {doubled}</p>
차이는 두 가지다.
- 반응성이 식별자에 붙어 있다, 위치에 붙어 있지 않다. Svelte 4는 "최상위
let"이라는 위치 규칙이었다. Svelte 5는$state()를 통과한 값만 반응적이다. $:라벨의 두 얼굴이 분리되었다. 파생 값은$derived, 사이드이펙트는$effect로 명시한다.
이게 왜 중요한가? 세 가지 시나리오를 보자.
1.1 컴포넌트 밖에서의 반응성
Svelte 4에서 카운터 로직을 별도 파일로 빼고 싶었다고 하자.
// counter.js (Svelte 4 시대 — 동작하지 않는다)
let count = 0
$: doubled = count * 2 // ← .svelte 파일이 아니면 컴파일러가 무시
$:는 .svelte 파일의 컴파일러 지시어였다. 일반 .js/.ts에서는 무의미한 JS 라벨 문법이었다. 그래서 우리는 store라는 별도의 추상을 썼다. writable·readable·derived 그리고 $store 접두 문법.
Svelte 5에서는?
// counter.svelte.js
export function createCounter() {
let count = $state(0)
let doubled = $derived(count * 2)
return {
get count() { return count },
get doubled() { return doubled },
increment: () => (count += 1),
}
}
확장자가 .svelte.js 또는 .svelte.ts면 컴파일러가 룬을 처리한다. 결과적으로 store가 따로 필요 없다. 룬 하나로 컴포넌트 안과 밖이 통일된다.
1.2 $:의 두 얼굴
Svelte 4의 $:는 같은 문법이 두 가지 의미를 가졌다. 컴파일러가 우변을 분석해서 "할당이면 파생, 표현식이면 이펙트"로 갈랐다.
<script>
let a = 1
let b = 2
$: sum = a + b // ← 파생 (할당)
$: console.log(a, b) // ← 이펙트 (표현식)
$: if (sum > 5) alert() // ← 이펙트 (if 문)
</script>
읽는 사람은 매번 우변을 한 번 더 봐야 했다. Svelte 5는 둘을 분리한다.
<script>
let a = $state(1)
let b = $state(2)
let sum = $derived(a + b)
$effect(() => console.log(a, b))
$effect(() => { if (sum > 5) alert() })
</script>
1.3 의존성 추적
둘 다 정적 분석으로 의존성을 추적한다. React는 의존성 배열을 사람이 적는다 — useMemo(() => a + b, [a, b]). Svelte는 적지 않는다. 차이는 React Compiler가 자동화하려는 것과 같은 지점이다.
2장 · 네 개의 룬 — $state·$derived·$effect·$props
2.1 $state — 반응성의 출발점
<script>
let count = $state(0)
let user = $state({ name: '영주', age: 32 })
let tags = $state(['svelte', 'runes'])
function rename() {
user.name = '영주2' // ← 깊이 반응성 (deep reactivity), 동작한다
tags.push('signals') // ← 배열 변이도 추적된다
}
</script>
$state는 기본적으로 깊은 반응성이다. 객체·배열을 Proxy로 감싸 깊은 속성 변경까지 추적한다. 큰 객체에서 오버헤드가 부담스러우면 $state.raw()로 얕은 반응성만 가져갈 수 있다.
let snapshot = $state.raw({ huge: data })
// snapshot.huge.foo = 'bar' ← 추적 안 됨
snapshot = { ...snapshot, huge: { ...snapshot.huge, foo: 'bar' } } // ← 추적됨
2.2 $derived — 파생, 그리고 게으름
<script>
let count = $state(0)
let doubled = $derived(count * 2)
let expensive = $derived.by(() => {
// 복수 줄 계산이 필요할 때
let acc = 0
for (let i = 0; i < count; i++) acc += i
return acc
})
</script>
$derived는 게으르고 캐시된다. 읽지 않으면 계산되지 않고, 의존성이 바뀌지 않으면 재계산되지 않는다. 이건 Solid의 createMemo, Vue의 computed와 같은 의미다.
2.3 $effect — 사이드이펙트의 명시적 게이트
<script>
let url = $state('/api/me')
$effect(() => {
const ctrl = new AbortController()
fetch(url, { signal: ctrl.signal })
.then(r => r.json())
.then(console.log)
return () => ctrl.abort() // ← cleanup 함수
})
</script>
$effect는 DOM 마운트 후, 그리고 의존성이 바뀔 때마다 실행된다. cleanup도 React useEffect와 같은 모양으로 반환한다. $effect.pre는 DOM 업데이트 이전에 실행되고, $effect.root는 명시적으로 dispose할 수 있는 효과 트리를 만든다.
핵심 규칙: $effect 안에서 $state를 쓰지 마라. 무한 루프와 동기화 폭발의 주범이다. 그게 $derived가 따로 있는 이유다.
2.4 $props — 컴포넌트 입력의 새 문법
<!-- Svelte 4 -->
<script>
export let name
export let age = 0
export let onSave
</script>
<!-- Svelte 5 -->
<script>
let { name, age = 0, onSave, ...rest } = $props()
</script>
여러 export let을 한 줄의 구조분해로 묶었다. 기본값·rest props·이름 변경까지 표준 JS 문법으로 풀린다. TypeScript와도 자연스럽다.
<script lang="ts">
interface Props {
name: string
age?: number
onSave: (id: string) => void
}
let { name, age = 0, onSave }: Props = $props()
</script>
추가로 $bindable()로 양방향 바인딩을 명시하고, $host()로 커스텀 엘리먼트 호스트에 접근한다.
3장 · 룬 vs 시그널 vs ref vs observable vs useState — 비교 매트릭스
여기서 한 페이지로 정리하자. 다섯 가지 반응성 모델을 같은 카운터로 본다.
<!-- Svelte 5 / Runes -->
<script>
let count = $state(0)
let doubled = $derived(count * 2)
$effect(() => console.log(count))
</script>
<button onclick={() => count++}>{count}</button>
// Solid / Signals
function Counter() {
const [count, setCount] = createSignal(0)
const doubled = createMemo(() => count() * 2)
createEffect(() => console.log(count()))
return <button onClick={() => setCount(count() + 1)}>{count()}</button>
}
<!-- Vue 3 / Composition + ref -->
<script setup>
import { ref, computed, watchEffect } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
watchEffect(() => console.log(count.value))
</script>
<template>
<button @click="count++">{{ count }}</button>
</template>
// MobX
import { makeAutoObservable } from 'mobx'
import { observer } from 'mobx-react-lite'
class Store {
count = 0
constructor() { makeAutoObservable(this) }
get doubled() { return this.count * 2 }
inc() { this.count += 1 }
}
const store = new Store()
const Counter = observer(() => (
<button onClick={() => store.inc()}>{store.count}</button>
))
// React 19 + React Compiler
function Counter() {
const [count, setCount] = useState(0)
// Compiler가 알아서 메모이즈한다 — 수동 useMemo 없음
const doubled = count * 2
useEffect(() => console.log(count), [count])
return <button onClick={() => setCount(count + 1)}>{count}</button>
}
비교 매트릭스 — 같은 차원을 다섯 줄로.
| 항목 | Svelte 5 Runes | Solid Signals | Vue 3 ref/computed | MobX | React + Compiler |
|---|---|---|---|---|---|
| 읽기 문법 | count (식별자) | count() (호출) | count.value (속성) | store.count (속성) | count (구조분해) |
| 쓰기 문법 | count++ | setCount(c => c + 1) | count.value++ | store.count++ | setCount(c => c + 1) |
| 파생 | $derived(...) | createMemo(...) | computed(...) | get ...() (게터) | 자동 (Compiler) |
| 효과 | $effect(...) | createEffect(...) | watchEffect(...) | autorun(...) | useEffect(..., [deps]) |
| 컴포넌트 밖 | .svelte.ts | 어디서나 | 어디서나 | 어디서나 | 훅은 안 됨 |
| 깊은 반응성 | 기본 (Proxy) | 얕음 (스토어로 보강) | 기본 (Proxy) | 기본 (Proxy/getter) | 얕음 (참조 비교) |
| 컴파일 단계 | 컴파일타임 변환 | 컴파일타임 변환 | 런타임 Proxy | 런타임 Proxy | 컴파일러 + 런타임 훅 |
| 의존성 배열 | 없음 (자동) | 없음 (자동) | 없음 (자동) | 없음 (자동) | 사람 또는 Compiler |
| TypeScript | 자연 | 자연 | 자연 | 데코레이터 가능 | 자연 |
| 번들 무게 | 매우 작음 | 매우 작음 | 중간 | 중간 | 큼 |
한 줄로 — **Svelte 5는 "컴파일 시간에 변환되는 시그널"**이다. Solid와 가장 가깝다. 다만 호출 문법(count())이 없어 보통의 JS 변수처럼 읽힌다.
4장 · $:에서 룬으로 — 실전 마이그레이션
좋은 소식: legacy 모드가 계속 동작한다. Svelte 5는 같은 컴파일러가 $:도 처리한다. svelte.config.js에서 compilerOptions.runes = false를 명시하거나, 컴포넌트 최상단에 <svelte:options runes={false}/>를 쓰면 4 시대 문법이 그대로 된다.
또 한 가지 좋은 소식: 컴포넌트 단위로 점진 마이그레이션이 가능하다. 한 컴포넌트는 룬, 다른 컴포넌트는 $:, 한 프로젝트에 공존한다.
자주 만나는 변환 패턴 다섯 개를 살펴본다.
4.1 let → $state
<!-- before -->
<script>
let count = 0
</script>
<!-- after -->
<script>
let count = $state(0)
</script>
마이그레이션 스크립트(npx sv migrate svelte-5)가 이 변환을 거의 자동으로 해 준다. 단, "쓰여지지 않는 let"은 변환하지 않는다 — 반응성이 필요 없는 변수다.
4.2 $: foo = ... → $derived
<!-- before -->
<script>
let a = 1, b = 2
$: sum = a + b
</script>
<!-- after -->
<script>
let a = $state(1), b = $state(2)
let sum = $derived(a + b)
</script>
4.3 $: { ... } 또는 $: if (...) → $effect
<!-- before -->
<script>
let count = 0
$: if (count > 10) document.title = `high: ${count}`
</script>
<!-- after -->
<script>
let count = $state(0)
$effect(() => {
if (count > 10) document.title = `high: ${count}`
})
</script>
4.4 stores → 룬 in 클래스
Svelte 4의 store 패턴을 클래스 + 룬으로 옮기는 것이 2026년 권장 패턴이다.
// before — store
import { writable, derived } from 'svelte/store'
function createCart() {
const items = writable([])
const count = derived(items, ($i) => $i.length)
return {
items,
count,
add: (item) => items.update((arr) => [...arr, item]),
}
}
// after — runes in class (.svelte.ts)
export class Cart {
items = $state([])
count = $derived(this.items.length)
add(item) { this.items.push(item) }
}
export const cart = new Cart()
호출부도 더 짧다. $cart.count 같은 store 접두사가 없어진다. cart.count다.
4.5 on:click → onclick
이건 룬과 직접 관계는 없지만 Svelte 5의 변화다.
<!-- before -->
<button on:click={handler}>x</button>
<input on:input={handler} bind:value={text} />
<!-- after -->
<button onclick={handler}>x</button>
<input oninput={handler} bind:value={text} />
on:click 같은 콜론 디렉티브 대신 표준 HTML 속성처럼 적는다. 이는 props로 핸들러를 전달하는 패턴과 자연스럽게 맞물린다.
5장 · SvelteKit 2 — 데이터 흐름이 명시적이 되었다
룬과 같은 방향의 변화가 SvelteKit에도 일어났다. 2024년 초의 SvelteKit 2.0이 도입한 두 변화가 2026년 표준이 되었다.
5.1 await 명시 — load가 더 이상 마법이 아니다
// before (SvelteKit 1)
export const load = async ({ fetch, params }) => {
const post = await fetch(`/api/posts/${params.id}`).then(r => r.json())
return { post }
}
// after (SvelteKit 2)
export const load = async ({ fetch, params, depends }) => {
depends('app:post')
const post = await fetch(`/api/posts/${params.id}`).then(r => r.json())
return { post }
}
depends로 무효화 키를 명시하고, invalidate('app:post')로 다시 부른다. 이전엔 URL 패턴 매칭으로 암묵적으로 됐다 — 이젠 명시적이다.
5.2 폼 액션과 enhance — JS 없이도, JS와 함께도
SvelteKit의 폼 액션은 룬과 무관하게 잘 살아 있다. 점진적 향상의 정수를 보여준다.
<script>
import { enhance } from '$app/forms'
let { form } = $props()
</script>
<form method="POST" use:enhance>
<input name="title" />
{#if form?.error}
<p>오류: {form.error}</p>
{/if}
<button>저장</button>
</form>
// +page.server.js
export const actions = {
default: async ({ request }) => {
const data = await request.formData()
const title = data.get('title')
if (!title) return { error: 'title required' }
await db.posts.create({ title })
return { success: true }
},
}
JS가 꺼져도 동작한다. JS가 켜져 있으면 enhance가 fetch로 가로채 SPA처럼 처리한다.
5.3 Universal vs Server load
+page.js(universal)와 +page.server.js(server-only)의 구분이 룬으로 더 명확해졌다. 클라이언트에서 다시 도는 universal load에서 $state를 만들면 hydration 후에도 반응성이 살아 있다. 서버 전용 load는 직렬화 가능한 JSON만 반환한다.
6장 · 실 사례 — 누가 룬을 쓰고 있는가
룬은 GA 1년 반 만에 Svelte 생태계 거의 전체에 퍼졌다.
- SvelteKit 자체가 룬 기반의 데모와 템플릿을 기본으로 제공한다.
- Skeleton UI v3 (Tailwind 4 기반 컴포넌트 라이브러리)가 룬 기반으로 다시 쓰였다.
- shadcn-svelte가 룬으로 마이그레이션됐다. 더 이상 store 접두사가 없다.
- TanStack Query for Svelte v5가 룬 기반 API를 채택했다.
- HeyApi·Bits UI·Melt UI — 모두 룬으로 이주했거나 이주 중이다.
그리고 큰 사용자들 — Spotify·Apple·1Password 일부 팀이 Svelte를 쓰고 있고, Apple Music의 일부 웹 표면이 Svelte로 알려져 있다. 다만 2026년 시점의 공식 통계로는 State of JS 2024 기준으로 Svelte의 retention(쓴 사람이 다시 쓸 의향)이 React·Vue와 비교해 가장 높은 축에 든다.
7장 · 무엇을 포기했나 — 정직한 거래
룬은 거의 모든 면에서 4를 이긴다. 그러나 "전부 좋은가"는 아니다.
7.1 어휘 수가 늘어났다
let count = 0에서 let count = $state(0)으로. 키 입력이 늘어났다. Svelte의 매력 중 하나였던 "JS 같은 간결함"이 살짝 흐려졌다. 다만 그 대가로 명시성·일관성·확장성을 얻었다.
7.2 Proxy 비용
$state는 객체·배열을 Proxy로 감싼다. 대규모 데이터 구조에서는 오버헤드가 있다. 권장: 핫 패스에는 $state.raw()를 쓰거나, 불변 자료구조를 한 덩어리로 갈아끼우는 패턴.
7.3 컴포넌트 밖 룬 = .svelte.ts 확장자
룬을 쓰는 일반 모듈은 .svelte.js 또는 .svelte.ts여야 한다. 그렇지 않으면 컴파일러가 처리하지 않는다. 작은 거지만 빌드 설정이 익숙하지 않으면 함정이다.
7.4 $effect의 함정
$effect 안에서 $state를 쓰면 무한 루프가 도는 게 가능하다. ESLint 룰(svelte/no-reactive-reassign)과 컴파일러 경고가 잡아 주지만, 사람의 주의가 여전히 필요하다.
7.5 학습 곡선
Svelte 4를 알던 사람에겐 두 버전을 동시에 머리에 둬야 하는 기간이 있다. 룬이 다 좋아도, 기존 자료의 절반은 $: 문법이다.
7.6 React 생태계만큼은 아니다
이건 룬 자체의 문제가 아니라 생태계의 크기 문제다. 2026년에도 npm 다운로드, 라이브러리 수, 채용 시장 모두 React가 더 크다. 다만 개발자 만족도·러닝 곡선·번들 크기·런타임 성능에서는 Svelte가 우위다.
8장 · "React-but-better"는 2026년에도 참인가
오래된 명제가 있다. "Svelte = React-but-better." 이걸 2026년에 다시 묻자.
8.1 React 19와 React Compiler
React 19가 use()·Actions·서버 컴포넌트의 시대를 굳혔다. React Compiler가 GA에 들어가면서 수동 useMemo·useCallback이 줄었다. 메모이제이션의 자동화는 사실 Solid·Svelte·Vue가 이미 갖고 있던 것 — React가 마침내 따라왔다.
그래도 React가 이기는 곳:
- 생태계 크기 — 라이브러리·툴·채용·자료 다 더 많다.
- 서버 컴포넌트(RSC) — Svelte도 비슷한 방향(서버 데이터 + 클라이언트 인터랙션)이지만, React가 추상의 깊이에서 앞선다.
- 호환성 기반 — React Native·Expo·매우 큰 회사들의 표준.
8.2 Svelte 5가 이기는 곳
- 번들 크기 — 비교할 수 없을 만큼 작다. 라우터·반응성·DOM 처리 모두 합쳐서 React + ReactDOM의 1/5 수준.
- 명시적 반응성 — 룬이 의도를 코드에 박는다. React Compiler가 "자동"이라면, 룬은 "선언"이다.
- 컴포넌트 안과 밖의 일관성 — 룬은 어디서나 같다. React 훅은 컴포넌트(또는 다른 훅) 안에서만 된다.
- DX의 단순함 —
bind:value·transition:fade·use:enhance같은 디렉티브가 그대로 쓰인다.
8.3 결론은 결국 "맥락"
"Svelte = React-but-better"는 개발 경험에서는 여전히 참이다. 생태계의 폭과 깊이에서는 React가 여전히 크다. 채용·기존 코드·팀 익숙함을 빼면 Svelte 5의 매력은 GA 시점보다 더 커졌다. 룬이 그 마지막 약점("컴포넌트 밖에서의 반응성")을 메웠기 때문이다.
9장 · Solid·Vue와 더 깊게
룬을 시그널과 ref와 같은 자리에 놓는다면, 디테일에서 어디가 다른가.
9.1 vs Solid Signals
// Solid
const [count, setCount] = createSignal(0)
console.log(count()) // ← 함수 호출
setCount(1)
setCount(c => c + 1)
<!-- Svelte 5 -->
<script>
let count = $state(0)
console.log(count) // ← 변수처럼
count = 1
count += 1
</script>
Solid는 시그널을 함수로 만든다. 호출이 추적 단위가 된다. 장점: 어디서 추적되는지 코드에서 바로 보인다. 단점: 키 입력이 늘고, 객체 안에서 시그널을 다루기가 살짝 번거롭다(createStore로 풀긴 한다).
Svelte 5는 컴파일러가 같은 일을 한다. count 식별자 접근을 자동으로 추적 호출로 변환한다. 장점: 보통 JS처럼 읽힌다. 단점: "지금 추적되고 있는가"를 보려면 컴파일 결과를 봐야 한다.
9.2 vs Vue 3 ref/computed
<script setup>
import { ref, computed } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
</script>
Vue는 .value를 통해 ref 접근을 명시한다. 템플릿 안에서는 .value가 자동 unwrap된다. 룬과 가장 가까운 모델이지만, 컴파일 시점이 아니라 런타임 Proxy라는 차이가 있다.
Vue 3.4+는 Vapor 모드(컴파일타임 반응성, Solid·Svelte 스타일)를 실험 중이다. 2026년 시점에 Vapor가 일반화되면, ref와 룬의 거리는 더 좁혀진다.
9.3 vs MobX
MobX는 OOP 친화적이다. 클래스에 makeAutoObservable을 부르면 모든 필드가 자동 observable, 모든 getter가 자동 computed가 된다.
Svelte 5의 "runes in classes" 패턴은 의도적으로 MobX와 닮았다.
// Svelte 5
class Cart {
items = $state([])
count = $derived(this.items.length)
add(item) { this.items.push(item) }
}
// MobX
class Cart {
items = []
constructor() { makeAutoObservable(this) }
get count() { return this.items.length }
add(item) { this.items.push(item) }
}
MobX는 데코레이터 또는 호출로 변환한다. Svelte 5는 룬으로 명시한다. 의미는 거의 같다.
10장 · 컴파일 결과를 들여다보기 — 무엇이 진짜로 일어나나
룬을 쓴 코드는 컴파일러가 뭐로 바꿀까. 단순한 예를 추적해 보자.
<!-- 소스 -->
<script>
let count = $state(0)
let doubled = $derived(count * 2)
$effect(() => console.log(count))
</script>
<button onclick={() => count++}>{count}</button>
대략적으로 컴파일 결과는 이렇게 된다(개념적, 실제 출력은 더 복잡하다).
import * as $ from 'svelte/internal/client'
function Component($$anchor) {
let count = $.state(0)
let doubled = $.derived(() => $.get(count) * 2)
$.user_effect(() => console.log($.get(count)))
const button = $.template('<button> </button>')()
$.event('click', button, () => $.set(count, $.get(count) + 1))
$.text(button.firstChild, () => $.get(count))
$.append($$anchor, button)
}
핵심:
- 식별자
count가 시그널 셀로 변환된다. count읽기는$.get(count)호출로, 쓰기는$.set(count, ...)호출로 변환된다.$derived는$.derived(thunk)로 — Solid의createMemo와 사실상 같은 구현이다.$effect는$.user_effect(thunk)로 — 마운트 이후, 의존성 변경 시마다 재실행된다.
즉 룬은 사람이 시그널을 호출 없이 쓰게 만든 컴파일러 마법이다. 4의 마법과 다른 점은, 마법의 트리거가 위치가 아니라 명시적 토큰이라는 것.
11장 · 큰 앱에서의 룬 — 패턴과 안티패턴
11.1 권장 패턴
- 도메인 로직은
.svelte.ts모듈에 두기 — 컴포넌트는 표현에 집중. - 클래스 + 룬으로 상태 컨테이너 — store보다 타입이 자연스럽다.
$derived로 파생,$effect로만 부수 효과 — 둘을 섞지 않는다.$effect의 cleanup 함수를 항상 챙긴다. 메모리 누수의 80%가 여기서 난다.- 얕은 반응성이 필요하면
$state.raw— 큰 트리에서 Proxy 비용을 피한다. - prop 타입은 인터페이스로 —
let { ... }: Props = $props(). $bindable()은 정말 양방향이 필요한 곳만 — 단방향 데이터 흐름이 디폴트.
11.2 안티패턴
$effect안에서$state쓰기 — 무한 루프. 파생이 필요하면$derived.- module-level
$state를 직접 export — 여러 SSR 요청이 공유한다. 함수로 감싸야 한다. $:잔재를 룬과 섞기 — legacy 모드 아니면 컴파일러가 거부한다. 한 컴포넌트는 한쪽으로 통일.- 거대한 객체를
$state— Proxy가 깊이 들어가 비싸다. 평탄화하거나$state.raw. - store를 룬과 같이 운영 — 가능하지만 두 모델을 머리에 같이 둬야 한다. 가능하면 일관화.
$effect로 데이터 페치 — 가능하지만 SvelteKitload가 더 좋다.$effect는 정말 클라이언트 사이드 부수 효과용.$inspect를 프로덕션에 두기 — 개발용 디버깅 룬이다.
12장 · 도구·생태계 — 룬 시대의 주변부
12.1 IDE·언어 서버
VS Code의 Svelte for VS Code 확장이 룬을 완벽히 인식한다. JetBrains WebStorm 2024.3+도 같다. 룬이 컴파일러 키워드라서, 의미는 LSP가 알려준다.
12.2 ESLint·Prettier
eslint-plugin-svelte가 룬용 규칙 묶음을 제공한다. svelte/no-reactive-reassign, svelte/require-state-with-init 같은 게 핵심이다.
12.3 테스트
vitest + @testing-library/svelte가 표준. .svelte.ts 안의 룬을 테스트할 때는 flushSync()로 동기 상태 업데이트를 강제하는 패턴이 자주 쓰인다.
12.4 빌드
Vite 5가 권장 번들러, Vite 6도 호환. SvelteKit이 그 위에 라우팅·SSR·어댑터를 얹는다. Bun도 Svelte 5를 정식 지원하지만, SvelteKit 어댑터는 Node·Vercel·Cloudflare·Netlify가 더 성숙하다.
12.5 컴포넌트 라이브러리
- shadcn-svelte — 룬 기반 무스타일 컴포넌트, 복사-붙여넣기 모델.
- Skeleton v3 — 룬 + Tailwind 4.
- Bits UI — 헤드리스 컴포넌트, 룬 기반.
- Melt UI — 빌더 패턴 헤드리스, 룬 마이그레이션 완료.
13장 · 미래 — 룬 다음은 무엇인가
Svelte 코어팀이 공개적으로 이야기하는 다음 방향은 세 가지다.
- Svelte 6 — 룬 기반 API를 안정화하고, legacy 모드를 점진적으로 제거. 6에서 legacy는 deprecation, 7에서 제거 후보.
- 컴파일러 최적화 — Vapor mode 같은 발상이 Svelte 5 위에 더 깊이 들어간다. fine-grained 업데이트는 이미 하고 있고, 그 위에 텍스트·속성·부분 트리 단위 최적화가 더해진다.
- SvelteKit과 RSC의 경계 — React 서버 컴포넌트와 같은 종류의 분리(서버 부품 vs 클라이언트 부품)를 룬·
load·폼 액션 위에서 어떻게 더 자연스럽게 풀지가 과제.
그리고 더 큰 그림: 시그널의 표준화. TC39 시그널 제안이 진행 중이다. Svelte·Solid·Vue·Angular가 비슷한 기본형을 공유하면, 룬은 시그널 호출 위의 표면 문법이 된다.
에필로그 — 명시성을 향한 한 걸음
Svelte 4의 let은 아름다웠다. "그냥 JS"였다. 그러나 "그냥 JS"가 아니었다. 컴파일러가 의미를 바꿨다. 그 비밀이 새로운 사람의 발목을 잡았고, TypeScript와 마찰했고, 컴포넌트 밖으로 못 나갔다.
Svelte 5의 룬은 그 비밀을 코드에 박았다. $state가 있는 곳에 반응성이 있다. 더 길어졌지만, 더 정직해졌다. Solid·Vue·MobX가 다 자기 식으로 도달한 같은 결론을 — Svelte는 자기 식으로 도달했다.
그리고 그 결론은 한 문장으로 요약된다.
"마법은 좋다. 다만 마법을 부르는 단어는 사람에게 보여야 한다."
12개 항목 체크리스트
.svelte컴포넌트가 룬 모드에 있는가(runes: true또는 자동)?let변수가 진짜 반응성이 필요한가 — 필요한 경우만$state?- 파생 값은
$derived로, 효과는$effect로 명확히 분리됐는가? $effect안에서$state에 쓰지 않는가?$effect의 cleanup 함수를 챙겼는가?- 큰 객체는
$state.raw로 얕은 반응성만 가져갔는가? - 컴포넌트 밖 룬 모듈이
.svelte.ts확장자인가? - props는
let { ... }: Props = $props()스타일로 적었는가? - 양방향 바인딩이 필요한 prop만
$bindable()인가? - store에서 룬으로 마이그레이션할 때 SSR 안전한가(함수로 감싸기)?
- SvelteKit
load의 무효화를depends/invalidate로 명시했는가? - legacy 모드의 잔재(
$:·on:click·export let)가 새 코드에 섞이지 않았는가?
안티패턴 10가지
$effect안에서$state쓰기 — 무한 루프.- module-level
$state를 그대로 export — SSR 공유 버그. - 거대한 객체를
$state— Proxy 비용. $:와 룬을 같은 컴포넌트에 섞기.- store와 룬을 같은 도메인에 공존시키기.
$effect로 데이터 페치 —load가 더 맞다.$inspect·$state.snapshot디버깅 코드를 프로덕션에 남기기.$bindable()을 단방향 데이터에 남용..svelte파일이 아닌 곳에 룬 쓰기.- 룬을 도입하면서 컴파일러 경고를 끄기.
다음 글 예고
다음 글 후보: SvelteKit 2 데이터 흐름 심층 — load·actions·enhance의 진짜 의미, 시그널 표준화(TC39) 추적 — Svelte·Solid·Vue·Angular가 공유할 미래, Svelte vs Astro vs SolidStart — 콘텐츠 사이트의 2026년 선택지.
"반응성은 마법이 아니라 도구다. 도구의 이름은 코드에 적혀 있어야 한다."
— Svelte 5 Runes, 끝.
참고 / References
- Svelte 공식 사이트
- Svelte 5 발표 블로그
- Svelte 5 마이그레이션 가이드
- Runes 문서
$state레퍼런스$derived레퍼런스$effect레퍼런스$props레퍼런스- SvelteKit 공식 문서
- SvelteKit 2 마이그레이션
- Solid 공식 — Reactivity
- Vue 3 Reactivity API
- Vue Vapor mode RFC
- MobX 공식
- React Compiler 문서
- TC39 Signals 제안
- State of JS 2024
- shadcn-svelte
- Skeleton UI
- Bits UI
- Melt UI
현재 단락 (1/449)
Svelte 3은 약속이었다. "리액티브 코드는 컴파일이 처리해야 한다, 런타임은 가벼워야 한다." `let count = 0`이 반응성을 갖고, `$: doubled = coun...