Svelte 5 Runes 완벽 가이드 — $state·$derived·$effect로 다시 쓰는 반응성

Svelte 5 Runes 완벽 가이드 — $state·$derived·$effect로 다시 쓰는 반응성

이 글의 핵심

Svelte 5는 기존 컴파일러 마법("let"은 반응 상태) 대신 Runes라는 명시적 API($state, $derived, $effect, $props, $bindable)로 반응성을 재설계했습니다. 컴포넌트 바깥에서도 상태를 공유할 수 있고, 타입 추론이 완벽해지며, 큰 애플리케이션의 유지보수성이 크게 개선됩니다. 이 글은 Runes 5종과 Svelte 4 → 5 마이그레이션, SvelteKit 패턴을 실전 예제로 정리합니다.

Svelte 5의 큰 변화

기존 Svelte의 “let 하나면 반응 상태”라는 마법은 인상적이었지만 다음 한계가 있었습니다.

  • 컴포넌트 밖에서 공유 불가 → store로 우회
  • TypeScript 친화도 낮음 → 컴파일러 마법의 타입을 추론하기 어려움
  • 리액티브 범위가 모호$: 블록이 언제 실행되는지 헷갈림
  • 복잡한 파생 상태$: chain이 빠르게 복잡해짐

Svelte 5의 해답: Runes. $state()·$derived()·$effect()·$props()·$bindable()의 5개 심볼이 반응성을 명시적으로 표현합니다.

$state: 반응 상태

<script lang="ts">
  let count = $state(0)
  let user = $state({ name: "JB", age: 30 })

  function inc() { count++ }
  function rename() { user.name = "Alice" }   // 깊이 반응
</script>

<button onclick={inc}>+</button>
<p>{count}</p>
<p>{user.name}</p>
<button onclick={rename}>rename</button>
  • Proxy 기반: 객체·배열 내부 변경도 자동 감지
  • TS: count의 타입은 number로 추론

깊은 반응 주의

let arr = $state([1, 2, 3])
arr.push(4)          // OK, 반응
arr = [...arr, 5]    // OK, 할당

const r = $state.raw({ big: large })  // 얕은 반응(성능 최적화)

$state.raw는 불변 스타일의 얕은 반응 상태로, 큰 객체를 통째로 교체할 때 성능을 확보합니다.

$derived: 파생 값

<script lang="ts">
  let items = $state<{ id: number; price: number }[]>([
    { id: 1, price: 10 },
    { id: 2, price: 20 },
  ])

  const total = $derived(items.reduce((s, i) => s + i.price, 0))
  const formatted = $derived(`Total: $${total.toFixed(2)}`)
</script>

<p>{formatted}</p>
  • 의존 배열 지정 불필요 — 함수 본문 추적으로 자동
  • 값 접근 시마다 재계산되지 않음 → 의존 변경 시에만 재평가

$derived.by

긴 표현식에는 $derived.by:

const report = $derived.by(() => {
  const filtered = items.filter((i) => i.price > threshold)
  return filtered.map((i) => ({ ...i, tax: i.price * 0.1 }))
})

$effect: 사이드 이펙트

<script lang="ts">
  let count = $state(0)

  $effect(() => {
    document.title = `Count: ${count}`

    // 의존: count만 추적
    return () => {
      console.log("cleanup for count =", count)
    }
  })
</script>
  • 마운트·의존 변경 시 실행, 클린업 함수 반환 가능
  • 자동 의존 추적: useEffect의 deps array 실수 없음
  • SSR에서는 실행되지 않음

프리-이펙트

$effect.pre(() => {
  // DOM 업데이트 직전
})

root effect (컴포넌트 밖)

import { createRoot } from "svelte"

const stop = $effect.root(() => {
  $effect(() => console.log(store.value))
  return () => console.log("stopped")
})

// 나중에
stop()

$props: 컴포넌트 Props

<!-- Button.svelte -->
<script lang="ts">
  interface Props {
    label: string
    disabled?: boolean
    onclick?: () => void
    children?: import("svelte").Snippet
  }
  let { label, disabled = false, onclick, children }: Props = $props()
