003: Svelte 5 Runes 시스템

Runes 소개

Svelte 5는 Runes라는 새로운 반응성 시스템을 도입했습니다. Runes는 $state, $derived, $effect와 같은 특별한 함수로, 반응형 상태와 계산을 더 명시적으로 선언할 수 있게 해줍니다.

Runes는 $ 접두사를 가진 함수처럼 보이지만, 실제로는 Svelte 컴파일러에게 지시를 내리는 특별한 키워드입니다. 일반 JavaScript 함수와 달리 임포트할 필요가 없으며, 변수에 할당하거나 함수 인자로 전달할 수 없습니다.

Svelte 4 vs Svelte 5 실시간 비교:

Svelte 4 스타일

안녕하세요, Svelte 4!

카운트: 0, 두 배: 0

컴포넌트 코드:

<script lang="ts">
  // Svelte 4 스타일 컴포넌트
  export let name: string = 'World';
  
  let count: number = 0;
  $: doubled = count * 2;
  
  function increment(): void {
    count += 1;
  }
</script>

<div class="bg-slate-700 p-4 rounded">
  <h3 class="text-lg text-sky-400 mb-2">Svelte 4 스타일</h3>
  <p class="text-gray-200">안녕하세요, {name}!</p>
  <p class="text-gray-200">카운트: {count}, 두 배: {doubled}</p>
  <button 
    on:click={increment}
    class="bg-sky-600 text-white py-1 px-3 rounded mt-2 hover:bg-sky-500"
  >
    증가
  </button>
</div>

Svelte 5 스타일

안녕하세요, Svelte 5!

카운트: 0, 두 배: 0

컴포넌트 코드:

<script lang="ts">
  // Svelte 5 스타일 컴포넌트
  let { name = "World" } = $props<{name?: string}>();

  let count = $state(0);
  let doubled = $derived(count * 2);

  function increment(): void {
    count += 1;
  }
</script>

<div class="bg-slate-700 p-4 rounded">
  <h3 class="text-lg text-sky-400 mb-2">Svelte 5 스타일</h3>
  <p class="text-gray-200">안녕하세요, {name}!</p>
  <p class="text-gray-200">카운트: {count}, 두 배: {doubled}</p>
  <button
    onclick={increment}
    class="bg-sky-600 text-white py-1 px-3 rounded mt-2 hover:bg-sky-500"
  >
    증가
  </button>
</div>

두 컴포넌트는 동일한 기능을 하지만, 구현 방식이 다릅니다. Svelte 4는 $: 반응형 선언을 사용하고, Svelte 5는 $state와 $derived 룬을 사용합니다. 또한 이벤트 핸들링 방식(on:click vs onclick)도 다릅니다.

$state 룬

$state 룬은 반응형 상태를 생성합니다. 이 상태가 변경되면 UI가 자동으로 업데이트됩니다. 기본 타입(숫자, 문자열, 불리언)뿐만 아니라 객체와 배열도 반응형으로 만들 수 있습니다.

$state 기본 예제

카운트: 0

이름: Svelte

활성화 상태: 활성

사용자: 홍길동, 30세

항목: 사과, 바나나, 오렌지

$state 코드 예제:

<script lang="ts">
    // $state 룬 예제 컴포넌트
    let count = $state(0);
    let name = $state("Svelte");
    let isActive = $state(true);
    let user = $state({
      name: "홍길동",
      age: 30,
    });
    let items = $state(["사과", "바나나", "오렌지"]);
  
    function updateState() {
      // 기본 타입 업데이트
      count += 1;
      name = "Svelte 5";
      isActive = !isActive;
  
      // 객체 업데이트
      user.age += 1;
  
      // 배열 업데이트
      items = [...items, "포도"];
    }
  </script>
  
  <div class="bg-slate-800 p-4 rounded mb-4">
    <p class="text-gray-200">카운트: <span class="text-sky-400">{count}</span></p>
    <p class="text-gray-200">이름: <span class="text-sky-400">{name}</span></p>
    <p class="text-gray-200">활성화 상태: <span class="text-sky-400">{isActive ? '활성' : '비활성'}</span></p>
    <p class="text-gray-200">사용자: <span class="text-sky-400">{user.name}, {user.age}세</span></p>
    <p class="text-gray-200">항목: <span class="text-sky-400">{items.join(', ')}</span></p>
  </div>
  
  <button 
    onclick={updateState}
    class="bg-sky-600 text-white py-2 px-4 rounded hover:bg-sky-500"
  >
    상태 업데이트
  </button>

