Svelte 5 Runes 실전 가이드: 반응형 시스템의 완전한 재설계

프론트엔드

Svelte 5RunesSvelteKit프론트엔드반응형 UI

이 글은 누구를 위한 것인가

  • Svelte를 처음 배우거나, Svelte 4에서 5로 마이그레이션을 계획 중인 개발자
  • React/Vue 개발자로 Svelte의 접근법이 궁금한 팀
  • Runes가 기존 반응형 선언과 어떻게 다른지 이해하고 싶은 엔지니어

들어가며

Svelte 5는 2024년 10월 출시된 메이저 버전으로, Runes라는 완전히 새로운 반응형 시스템을 도입했다. 기존 let 선언으로 반응형을 표현하던 방식에서 $state, $derived, $effect 등의 명시적 프리미티브로 전환했다.

처음에는 "왜 더 복잡해졌지?"라고 느낄 수 있다. 하지만 Runes는 .svelte 파일 외부에서도 반응형 상태를 사용할 수 있고, TypeScript와 더 잘 작동하며, 로직 재사용이 훨씬 자연스러워졌다.

이 글은 bluefoxdev.kr의 Svelte 생태계 가이드 를 참고하고, Svelte 5 실전 적용 관점에서 확장하여 작성했습니다.


1. Runes 개요

[Svelte 4 vs Svelte 5 반응형]

Svelte 4:
  let count = 0;           // 반응형 (암시적)
  $: doubled = count * 2;  // 파생 값
  $: { effect code }       // 사이드 이펙트

Svelte 5 Runes:
  let count = $state(0);              // 명시적 반응형
  let doubled = $derived(count * 2); // 명시적 파생
  $effect(() => { effect code });     // 명시적 이펙트

장점:
✅ .svelte 파일 외부에서 사용 가능
✅ TypeScript 타입 추론 완전 지원
✅ 로직을 일반 .svelte.ts 파일로 분리 가능
✅ React 개발자에게 익숙한 패턴

2. $state - 반응형 상태

<!-- Counter.svelte -->
<script lang="ts">
  // Svelte 5: 명시적 $state
  let count = $state(0);
  let name = $state('');
  
  // 객체도 깊은 반응형
  let user = $state({
    name: 'Alice',
    preferences: {
      theme: 'dark',
    },
  });
  
  function updateTheme(theme: string) {
    user.preferences.theme = theme;  // 깊은 반응형 업데이트
  }
</script>

<button onclick={() => count++}>
  카운트: {count}
</button>

<input bind:value={name} placeholder="이름" />
<p>안녕하세요, {name || '이름 없음'}님!</p>
// $state.raw - 깊은 반응형 없이 성능 최적화
let largeList = $state.raw<Item[]>([]);

// 배열 전체를 교체할 때만 업데이트 (내부 변경은 추적 안 함)
function addItem(item: Item) {
  largeList = [...largeList, item];  // 전체 교체로 업데이트
}

3. $derived - 파생 상태

<script lang="ts">
  let items = $state<string[]>([]);
  let filter = $state('');
  
  // $derived: 의존하는 state가 변할 때만 재계산
  let filtered = $derived(
    items.filter(item => item.toLowerCase().includes(filter.toLowerCase()))
  );
  
  let count = $derived(filtered.length);
  
  // 복잡한 파생은 $derived.by 사용
  let stats = $derived.by(() => {
    const total = items.length;
    const matching = filtered.length;
    const percentage = total > 0 ? (matching / total * 100).toFixed(1) : '0';
    return { total, matching, percentage };
  });
</script>

<input bind:value={filter} placeholder="필터" />
<p>{stats.matching}/{stats.total}개 표시 중 ({stats.percentage}%)</p>

{#each filtered as item}
  <div>{item}</div>
{/each}

4. $effect - 사이드 이펙트

<script lang="ts">
  let query = $state('');
  let results = $state<SearchResult[]>([]);
  let isLoading = $state(false);
  
  // $effect: 의존하는 reactive 값이 변할 때 실행
  // 클린업 함수 반환 가능
  $effect(() => {
    if (!query.trim()) {
      results = [];
      return;
    }
    
    isLoading = true;
    const controller = new AbortController();
    
    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then(res => res.json())
      .then(data => {
        results = data;
        isLoading = false;
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          isLoading = false;
        }
      });
    
    // 클린업: 다음 effect 실행 전 호출
    return () => controller.abort();
  });
</script>