</script>

<button {disabled} {onclick}>
  {label}
  {#if children}{@render children()}{/if}
</button>
  • 구조 분해 + 기본값
  • children은 Snippet (Svelte 5의 slot 대체)

Rest props

let { id, ...rest }: { id: string; [key: string]: unknown } = $props()

$props.id / $props.legacy

  • $props.id(): 고유 ID 생성 (a11y·form 연결)
  • $props.legacy(): 레거시 $$props 호환

$bindable: 양방향 바인딩

<!-- Input.svelte -->
<script lang="ts">
  let { value = $bindable("") } = $props<{ value?: string }>()
</script>
<input bind:value />
<!-- 사용 -->
<script lang="ts">
  import Input from "./Input.svelte"
  let name = $state("")
</script>

<Input bind:value={name} />
<p>Hello {name}</p>

React의 제어 컴포넌트 + onChange 패턴을 bind: 한 줄로 대체합니다.

Snippets: 슬롯의 진화

<!-- Card.svelte -->
<script lang="ts">
  import type { Snippet } from "svelte"
  let { header, children }: { header?: Snippet; children: Snippet } = $props()
</script>

<article>
  {#if header}<header>{@render header()}</header>{/if}
  <section>{@render children()}</section>
</article>
<Card>
  {#snippet header()}<h2>Title</h2>{/snippet}
  <p>Body text</p>
</Card>

Snippet은 JavaScript 함수이므로 인자를 받을 수도 있고 다른 컴포넌트에 전달 가능합니다.

{#snippet row(item: Item)}
  <tr><td>{item.id}</td><td>{item.name}</td></tr>
{/snippet}

<Table rows={items} renderRow={row} />

공유 상태: .svelte.ts

// stores/counter.svelte.ts
class CounterStore {
  count = $state(0)
  double = $derived(this.count * 2)

  inc() { this.count++ }
  reset() { this.count = 0 }
}

export const counter = new CounterStore()
<script lang="ts">
  import { counter } from "./stores/counter.svelte"
</script>

<button onclick={() => counter.inc()}>+</button>
<p>{counter.count} / {counter.double}</p>

클래스 + Runes 조합이 Svelte 5에서 공유 상태의 정석 패턴입니다. 테스트·타입·도구가 모두 자연스럽게 따라옵니다.

Context로 주입

<!-- 루트 레이아웃 -->
<script lang="ts">
  import { setContext } from "svelte"
  import { CounterStore } from "./stores/counter.svelte"
  setContext("counter", new CounterStore())
</script>
<script lang="ts">
  import { getContext } from "svelte"
  const counter = getContext<CounterStore>("counter")
</script>

Context + 클래스 패턴으로 전역 싱글톤 의존을 피하고 테스트가 쉬워집니다.

이벤트 핸들러: on: → onclick

Svelte 5는 커스텀 디렉티브 on:click 대신 속성처럼 써서 DOM과 일관성을 높였습니다.

<!-- Svelte 4 -->
<button on:click={handler}>Click</button>

<!-- Svelte 5 -->
<button onclick={handler}>Click</button>

수식어 없이 직접 표현

<button
  onclick={(e) => { e.preventDefault(); handler() }}
  onkeydown={(e) => { if (e.key === "Enter") handler() }}
>
  Click
</button>

preventDefault 등 수식어는 일반 함수로 표현합니다.

SvelteKit 통합

load 반환 + Rune

<!-- +page.svelte -->
<script lang="ts">
  import type { PageData } from "./$types"
  let { data }: { data: PageData } = $props()

  let filter = $state("")
  const filtered = $derived(
    data.posts.filter((p) => p.title.toLowerCase().includes(filter.toLowerCase()))
  )
</script>

<input bind:value={filter} placeholder="Search..." />
<ul>{#each filtered as p}<li>{p.title}</li>{/each}</ul>

Form action

// +page.server.ts
import type { Actions } from "./$types"

export const actions: Actions = {
  create: async ({ request }) => {
    const form = await request.formData()
    const title = form.get("title") as string
    await db.insert(posts).values({ title })
    return { success: true }
  },
}
<script lang="ts">
  import { enhance } from "$app/forms"
  let { form } = $props()
  let submitting = $state(false)
</script>

<form method="POST" action="?/create" use:enhance={() => {
  submitting = true
  return async ({ update }) => {
    await update()
    submitting = false
  }
}}>
  <input name="title" />
  <button disabled={submitting}>Save</button>
  {#if form?.success}<p>Saved</p>{/if}
</form>

마이그레이션: Svelte 4 → 5

자동 변환

npx svelte-migrate svelte-5

대부분 let$state, $:$derived/$effect, 이벤트 on:onclick 등을 자동 변환. 수동 검토 필요한 패턴은 주석으로 표시됩니다.

수동 체크리스트

  • 모든 export let → $props() 구조 분해
  • reactive statements $:$derived/$effect
  • slot → snippet (점진적 가능, 당장 필수 아님)
  • event dispatcher → 콜백 prop (onchange: (v) => ...)
  • svelte/store$store 자동 구독 → 그대로 또는 Rune 기반으로 전환

성능

  • 컴파일러가 fine-grained 업데이트 코드 생성: 관련 DOM만 갱신
  • 번들 크기: React 대비 1/3 ~ 1/5
  • 메모리: Proxy 기반 $state는 기존 store 대비 유사하거나 경미한 오버헤드
  • SSR: SvelteKit의 hybrid 렌더링, streaming, island 등 전부 호환

언제 Svelte 5를 선택

  • 빠른 DX와 작은 번들이 필요한 스타트업·사이드 프로젝트
  • 인터랙티브 위젯이 중심인 SaaS
  • SvelteKit의 파일 기반 라우팅·Form actions가 개발 속도를 끌어올릴 때
  • React 피로감이 있는 팀

대규모 엔터프라이즈·라이브러리 생태계 접근성이 매우 중요하면 여전히 React가 안전한 선택.

트러블슈팅

$state is not defined

  • Svelte 5 사용 버전 확인 (svelte 5.x)
  • .svelte 또는 .svelte.ts 파일인지 확인. .ts는 불가

Rune 사용 시 TypeScript 에러

  • svelte-check·svelte-preprocess·vite-plugin-svelte를 5 대응 버전으로 업데이트
  • tsconfig.jsontypes: ["svelte"] 확인

reactive loop

$effect 안에서 자신이 의존하는 상태를 수정하면 무한 루프. untrack(() => state)로 의존 차단.

객체 깊은 반응에서 Proxy 비용

매우 큰 객체 트리는 $state.raw 또는 $state.snapshot()으로 영역 분리.

체크리스트

  • Svelte 5·SvelteKit 2 최신
  • $state/$derived/$effect의 역할 분리 원칙
  • 공유 상태: .svelte.ts + Context
  • Snippet으로 slot 패턴 점진적 대체
  • on:onclick 전환
  • svelte-migrate 실행 후 수동 검증
  • svelte-check를 CI에서 통과

마무리

Runes는 Svelte의 “컴파일러 마법”을 최소한만 남기고 명시적이고 타입 친화적인 반응성으로 재설계했습니다. 작성 코드의 양은 크게 늘지 않고 오히려 줄어드는 경우가 많으며, 컴포넌트 경계 밖에서 상태를 공유하는 패턴이 자연스러워졌습니다. 2026년 현재 SvelteKit·Melt UI·Shadcn for Svelte 같은 생태계가 Svelte 5 기준으로 재정비를 마쳐 본격적인 생산 사용 단계에 접어들었습니다. 새 프로젝트는 당연히 Svelte 5로, 기존 4 프로젝트도 svelte-migrate로 하루 내 전환 가능하니 오늘부터 Runes를 써보세요.

관련 글

  • Svelte 완벽 가이드
  • SvelteKit 완벽 가이드
  • React vs Svelte 비교
  • 2026 프론트엔드 프레임워크 비교