객체 반응성 예제

이름: 홍길동

나이: 30

취미: 독서, 영화 감상

객체와 배열 반응성 코드 예제:

<script lang="ts">
  // 객체와 배열의 반응성 예제
  let person = $state({
    name: "홍길동",
    age: 30,
    hobbies: ["독서", "영화 감상"],
  });

  function incrementAge() {
    person.age += 1;
  }

  function changeName() {
    person.name = person.name === "홍길동" ? "김철수" : "홍길동";
  }

  function addHobby() {
    person.hobbies = [...person.hobbies, "여행"];
  }
</script>

<div class="bg-slate-800 p-4 rounded mb-4">
  <p class="text-gray-200">이름: <span class="text-sky-400">{person.name}</span></p>
  <p class="text-gray-200">나이: <span class="text-sky-400">{person.age}</span></p>
  <p class="text-gray-200">취미: <span class="text-sky-400">{person.hobbies.join(", ")}</span></p>
</div>

<div class="flex flex-wrap gap-2">
  <button
    onclick={incrementAge}
    class="bg-sky-600 text-white py-2 px-4 rounded hover:bg-sky-500"
  >
    나이 증가
  </button>
  <button
    onclick={changeName}
    class="bg-sky-600 text-white py-2 px-4 rounded hover:bg-sky-500"
  >
    이름 변경
  </button>
  <button
    onclick={addHobby}
    class="bg-sky-600 text-white py-2 px-4 rounded hover:bg-sky-500"
  >
    취미 추가
  </button>
</div>

할일 목록 예제

  • 스벨트 배우기
  • 룬 시스템 이해하기

할일 목록 코드 예제:

<script lang="ts">
  // 할일 목록 예제
  let todoList = $state([
    { id: 1, text: "스벨트 배우기", done: false },
    { id: 2, text: "룬 시스템 이해하기", done: true },
  ]);

  function addTodo() {
    const newId =
      todoList.length > 0 ? Math.max(...todoList.map((t) => t.id)) + 1 : 1;
    todoList = [...todoList, { id: newId, text: "새 할일", done: false }];
  }

  function toggleTodo(id: number) {
    todoList = todoList.map((todo) =>
      todo.id === id ? { ...todo, done: !todo.done } : todo,
    );
  }

  function removeTodo(id: number) {
    todoList = todoList.filter((todo) => todo.id !== id);
  }
</script>