<input bind:value={query} placeholder="검색" />
{#if isLoading}
  <p>검색 중...</p>
{:else}
  {#each results as result}
    <div>{result.title}</div>
  {/each}
{/if}

5. $props - 컴포넌트 속성

<!-- Button.svelte -->
<script lang="ts">
  // 구조분해로 props 정의
  let { 
    label, 
    variant = 'primary', 
    disabled = false,
    onclick,
    children,
  }: {
    label?: string;
    variant?: 'primary' | 'secondary';
    disabled?: boolean;
    onclick?: () => void;
    children?: import('svelte').Snippet;
  } = $props();
</script>

<button 
  class="btn btn-{variant}" 
  {disabled}
  {onclick}
>
  {#if children}
    {@render children()}
  {:else}
    {label}
  {/if}
</button>

<!-- 사용 -->
<Button label="클릭" variant="secondary" onclick={() => console.log('clicked')} />

<!-- children 슬롯 사용 -->
<Button onclick={handleClick}>
  <span>🚀 시작하기</span>
</Button>
<!-- $bindable: 부모에서 bind: 사용 가능 -->
<!-- Input.svelte -->
<script lang="ts">
  let { value = $bindable(''), placeholder = '' } = $props<{
    value?: string;
    placeholder?: string;
  }>();
</script>

<input bind:value {placeholder} />

<!-- 부모에서 -->
<script>
  let name = $state('');
</script>
<Input bind:value={name} placeholder="이름" />

6. 재사용 가능한 반응형 로직

Runes의 가장 큰 장점: .svelte 파일이 아닌 일반 TypeScript 파일에서도 반응형 로직 사용 가능.

// lib/useLocalStorage.svelte.ts (주목: .svelte.ts 확장자)
export function useLocalStorage<T>(key: string, initialValue: T) {
  const stored = localStorage.getItem(key);
  let value = $state<T>(stored ? JSON.parse(stored) : initialValue);
  
  $effect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  });
  
  return {
    get value() { return value; },
    set value(v: T) { value = v; },
  };
}

// 사용 (컴포넌트에서)
import { useLocalStorage } from '$lib/useLocalStorage.svelte';

const theme = useLocalStorage('theme', 'light');
theme.value = 'dark';  // 자동으로 localStorage에 저장
// lib/useFetch.svelte.ts
export function useFetch<T>(url: string) {
  let data = $state<T | null>(null);
  let error = $state<Error | null>(null);
  let loading = $state(false);
  
  $effect(() => {
    loading = true;
    error = null;
    
    const controller = new AbortController();
    
    fetch(url, { signal: controller.signal })
      .then(r => r.json())
      .then(d => { data = d; loading = false; })
      .catch(e => {
        if (e.name !== 'AbortError') {
          error = e;
          loading = false;
        }
      });
    
    return () => controller.abort();
  });
  
  return {
    get data() { return data; },
    get error() { return error; },
    get loading() { return loading; },
  };
}

7. SvelteKit 2와의 통합

// src/routes/products/+page.ts
import type { PageLoad } from './$types';

export const load: PageLoad = async ({ fetch, url }) => {
  const q = url.searchParams.get('q') ?? '';
  const products = await fetch(`/api/products?q=${q}`).then(r => r.json());
  
  return { products, q };
};
<!-- src/routes/products/+page.svelte -->
<script lang="ts">
  import type { PageData } from './$types';
  
  let { data }: { data: PageData } = $props();
  
  // 서버 데이터를 반응형 상태로 변환
  let searchQuery = $state(data.q);
  
  // URL 변경 시 데이터 재로드
  function search() {
    goto(`/products?q=${searchQuery}`);
  }
</script>

<input bind:value={searchQuery} />
<button onclick={search}>검색</button>

{#each data.products as product (product.id)}
  <ProductCard {product} />
{/each}

8. Svelte 4 → 5 마이그레이션

<!-- Svelte 4 -->
<script>
  let count = 0;
  $: doubled = count * 2;
  $: {
    console.log('count changed:', count);
  }
  
  export let title = 'Default';
</script>

<!-- Svelte 5 (변환 후) -->
<script>
  let count = $state(0);
  let doubled = $derived(count * 2);
  $effect(() => {
    console.log('count changed:', count);
  });
  
  let { title = 'Default' } = $props();
</script>
# 공식 마이그레이션 도구
npx sv migrate svelte-5

# 주의: 자동 변환이 100%는 아님
# 특히 reactive store($store 패턴)는 수동 검토 필요

마무리

Svelte 5 Runes는 기존의 "마법 같은" 반응형에서 "명시적인" 반응형으로의 전환이다. 처음에는 더 장황해 보일 수 있지만, .svelte.ts 파일에서 로직 재사용이 가능해진 것은 큰 진보다.

React에서 넘어온다면: $stateuseState, $deriveduseMemo, $effectuseEffect로 매핑하면 쉽게 이해된다. 다만 deps 배열 없이 자동 추적한다는 점이 다르다.

Svelte의 장점인 컴파일 타임 최적화와 작은 번들 크기는 Svelte 5에서도 유지된다. 성능이 중요하고 번들 크기를 최소화해야 하는 프로젝트에서 여전히 매력적인 선택이다.