Skip to main content

Svelte 5가 정식 출시되면서 기존의 반응형 시스템이 완전히 새로워졌습니다. 바로 Runes(룬)이라는 개념인데요. 기존 Svelte의 마법 같던 반응형 선언 방식을 명시적이고 예측 가능한 방식으로 바꾸었습니다. 이 글에서는 SvelteKit 5에서 Runes를 실전에서 어떻게 활용하는지 코드와 함께 상세히 설명합니다.

Runes란 무엇인가?

Runes는 Svelte 5에서 도입된 반응형 기본 단위(reactive primitives)입니다. 기존 Svelte 4에서는 let 선언만으로 반응형 변수가 만들어졌지만, Svelte 5에서는 명시적인 함수를 통해 반응성을 선언합니다.

Runes는 $로 시작하는 특별한 함수들이며, 컴파일러가 이를 인식해 최적화된 반응형 코드로 변환합니다. 컴파일 타임에 처리되기 때문에 런타임 오버헤드가 없고, 의도를 명확히 표현할 수 있다는 게 핵심 장점입니다.

$state — 반응형 상태 선언

$state는 가장 기본적인 Rune으로, 반응형 상태를 선언합니다. Svelte 4의 let과 유사하지만 훨씬 더 명시적입니다.

// Svelte 4 방식
let count = 0;

// Svelte 5 Runes 방식
let count = $state(0);

실전 예시를 봅시다:

<script>
  let count = $state(0);
  let user = $state({
    name: '홍길동',
    email: 'hong@example.com'
  });
</script>

<button onclick={() => count++}>
  클릭 수: {count}
</button>

<input bind:value={user.name} />
<p>안녕하세요, {user.name}님!</p>

$state로 선언한 객체는 깊은 반응성(deep reactivity)을 가집니다. 중첩된 속성을 변경해도 UI가 자동으로 업데이트됩니다.

$state.raw — 얕은 반응성

객체 전체를 교체할 때만 반응성이 필요한 경우 $state.raw를 사용하면 성능을 최적화할 수 있습니다.

// 전체 교체 시에만 반응
let items = $state.raw([1, 2, 3]);

// 이렇게 하면 반응 안 됨 (얕은 반응성)
items.push(4); // ❌

// 이렇게 해야 반응됨
items = [...items, 4]; // ✅

$derived — 파생 상태 선언

$derived는 다른 상태로부터 자동으로 계산되는 값을 선언합니다. 기존 Svelte 4의 $: derivedValue = ...와 동일한 역할이지만, 훨씬 명확한 문법을 제공합니다.

<script>
  let price = $state(10000);
  let quantity = $state(3);

  // price나 quantity가 변경되면 자동으로 재계산
  let total = $derived(price * quantity);
  let discounted = $derived(total > 20000 ? total * 0.9 : total);
  let formattedTotal = $derived(
    discounted.toLocaleString('ko-KR') + '원'
  );
</script>

<p>합계: {formattedTotal}</p>

$derived.by — 복잡한 파생 로직

단순한 표현식이 아닌 복잡한 로직이 필요할 때는 $derived.by를 사용합니다.