<div class="bg-slate-800 p-4 rounded mb-4">
  <ul class="list-none p-0">
    {#each todoList as todo (todo.id)}
      <li class="flex items-center mb-2">
        <input
          type="checkbox"
          checked={todo.done}
          onchange={() => toggleTodo(todo.id)}
          class="mr-2"
        />
        <span class={todo.done ? "line-through text-gray-400" : "text-gray-200"}>
          {todo.text}
        </span>
        <button
          onclick={() => removeTodo(todo.id)}
          class="ml-auto bg-red-600 text-white text-xs py-1 px-2 rounded hover:bg-red-500"
        >
          삭제
        </button>
      </li>
    {/each}
  </ul>
</div>

<button
  onclick={addTodo}
  class="bg-sky-600 text-white py-2 px-4 rounded hover:bg-sky-500"
>
  할일 추가
</button>

$derived 룬

$derived 룬은 다른 상태에서 파생된 값을 계산합니다. 원본 상태가 변경되면 파생된 값도 자동으로 업데이트됩니다. 이는 이전 버전의 $: 반응형 선언을 대체합니다.

$derived 예제

Count: 0

Doubled: 0

Tripled: 0

Message: 0의 2배는 0, 3배는 0입니다


할일 진행률: 50% (1/2)

$derived 코드 예제:

<script lang="ts">
  // $derived 룬 예제
  let count = $state(0);
  let doubled = $derived(count * 2);
  let tripled = $derived(count * 3);
  let message = $derived(`${count}의 2배는 ${doubled}, 3배는 ${tripled}입니다`);

  // 할일 목록 예제를 위한 상태
  let todos = $state([
    { id: 1, done: false, text: "스벨트 배우기" },
    { id: 2, done: true, text: "룬 시스템 이해하기" },
  ]);
  let completedCount = $derived(todos.filter((todo) => todo.done).length);
  let totalCount = $derived(todos.length);
  let progress = $derived(
    totalCount === 0 ? 0 : Math.round((completedCount / totalCount) * 100)
  );

  function incrementCount() {
    count += 1;
  }
</script>

<div class="bg-slate-800 p-4 rounded mb-4">
  <p class="text-gray-200">Count: <span class="text-sky-400">{count}</span></p>
  <p class="text-gray-200">Doubled: <span class="text-sky-400">{doubled}</span></p>
  <p class="text-gray-200">Tripled: <span class="text-sky-400">{tripled}</span></p>
  <p class="text-gray-200">Message: <span class="text-sky-400">{message}</span></p>
  <hr class="border-slate-700 my-2" />
  <p class="text-gray-200">할일 진행률: <span class="text-sky-400">{progress}% ({completedCount}/{totalCount})</span></p>
</div>

<button
  onclick={incrementCount}
  class="bg-sky-600 text-white py-2 px-4 rounded hover:bg-sky-500"
>
  카운트 증가
</button>

$effect 룬

$effect 룬은 상태 변경에 반응하여 부수 효과를 실행합니다. 이는 DOM 조작, 타이머 설정, API 호출 등 반응형 상태에 따라 실행해야 하는 작업에 유용합니다.

조건부 effect 예제

상태: 활성

(콘솔에 "활성화되었습니다" 메시지가 표시됩니다)

$effect 코드 예제:

<script lang="ts">
  // 조건부 effect 예제
  let isActive = $state(true);
  
  $effect(() => {
    if (isActive) {
      console.log("활성화되었습니다");
    }
  });

  function toggleActive() {
    isActive = !isActive;
  }
</script>

<div class="bg-slate-800 p-4 rounded mb-4">
  <p class="text-gray-200">상태: <span class="text-sky-400">{isActive ? "활성" : "비활성"}</span></p>
  <p class="text-sm text-gray-400">
    (콘솔에 "활성화되었습니다" 메시지가 표시됩니다)
  </p>
</div>

<button
  onclick={toggleActive}
  class="bg-sky-600 text-white py-2 px-4 rounded hover:bg-sky-500"
>
  상태 토글
</button>

Effect 카운트 예제

Effect 카운트: 0

메시지:

Effect 카운트 코드 예제:

<script lang="ts">
  // Effect 카운트 증가 예제
  let effectCount = $state(0);
  let effectMessage = $state("");

  // 상태 변화에 반응하는 effect
  $effect(() => {
    effectMessage = `effectCount가 ${effectCount}로 변경되었습니다`;
    console.log(effectMessage);
  });

  function incrementEffectCount() {
    effectCount += 1;
  }
</script>

<div class="bg-slate-800 p-4 rounded mb-4">
  <p class="text-gray-200">Effect 카운트: <span class="text-sky-400">{effectCount}</span></p>
  <p class="text-gray-200">메시지: <span class="text-sky-400">{effectMessage}</span></p>
</div>

<button
  onclick={incrementEffectCount}
  class="bg-sky-600 text-white py-2 px-4 rounded hover:bg-sky-500"
>
  Effect 카운트 증가
</button>

시간 업데이트 예제

현재 시간: 2:20:03 AM

(1초마다 자동으로 업데이트됩니다)

시간 업데이트 코드 예제:

<script lang="ts">
  // 시간 업데이트 예제
  let currentTime = $state(new Date());

  // 시간 업데이트를 위한 effect
  $effect(() => {
    const interval = setInterval(() => {
      currentTime = new Date();
    }, 1000);

    // 정리 함수 반환 (컴포넌트가 제거될 때 실행)
    return () => {
      clearInterval(interval);
    };
  });
</script>

<div class="bg-slate-800 p-4 rounded mb-4">
  <p class="text-gray-200">현재 시간: <span class="text-sky-400">{currentTime.toLocaleTimeString()}</span></p>
  <p class="text-sm text-gray-400">
    (1초마다 자동으로 업데이트됩니다)
  </p>
</div>

$state.raw와 $state.snapshot

$state.raw는 얕은 반응성을 제공하는 변형된 $state 룬입니다. 최상위 속성만 반응형으로 만들고 중첩된 객체나 배열은 반응형으로 만들지 않습니다. 성능 최적화가 필요한 경우 유용합니다.

참고: Svelte 5의 최신 버전에서는 $state.raw의 동작이 변경되어 중첩 속성 변경도 UI에 반영될 수 있습니다. 그러나 공식 문서에 따르면 중첩 속성의 반응성은 보장되지 않으므로 전체 객체를 재할당하는 것이 안전합니다.

$state.snapshot은 반응형 상태의 일반 JavaScript 객체 스냅샷을 생성합니다. API 요청이나 로컬 스토리지 저장 등에 유용합니다.

$state vs $state.raw 비교

일반 $state 객체:

이름: 홍길동

나이: 30

직업: 개발자

$state.raw 객체:

이름: 이영희

나이: 25

직업: 디자이너

참고: Svelte 5 최신 버전에서는 $state.raw의 중첩 속성 변경도 UI에 반영될 수 있지만, 공식적으로는 보장되지 않으므로 전체 객체 재할당 방식을 권장합니다.

$state vs $state.raw 코드 예제:

<script lang="ts">
  // $state vs $state.raw 비교 예제
  let normalPerson = $state({
    name: "홍길동",
    profile: {
      age: 30,
      job: "개발자",
    },
  });

  let rawPerson = $state.raw({
    name: "이영희",
    profile: {
      age: 25,
      job: "디자이너",
    },
  });

  function updateNormalPerson() {
    normalPerson.profile.age += 1;
  }

  function updateRawPersonWrong() {
    // 작동하지 않는 코드
    rawPerson.profile.age += 1;
  }

  function updateRawPersonCorrect() {
    rawPerson = {
      ...rawPerson,
      profile: {
        ...rawPerson.profile,
        age: rawPerson.profile.age + 1,
      },
    };
  }
</script>

$state.snapshot 예제

복잡한 객체:

  • 아이템 1: 미완료
  • 아이템 2: 완료

마지막 업데이트: 4/20/2025, 2:20:03 AM

항목 수: 2

$state.snapshot 코드 예제:

<script lang="ts">
  // $state.snapshot 예제
  let complexObject = $state({
    items: [
      { id: 1, name: "아이템 1", completed: false },
      { id: 2, name: "아이템 2", completed: true },
    ],
    meta: {
      lastUpdated: new Date(),
      count: 2,
    },
  });

  function takeSnapshot() {
    // 프록시가 아닌 일반 객체 스냅샷 생성
    const snapshot = $state.snapshot(complexObject);
    alert(JSON.stringify(snapshot, null, 2));
  }
</script>

실용적인 예제

카운터 앱

카운터 앱 코드:

<script lang="ts">
  let counterValue = $state(0);
  let counterHistory = $state<number[]>([]);
  let counterAverage = $derived(
    counterHistory.length > 0
      ? counterHistory.reduce((sum, val) => sum + val, 0) / counterHistory.length
      : 0
  );

  function updateCounter(amount: number) {
    counterValue += amount;
    counterHistory = [...counterHistory, counterValue];
  }

  function resetCounter() {
    counterValue = 0;
    counterHistory = [];
  }
</script>

<div class="bg-slate-800 p-4 rounded mb-4">
  <p class="text-2xl mb-2">{counterValue}</p>
  <p>기록: {counterHistory.join(", ")}</p>
  <p>평균: {counterAverage.toFixed(2)}</p>
</div>

<div class="flex gap-2">
  <button
    onclick={() => updateCounter(-1)}
    class="bg-red-600 text-white py-2 px-4 rounded hover:bg-red-500"
  >
    감소
  </button>
  <button
    onclick={() => updateCounter(1)}
    class="bg-green-600 text-white py-2 px-4 rounded hover:bg-green-500"
  >
    증가
  </button>
  <button
    onclick={resetCounter}
    class="bg-gray-600 text-white py-2 px-4 rounded hover:bg-gray-500"
  >
    초기화
  </button>
</div>

0

기록:

평균: 0.00

테마 전환

테마 전환 코드:

<script lang="ts">
  let theme = $state("dark");

  $effect(() => {
    if (theme === "dark") {
      document.documentElement.classList.add("dark-theme");
      document.documentElement.classList.remove("light-theme");
    } else {
      document.documentElement.classList.add("light-theme");
      document.documentElement.classList.remove("dark-theme");
    }
    console.log(`테마가 ${theme}로 변경되었습니다`);
  });

  function toggleTheme() {
    theme = theme === "dark" ? "light" : "dark";
  }
</script>

<div
  class={`p-4 rounded mb-4 transition-colors duration-300 ${
    theme === "dark" ? "bg-slate-800 text-sky-400" : "bg-slate-100 text-sky-600"
  }`}
>
  <p>
    현재 테마: <span class="font-bold">{theme}</span>
  </p>
  <p class="mt-2">
    {theme === "dark"
      ? "어두운 테마가 적용되었습니다."
      : "밝은 테마가 적용되었습니다."}
  </p>
</div>

<button
  onclick={toggleTheme}
  class={`py-2 px-4 rounded cursor-pointer text-base ${
    theme === "dark"
      ? "bg-sky-600 text-white hover:bg-sky-500"
      : "bg-sky-600 text-white hover:bg-sky-500"
  }`}
>
  {theme === "dark" ? "밝은 테마로 전환" : "어두운 테마로 전환"}
</button>

현재 테마: dark

어두운 테마가 적용되었습니다.

폼 처리

폼 처리 코드:

<script lang="ts">
  let formName = $state("");
  let formEmail = $state("");
  let formSubmitted = $state(false);
  let formData = $state({ name: "", email: "" });

  function handleSubmit(event: Event) {
    event.preventDefault();
    formData = { name: formName, email: formEmail };
    formSubmitted = true;

    setTimeout(() => {
      formSubmitted = false;
    }, 5000);
  }
</script>

{#if formSubmitted}
  <div class="bg-green-800 text-white p-4 rounded mb-4">
    <p>폼이 제출되었습니다!</p>
    <p>이름: {formData.name}</p>
    <p>이메일: {formData.email}</p>
  </div>
{/if}

<form onsubmit={handleSubmit} class="bg-slate-800 p-4 rounded mb-4">
  <div class="mb-4">
    <label for="name" class="block mb-2">이름:</label>
    <input
      id="name"
      type="text"
      bind:value={formName}
      class="w-full p-2 bg-slate-700 border border-slate-600 rounded"
    />
  </div>

  <div class="mb-4">
    <label for="email" class="block mb-2">이메일:</label>
    <input
      id="email"
      type="email"
      bind:value={formEmail}
      class="w-full p-2 bg-slate-700 border border-slate-600 rounded"
    />
  </div>

  <button
    type="submit"
    class="bg-sky-600 text-white py-2 px-4 rounded hover:bg-sky-500"
  >
    제출
  </button>
</form>

결론

Svelte 5의 Runes 시스템은 반응성을 더 명시적이고 강력하게 만들어 줍니다. $state, $derived, $effect와 같은 룬을 사용하면 상태 관리가 더 직관적이고 예측 가능해집니다.

이 새로운 접근 방식은 Svelte의 간결함을 유지하면서도 더 복잡한 애플리케이션을 구축할 수 있는 강력한 도구를 제공합니다. 룬 시스템을 마스터하면 더 효율적이고 유지보수하기 쉬운 Svelte 애플리케이션을 개발할 수 있습니다.

실습 과제

Todo 앱 만들기

지금까지 배운 Svelte 5의 Runes 시스템을 활용하여 다음 기능이 있는 Todo 앱을 만들어보세요:

  • $state를 사용한 할 일 목록 관리
  • $derived를 사용한 완료된 할 일 개수 계산
  • $effect를 사용한 로컬 스토리지 저장
  • $state.snapshot을 사용한 데이터 백업 기능

다음 강의 미리보기

4강: 컴포넌트 통신

다음 강의에서는 Svelte 컴포넌트 간의 통신 방법에 대해 배웁니다:

  • $props를 사용한 데이터 전달
  • 이벤트 디스패치와 처리 방법
  • 컴포넌트 구성 (슬롯과 스니펫)
  • 컨텍스트 API 활용
  • 부모-자식 컴포넌트 간 통신 패턴
  • 양방향 데이터 바인딩
  • 컴포넌트 생명주기 이해하기

© 2024 Coding Stairs. All rights reserved.