<script>
  let numbers = $state([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);

  let stats = $derived.by(() => {
    const even = numbers.filter(n => n % 2 === 0);
    const odd = numbers.filter(n => n % 2 !== 0);
    const sum = numbers.reduce((a, b) => a + b, 0);
    const avg = sum / numbers.length;
    return { even, odd, sum, avg };
  });
</script>

<p>짝수: {stats.even.join(', ')}</p>
<p>홀수: {stats.odd.join(', ')}</p>
<p>합계: {stats.sum}, 평균: {stats.avg}</p>

$effect — 사이드 이펙트 처리

$effect는 반응형 상태가 변경될 때 실행되는 사이드 이펙트를 선언합니다. React의 useEffect와 유사하지만, 의존성 배열 없이 사용하는 상태를 자동으로 추적합니다.

<script>
  let searchQuery = $state('');
  let results = $state([]);
  let loading = $state(false);

  $effect(() => {
    // searchQuery가 변경될 때마다 실행
    if (!searchQuery.trim()) {
      results = [];
      return;
    }

    loading = true;
    const timer = setTimeout(async () => {
      const res = await fetch(`/api/search?q=${searchQuery}`);
      results = await res.json();
      loading = false;
    }, 300); // 디바운스

    // 클린업 함수 (다음 effect 실행 전 호출)
    return () => clearTimeout(timer);
  });
</script>

<input bind:value={searchQuery} placeholder="검색어 입력..." />
{#if loading}<p>검색 중...</p>{/if}
{#each results as result}
  <p>{result.title}</p>
{/each}

위 예시에서 $effectsearchQuery가 변경될 때마다 자동으로 재실행되고, 이전 타이머를 클린업합니다. 의존성 배열을 명시하지 않아도 Svelte 컴파일러가 자동으로 추적합니다.

$effect.pre — DOM 업데이트 전 실행

DOM이 업데이트되기 전에 실행해야 하는 로직이 있을 때 사용합니다.

<script>
  let messages = $state([]);
  let container;

  // DOM 업데이트 전에 스크롤 위치 저장
  $effect.pre(() => {
    const isAtBottom =
      container?.scrollHeight - container?.scrollTop === container?.clientHeight;
    if (isAtBottom) {
      // DOM 업데이트 후 스크롤 아래로 이동하도록 표시
      $effect(() => {
        container?.scrollTo(0, container.scrollHeight);
      });
    }
  });
</script>

$props — 컴포넌트 Props 선언

Svelte 5에서는 export let 대신 $props()로 props를 선언합니다.

<!-- Button.svelte -->
<script>
  let {
    label = '클릭',
    variant = 'primary',
    disabled = false,
    onclick
  } = $props();
</script>

<button
  class="btn btn-{variant}"
  {disabled}
  {onclick}
>
  {label}
</button>

구조 분해 할당(destructuring)으로 기본값을 설정하고, TypeScript와 함께 사용할 때는 타입도 명확하게 지정할 수 있습니다.

// TypeScript와 함께
interface Props {
  title: string;
  count?: number;
  onchange?: (value: number) => void;
}

let { title, count = 0, onchange }: Props = $props();

$bindable — 양방향 바인딩 Props

부모에서 bind:로 바인딩할 수 있는 prop을 선언할 때 사용합니다.

<!-- Counter.svelte -->
<script>
  let { count = $bindable(0) } = $props();
</script>

<button onclick={() => count++}>+</button>
<span>{count}</span>
<button onclick={() => count--}>-</button>
<!-- App.svelte -->
<script>
  import Counter from './Counter.svelte';
  let total = $state(0);
</script>

<!-- total이 Counter 내부에서 변경되면 부모도 업데이트 -->
<Counter bind:count={total} />
<p>현재 카운트: {total}</p>

실전 예제: Todo 앱으로 보는 Runes 통합 활용

지금까지 배운 Runes를 종합해서 간단한 Todo 앱을 만들어봅시다.

<script>
  let todos = $state([
    { id: 1, text: 'SvelteKit 5 공부하기', done: false },
    { id: 2, text: 'Runes 마스터하기', done: false },
    { id: 3, text: '프로젝트에 적용하기', done: false }
  ]);

  let newTodo = $state('');
  let filter = $state('all'); // 'all' | 'active' | 'done'

  // 필터링된 목록
  let filtered = $derived.by(() => {
    if (filter === 'active') return todos.filter(t => !t.done);
    if (filter === 'done') return todos.filter(t => t.done);
    return todos;
  });

  // 통계
  let stats = $derived({
    total: todos.length,
    done: todos.filter(t => t.done).length,
    active: todos.filter(t => !t.done).length
  });

  // 로컬스토리지 동기화
  $effect(() => {
    localStorage.setItem('todos', JSON.stringify(todos));
  });

  function addTodo() {
    if (!newTodo.trim()) return;
    todos = [...todos, {
      id: Date.now(),
      text: newTodo.trim(),
      done: false
    }];
    newTodo = '';
  }

  function toggleTodo(id) {
    todos = todos.map(t =>
      t.id === id ? { ...t, done: !t.done } : t
    );
  }

  function removeTodo(id) {
    todos = todos.filter(t => t.id !== id);
  }
</script>

<div class="todo-app">
  <h1>할 일 목록</h1>
  <p>전체 {stats.total}개 · 완료 {stats.done}개 · 남은 {stats.active}개</p>

  <form onsubmit|preventDefault={addTodo}>
    <input bind:value={newTodo} placeholder="새 할 일 입력..." />
    <button type="submit">추가</button>
  </form>

  <div class="filters">
    {#each ['all', 'active', 'done'] as f}
      <button
        class:active={filter === f}
        onclick={() => filter = f}
      >{f}</button>
    {/each}
  </div>

  {#each filtered as todo (todo.id)}
    <div class="todo-item">
      <input
        type="checkbox"
        checked={todo.done}
        onchange={() => toggleTodo(todo.id)}
      />
      <span class:done={todo.done}>{todo.text}</span>
      <button onclick={() => removeTodo(todo.id)}>삭제</button>
    </div>
  {/each}
</div>

Svelte 4 → 5 마이그레이션 가이드

기존 Svelte 4 프로젝트를 Svelte 5로 마이그레이션할 때 변경해야 할 핵심 포인트입니다.

Svelte 4Svelte 5
let count = 0let count = $state(0)
$: double = count * 2let double = $derived(count * 2)
$: { console.log(count) }$effect(() => { console.log(count) })
export let namelet { name } = $props()
export let value = 0 (bindable)let { value = $bindable(0) } = $props()
on:clickonclick (이벤트 핸들러 속성)

Svelte는 공식적으로 마이그레이션 도구를 제공합니다:

npx sv migrate svelte-5

Runes를 사용해야 하는 이유

  • 명시성: 어떤 값이 반응형인지 코드만 봐도 바로 알 수 있습니다
  • 예측 가능성: 의존성 추적이 명확해서 디버깅이 쉬워집니다
  • 컴포넌트 경계 초월: 컴포넌트 외부 파일(.svelte.js, .svelte.ts)에서도 Runes 사용 가능
  • 더 나은 TypeScript 지원: 타입 추론이 자연스럽게 작동합니다
  • 성능: 컴파일 타임 최적화로 런타임 오버헤드 없음

마치며

SvelteKit 5의 Runes는 처음에는 낯설 수 있지만, 익숙해지면 기존 방식보다 훨씬 직관적이라는 걸 느끼게 됩니다. 특히 컴포넌트 외부에서도 반응형 상태를 공유할 수 있다는 점은 상태 관리 라이브러리 없이도 복잡한 앱을 만들 수 있게 해줍니다.

코드벤터는 SvelteKit을 활용한 실제 서비스 개발 경험을 바탕으로 기술 블로그를 운영하고 있습니다. 다음 포스팅에서는 SvelteKit + FastAPI 풀스택 개발 가이드로 찾아오겠습니다. 🐾

댓글 남기기