Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save YangSiJun528/c75b4b81f5bd81db91aa2e82c25c6f74 to your computer and use it in GitHub Desktop.

Select an option

Save YangSiJun528/c75b4b81f5bd81db91aa2e82c25c6f74 to your computer and use it in GitHub Desktop.
Svelte 5 Runes 완전 가이드.md

Svelte 5 Runes 완전 가이드

목차

  1. Runes란 무엇인가?
  2. 모든 Runes 개요
  3. 상태 관리 Runes
  4. 컴포넌트 통신 Runes
  5. 디버깅 및 특수 목적 Runes
  6. 실제 사용 패턴과 예제
  7. 마이그레이션 가이드

Runes란 무엇인가?

rune /ruːn/ 명사
신비롭거나 마법적인 상징으로 사용되는 문자나 표시

Runes는 .svelte.svelte.js / .svelte.ts 파일에서 Svelte 컴파일러를 제어하는 데 사용하는 기호입니다. Svelte를 언어로 생각한다면, runes는 구문의 일부이며 키워드입니다.

Runes의 특징

  1. $ 접두사: 모든 runes는 $로 시작하며 함수처럼 보입니다
  2. import 불필요: 언어의 일부이므로 별도로 import할 필요 없음
  3. 값이 아님: 변수에 할당하거나 함수의 인수로 전달할 수 없음
  4. 위치 제한: JavaScript 키워드처럼 특정 위치에서만 유효
let message = $state('hello'); // ✅ 올바른 사용
const myState = $state; // ❌ 불가능 - 값이 아님

모든 Runes 개요

Svelte 5에는 총 7개의 주요 Runes가 있습니다:

Rune 목적 카테고리
$state 반응형 상태 생성 상태 관리
$derived 파생된 상태 생성 상태 관리
$effect 부작용 처리 상태 관리
$props 컴포넌트 속성 받기 컴포넌트 통신
$bindable 양방향 바인딩 컴포넌트 통신
$inspect 디버깅 도구 디버깅
$host 커스텀 엘리먼트 호스트 접근 특수 목적

상태 관리 Runes

1. $state - 반응형 상태의 핵심

$state는 Svelte 5의 반응형 시스템의 핵심입니다. UI가 상태 변화에 반응하도록 하는 reactive state를 생성합니다.

기본 사용법

let count = $state(0);
let user = $state({ name: 'John', age: 30 });
let items = $state(['apple', 'banana']);

특징

  • 직접 접근: count는 숫자 자체이며, .valuegetCount() 같은 래퍼 없이 직접 사용
  • Deep Reactivity: 객체나 배열의 경우 깊은 반응성을 제공하는 Proxy 생성
  • 세밀한 업데이트: array.push() 같은 메서드도 반응성 트리거
<script>
  let todos = $state([
    { done: false, text: 'add more todos' }
  ]);
</script>

<button onclick={() => todos.push({ done: false, text: 'new todo' })}>
  Add Todo
</button>

{#each todos as todo}
  <div>
    <input bind:checked={todo.done} type="checkbox" />
    {todo.text}
  </div>
{/each}

$state.raw - 성능 최적화

객체와 배열을 deeply reactive하게 만들고 싶지 않을 때 사용합니다.

let person = $state.raw({
  name: 'Heraclitus',
  age: 49
});

// ❌ 이것은 효과 없음
person.age += 1;

// ✅ 이것은 작동함 (전체 객체 교체)
person = {
  name: 'Heraclitus',
  age: 50
};

언제 사용하나요?

  • 큰 배열이나 객체에서 성능 향상이 필요할 때
  • 변경하지 않을 데이터를 다룰 때
  • 반응성 비용을 피하고 싶을 때

$state.snapshot - 정적 스냅샷

deeply reactive $state proxy의 정적 스냅샷을 생성합니다.

let counter = $state({ count: 0 });

function onClick() {
  // 'Proxy { ... }' 대신 '{ count: ... }'를 로그
  console.log($state.snapshot(counter));
}

언제 사용하나요?

  • 외부 라이브러리나 API에 상태를 전달할 때
  • structuredClone 같은 함수에 전달할 때
  • 디버깅 목적으로 현재 상태를 확인할 때

2. $derived - 계산된 상태

다른 상태로부터 파생된 상태를 선언합니다. 의존성이 변경될 때 자동으로 재계산됩니다.

기본 사용법

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

$derived.by - 복잡한 계산

복잡한 파생 로직을 위한 함수형 버전입니다.

let numbers = $state([1, 2, 3, 4, 5]);
let total = $derived.by(() => {
  let sum = 0;
  for (const n of numbers) {
    sum += n;
  }
  return sum;
});

let statistics = $derived.by(() => {
  const sum = numbers.reduce((a, b) => a + b, 0);
  const avg = sum / numbers.length;
  const max = Math.max(...numbers);
  const min = Math.min(...numbers);
  
  return { sum, avg, max, min };
});

중요한 규칙

  • 부작용 금지: $derived 내부에서는 상태를 변경하면 안됨
  • 순수 함수: 같은 입력에 대해 항상 같은 출력을 반환해야 함
// ❌ 잘못된 사용
let count = $state(0);
let doubled = $derived(count++ * 2); // 상태 변경 금지!

// ✅ 올바른 사용
let count = $state(0);
let doubled = $derived(count * 2);

3. $effect - 부작용 처리

상태가 업데이트될 때 실행되는 함수입니다. DOM 조작, API 호출, 서드파티 라이브러리 연동 등에 사용합니다.

기본 사용법

let size = $state(50);
let color = $state('#ff3e00');
let canvas;

$effect(() => {
  const context = canvas.getContext('2d');
  context.clearRect(0, 0, canvas.width, canvas.height);
  
  // size나 color가 변경될 때마다 재실행
  context.fillStyle = color;
  context.fillRect(0, 0, size, size);
});

Cleanup (정리) 함수

effect가 재실행되기 전에 실행될 정리 함수를 반환할 수 있습니다.

let milliseconds = $state(1000);

$effect(() => {
  const interval = setInterval(() => {
    count++;
  }, milliseconds);

  // cleanup 함수 - milliseconds가 변경되면 이전 interval 정리
  return () => {
    clearInterval(interval);
  };
});

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

DOM 업데이트가 적용되기 전에 실행되는 effect입니다.

let element;

$effect.pre(() => {
  // DOM 업데이트 전에 요소의 크기나 위치 읽기
  const rect = element.getBoundingClientRect();
  console.log('Before update:', rect);
});

언제 사용하지 말아야 하나요?

  1. 상태 동기화: $derived 사용
// ❌ 잘못된 방법
let firstName = $state('John');
let lastName = $state('Doe');
let fullName = $state('');

$effect(() => {
  fullName = `${firstName} ${lastName}`;
});

// ✅ 올바른 방법
let firstName = $state('John');
let lastName = $state('Doe');
let fullName = $derived(`${firstName} ${lastName}`);
  1. 이벤트 핸들러에서 상태 업데이트: 직접 업데이트
// ❌ 불필요한 effect
let count = $state(0);

$effect(() => {
  if (someCondition) {
    count++;
  }
});

// ✅ 이벤트 핸들러에서 직접
function handleClick() {
  if (someCondition) {
    count++;
  }
}

컴포넌트 통신 Runes

4. $props - 컴포넌트 속성

컴포넌트의 입력값(properties)을 받는 Rune입니다. Svelte 4의 export let을 대체합니다.

기본 사용법

부모 컴포넌트:

<script>
  import Button from './Button.svelte';
</script>

<Button variant="primary" disabled={false}>
  Click me
</Button>

자식 컴포넌트 (Button.svelte):

<script>
  let { variant = 'default', disabled = false, children } = $props();
</script>

<button class="btn btn-{variant}" {disabled}>
  {@render children()}
</button>

고급 패턴

1. 속성 이름 변경:

<script>
  // 'class'는 JavaScript 키워드이므로 이름 변경
  let { class: className, ...rest } = $props();
</script>

<div class={className} {...rest}>
  <!-- content -->
</div>

2. Rest Props (나머지 속성):

<script>
  let { variant, disabled, ...rest } = $props();
</script>

<button class="btn btn-{variant}" {disabled} {...rest}>
  <!-- 나머지 모든 속성을 button에 전달 -->
</button>

3. TypeScript 타입 안전성:

interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'danger';
  disabled?: boolean;
  children: Snippet;
  onclick?: () => void;
}

let { 
  variant = 'primary', 
  disabled = false, 
  children,
  onclick 
}: ButtonProps = $props();

5. $bindable - 양방향 바인딩

일반적으로 props는 부모에서 자식으로의 단방향 흐름이지만, $bindable을 사용하면 자식에서 부모로도 데이터가 흐를 수 있습니다.

기본 사용법

자식 컴포넌트 (FancyInput.svelte):

<script>
  let { value = $bindable(), placeholder = '', ...rest } = $props();
</script>

<input bind:value {placeholder} {...rest} />

<style>
  input {
    border: 2px solid #ff3e00;
    border-radius: 4px;
    padding: 8px;
  }
</style>

부모 컴포넌트:

<script>
  let message = $state('Hello World');
</script>

<FancyInput bind:value={message} placeholder="Enter message" />
<p>Current message: {message}</p>

실제 사용 예제 - 커스텀 체크박스

<!-- CustomCheckbox.svelte -->
<script>
  let { 
    checked = $bindable(false), 
    label,
    disabled = false 
  } = $props();
</script>

<label class:disabled>
  <input 
    type="checkbox" 
    bind:checked 
    {disabled}
  />
  <span class="checkmark"></span>
  {label}
</label>

<style>
  label {
    display: flex;
    align-items: center;
    cursor: pointer;
  }
  
  .checkmark {
    width: 20px;
    height: 20px;
    border: 2px solid #ccc;
    margin-right: 8px;
  }
  
  input:checked + .checkmark {
    background-color: #ff3e00;
  }
</style>

사용:

<script>
  let isAccepted = $state(false);
</script>

<CustomCheckbox 
  bind:checked={isAccepted} 
  label="I accept the terms and conditions" 
/>

{#if isAccepted}
  <p>Thank you for accepting!</p>
{/if}

주의사항

  • 신중하게 사용: 양방향 바인딩은 데이터 흐름을 복잡하게 만들 수 있음
  • 명확한 소유권: 어떤 컴포넌트가 상태를 '소유'하는지 명확히 해야 함
  • 대안 고려: 대부분의 경우 이벤트 콜백이 더 명확할 수 있음

디버깅 및 특수 목적 Runes

6. $inspect - 디버깅 도구

개발 중에만 작동하는 디버깅 도구입니다. 프로덕션 빌드에서는 자동으로 제거됩니다.

기본 사용법

let count = $state(0);
let message = $state('hello');

// count나 message가 변경될 때마다 console.log
$inspect(count, message);

고급 기능

1. 커스텀 로깅:

$inspect(count, message).with((type, ...values) => {
  if (type === 'init') {
    console.log('🚀 초기값:', values);
  } else if (type === 'update') {
    console.log('🔄 업데이트:', values);
  }
});

2. 스택 트레이스:

// 값이 어디서 변경되었는지 추적
$inspect.trace(count);

실제 디버깅 예제

<script>
  let user = $state({
    name: 'John',
    preferences: {
      theme: 'dark',
      notifications: true
    }
  });

  // 사용자 객체의 모든 변경사항 추적
  $inspect(user).with((type, value) => {
    console.group(`User ${type}`);
    console.log('Current state:', JSON.stringify(value, null, 2));
    console.groupEnd();
  });

  function updateTheme(newTheme) {
    user.preferences.theme = newTheme;
  }
</script>

<button onclick={() => updateTheme('light')}>
  Switch to Light Theme
</button>

7. $host - 커스텀 엘리먼트 호스트

컴포넌트를 custom element로 컴파일할 때 host element에 접근할 수 있게 해주는 Rune입니다.

기본 사용법

Stepper.svelte (Custom Element):

<svelte:options customElement="my-stepper" />

<script>
  let count = $state(0);
  
  function dispatch(type, detail = {}) {
    $host().dispatchEvent(new CustomEvent(type, { detail }));
  }
  
  function increment() {
    count++;
    dispatch('increment', { count });
  }
  
  function decrement() {
    count--;
    dispatch('decrement', { count });
  }
</script>

<div class="stepper">
  <button onclick={decrement}>-</button>
  <span>{count}</span>
  <button onclick={increment}>+</button>
</div>

<style>
  .stepper {
    display: flex;
    align-items: center;
    gap: 8px;
  }
  
  button {
    width: 32px;
    height: 32px;
    border: 1px solid #ccc;
    background: white;
    cursor: pointer;
  }
</style>

HTML에서 사용:

<script>
  const stepper = document.querySelector('my-stepper');
  
  stepper.addEventListener('increment', (e) => {
    console.log('Incremented to:', e.detail.count);
  });
  
  stepper.addEventListener('decrement', (e) => {
    console.log('Decremented to:', e.detail.count);
  });
</script>

<my-stepper></my-stepper>

실제 사용 패턴과 예제

패턴 1: 전역 상태 관리

간단한 전역 상태

// stores/theme.svelte.js
export const theme = $state({
  mode: 'light',
  primaryColor: '#ff3e00'
});

export function toggleTheme() {
  theme.mode = theme.mode === 'light' ? 'dark' : 'light';
}

복잡한 상태 관리 (클래스 사용)

// stores/userStore.svelte.js
class UserStore {
  user = $state(null);
  isLoading = $state(false);
  error = $state(null);
  
  async login(credentials) {
    this.isLoading = true;
    this.error = null;
    
    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(credentials)
      });
      
      if (!response.ok) {
        throw new Error('Login failed');
      }
      
      this.user = await response.json();
    } catch (err) {
      this.error = err.message;
    } finally {
      this.isLoading = false;
    }
  }
  
  logout() {
    this.user = null;
    this.error = null;
  }
  
  get isLoggedIn() {
    return this.user !== null;
  }
}

export const userStore = new UserStore();

패턴 2: 폼 상태 관리

<!-- ContactForm.svelte -->
<script>
  let formData = $state({
    name: '',
    email: '',
    message: ''
  });
  
  let errors = $state({});
  let isSubmitting = $state(false);
  
  // 실시간 유효성 검사
  let isValid = $derived(
    formData.name.length > 0 &&
    formData.email.includes('@') &&
    formData.message.length > 10
  );
  
  // 에러 메시지 계산
  let nameError = $derived(
    formData.name.length === 0 ? 'Name is required' : null
  );
  
  let emailError = $derived(
    !formData.email.includes('@') ? 'Valid email is required' : null
  );
  
  async function handleSubmit() {
    if (!isValid) return;
    
    isSubmitting = true;
    
    try {
      const response = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(formData)
      });
      
      if (response.ok) {
        // 성공 시 폼 리셋
        formData = { name: '', email: '', message: '' };
        alert('Message sent successfully!');
      }
    } catch (error) {
      alert('Failed to send message');
    } finally {
      isSubmitting = false;
    }
  }
</script>

<form onsubmit={handleSubmit}>
  <div class="field">
    <label for="name">Name</label>
    <input 
      id="name"
      bind:value={formData.name}
      class:error={nameError}
    />
    {#if nameError}
      <span class="error-message">{nameError}</span>
    {/if}
  </div>
  
  <div class="field">
    <label for="email">Email</label>
    <input 
      id="email"
      type="email"
      bind:value={formData.email}
      class:error={emailError}
    />
    {#if emailError}
      <span class="error-message">{emailError}</span>
    {/if}
  </div>
  
  <div class="field">
    <label for="message">Message</label>
    <textarea 
      id="message"
      bind:value={formData.message}
      rows="4"
    ></textarea>
  </div>
  
  <button 
    type="submit" 
    disabled={!isValid || isSubmitting}
  >
    {isSubmitting ? 'Sending...' : 'Send Message'}
  </button>
</form>

<style>
  .field {
    margin-bottom: 1rem;
  }
  
  label {
    display: block;
    margin-bottom: 0.5rem;
    font-weight: bold;
  }
  
  input, textarea {
    width: 100%;
    padding: 0.5rem;
    border: 1px solid #ccc;
    border-radius: 4px;
  }
  
  .error {
    border-color: #ff0000;
  }
  
  .error-message {
    color: #ff0000;
    font-size: 0.875rem;
    margin-top: 0.25rem;
  }
  
  button:disabled {
    opacity: 0.6;
    cursor: not-allowed;
  }
</style>

패턴 3: 데이터 페칭과 캐싱

// stores/dataStore.svelte.js
class DataStore {
  cache = $state(new Map());
  loading = $state(new Set());
  
  async fetchData(key, fetcher) {
    // 캐시에 있으면 반환
    if (this.cache.has(key)) {
      return this.cache.get(key);
    }
    
    // 이미 로딩 중이면 대기
    if (this.loading.has(key)) {
      return new Promise((resolve) => {
        const checkCache = () => {
          if (this.cache.has(key)) {
            resolve(this.cache.get(key));
          } else {
            setTimeout(checkCache, 100);
          }
        };
        checkCache();
      });
    }
    
    // 새로운 데이터 페칭
    this.loading.add(key);
    
    try {
      const data = await fetcher();
      this.cache.set(key, data);
      return data;
    } finally {
      this.loading.delete(key);
    }
  }
  
  invalidate(key) {
    this.cache.delete(key);
  }
  
  isLoading(key) {
    return this.loading.has(key);
  }
}

export const dataStore = new DataStore();

마이그레이션 가이드

Svelte 4에서 Svelte 5로

1. 반응형 변수 (let$state)

Svelte 4:

<script>
  let count = 0;
  let user = { name: 'John' };
</script>

Svelte 5:

<script>
  let count = $state(0);
  let user = $state({ name: 'John' });
</script>

2. 반응형 구문 ($:$derived/$effect)

Svelte 4:

<script>
  let count = 0;
  $: doubled = count * 2;
  $: {
    if (count > 5) {
      alert('Count is too high!');
    }
  }
</script>

Svelte 5:

<script>
  let count = $state(0);
  let doubled = $derived(count * 2);
  
  $effect(() => {
    if (count > 5) {
      alert('Count is too high!');
    }
  });
</script>

3. Props (export let$props)

Svelte 4:

<script>
  export let title = 'Default Title';
  export let items = [];
  export let onItemClick = () => {};
</script>

Svelte 5:

<script>
  let { 
    title = 'Default Title', 
    items = [], 
    onItemClick = () => {} 
  } = $props();
</script>

4. 스토어 → Runes

Svelte 4:

// store.js
import { writable, derived } from 'svelte/store';

export const count = writable(0);
export const doubled = derived(count, $count => $count * 2);

Svelte 5:

// store.svelte.js
export const state = $state({ count: 0 });
export const doubled = $derived(state.count * 2);

점진적 마이그레이션

Svelte 5는 기존 Svelte 4 구문과 호환됩니다. 다음과 같이 점진적으로 마이그레이션할 수 있습니다:

  1. 새 컴포넌트부터 Runes 사용
  2. 기존 컴포넌트는 필요에 따라 점진적 업데이트
  3. Svelte 4와 Svelte 5 컴포넌트 혼용 가능

결론

Svelte 5의 Runes는 더 명시적이고 예측 가능한 반응형 시스템을 제공합니다. 주요 장점은:

  1. Universal Reactivity: 컴포넌트 내외부에서 동일한 반응형 시스템 사용
  2. 명시적 API: 무엇이 반응형인지 명확하게 표현
  3. 성능 향상: 더 효율적인 신호 기반 시스템
  4. TypeScript 친화적: 타입 안전성 향상
  5. 디버깅 개선: 더 나은 개발자 경험

각 Rune을 적절한 상황에서 사용하면 더 깔끔하고 유지보수하기 쉬운 Svelte 애플리케이션을 만들 수 있습니다.


상태 관리 심화 가이드

컴포넌트 vs 전역 상태 관리 전략

앞서 설명한 기본 개념을 바탕으로, 실제 애플리케이션에서 상태를 어떻게 구조화할지 결정하는 것이 중요합니다.

컴포넌트 상태를 사용해야 하는 경우

  1. UI 상태: 모달 열림/닫힘, 폼 입력값, 로딩 상태 등
  2. 일시적 상태: 애니메이션 상태, 임시 계산값 등
  3. 컴포넌트별 고유 상태: 각 인스턴스마다 다른 값을 가져야 하는 경우
<!-- TodoItem.svelte -->
<script>
  let { todo, onToggle, onDelete } = $props();
  
  // 컴포넌트별 UI 상태
  let isEditing = $state(false);
  let editText = $state('');
  
  function startEdit() {
    isEditing = true;
    editText = todo.text;
  }
  
  function saveEdit() {
    if (editText.trim()) {
      todo.text = editText.trim();
      isEditing = false;
    }
  }
</script>

<div class="todo-item">
  {#if isEditing}
    <input 
      bind:value={editText} 
      onkeydown={(e) => e.key === 'Enter' && saveEdit()}
    />
    <button onclick={saveEdit}>Save</button>
  {:else}
    <span 
      class:completed={todo.completed}
      ondblclick={startEdit}
    >
      {todo.text}
    </span>
  {/if}
  
  <button onclick={() => onToggle(todo.id)}>
    {todo.completed ? '' : ''}
  </button>
  <button onclick={() => onDelete(todo.id)}>🗑</button>
</div>

전역 상태를 사용해야 하는 경우

  1. 애플리케이션 전체 설정: 테마, 언어, 사용자 설정 등
  2. 사용자 데이터: 로그인 정보, 프로필 등
  3. 공유 데이터: 여러 컴포넌트에서 접근해야 하는 데이터
  4. 캐시된 데이터: API 응답, 계산 결과 등

고급 상태 관리 패턴

1. 상태 팩토리 패턴

재사용 가능한 상태 로직을 만들 때 유용합니다.

// composables/useCounter.svelte.js
export function useCounter(initialValue = 0, step = 1) {
  let count = $state(initialValue);
  
  const increment = () => count += step;
  const decrement = () => count -= step;
  const reset = () => count = initialValue;
  const set = (value) => count = value;
  
  return {
    get count() { return count; },
    increment,
    decrement,
    reset,
    set
  };
}
<!-- 사용 예제 -->
<script>
  import { useCounter } from './composables/useCounter.svelte.js';
  
  const counter1 = useCounter(0, 1);
  const counter2 = useCounter(100, 10);
</script>

<div>
  <h3>Counter 1: {counter1.count}</h3>
  <button onclick={counter1.increment}>+1</button>
  <button onclick={counter1.decrement}>-1</button>
</div>

<div>
  <h3>Counter 2: {counter2.count}</h3>
  <button onclick={counter2.increment}>+10</button>
  <button onclick={counter2.decrement}>-10</button>
</div>

2. 상태 머신 패턴

복잡한 상태 전환을 관리할 때 유용합니다.

// stores/authStore.svelte.js
class AuthStore {
  state = $state('idle'); // 'idle' | 'loading' | 'authenticated' | 'error'
  user = $state(null);
  error = $state(null);
  
  get isIdle() { return this.state === 'idle'; }
  get isLoading() { return this.state === 'loading'; }
  get isAuthenticated() { return this.state === 'authenticated'; }
  get hasError() { return this.state === 'error'; }
  
  async login(credentials) {
    if (this.state === 'loading') return;
    
    this.state = 'loading';
    this.error = null;
    
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(credentials)
      });
      
      if (!response.ok) {
        throw new Error('Invalid credentials');
      }
      
      this.user = await response.json();
      this.state = 'authenticated';
    } catch (err) {
      this.error = err.message;
      this.state = 'error';
    }
  }
  
  logout() {
    this.user = null;
    this.error = null;
    this.state = 'idle';
  }
  
  clearError() {
    if (this.state === 'error') {
      this.state = 'idle';
      this.error = null;
    }
  }
}

export const authStore = new AuthStore();

3. 옵저버 패턴

상태 변화를 구독하고 반응하는 패턴입니다.

// stores/eventStore.svelte.js
class EventStore {
  listeners = new Map();
  
  subscribe(event, callback) {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event).add(callback);
    
    // 구독 해제 함수 반환
    return () => {
      this.listeners.get(event)?.delete(callback);
    };
  }
  
  emit(event, data) {
    this.listeners.get(event)?.forEach(callback => {
      callback(data);
    });
  }
}

export const eventStore = new EventStore();

// 사용 예제
class NotificationStore {
  notifications = $state([]);
  
  constructor() {
    // 이벤트 구독
    eventStore.subscribe('user:login', (user) => {
      this.add(`Welcome back, ${user.name}!`, 'success');
    });
    
    eventStore.subscribe('api:error', (error) => {
      this.add(error.message, 'error');
    });
  }
  
  add(message, type = 'info') {
    const id = Date.now();
    this.notifications.push({ id, message, type });
    
    // 3초 후 자동 제거
    setTimeout(() => this.remove(id), 3000);
  }
  
  remove(id) {
    this.notifications = this.notifications.filter(n => n.id !== id);
  }
}

export const notificationStore = new NotificationStore();

성능 최적화 전략

1. $state.raw 활용

큰 데이터셋이나 자주 변경되지 않는 데이터에 사용합니다.

// stores/dataStore.svelte.js
class DataStore {
  // 큰 읽기 전용 데이터
  staticData = $state.raw({
    countries: [...], // 큰 배열
    categories: [...],
    constants: {...}
  });
  
  // 자주 변경되는 작은 상태
  currentPage = $state(1);
  filters = $state({});
  
  // 필터링된 데이터 (derived로 계산)
  filteredData = $derived.by(() => {
    return this.staticData.countries.filter(country => {
      // 복잡한 필터링 로직
      return Object.entries(this.filters).every(([key, value]) => {
        return !value || country[key] === value;
      });
    });
  });
}

2. 지연 로딩과 캐싱

// stores/lazyDataStore.svelte.js
class LazyDataStore {
  cache = $state(new Map());
  loading = $state(new Set());
  
  async getData(key) {
    // 캐시에서 확인
    if (this.cache.has(key)) {
      return this.cache.get(key);
    }
    
    // 이미 로딩 중인지 확인
    if (this.loading.has(key)) {
      // 로딩 완료까지 대기
      while (this.loading.has(key)) {
        await new Promise(resolve => setTimeout(resolve, 50));
      }
      return this.cache.get(key);
    }
    
    // 새로운 데이터 로딩
    this.loading.add(key);
    
    try {
      const data = await fetch(`/api/data/${key}`).then(r => r.json());
      this.cache.set(key, data);
      return data;
    } finally {
      this.loading.delete(key);
    }
  }
  
  invalidate(key) {
    this.cache.delete(key);
  }
  
  isLoading(key) {
    return this.loading.has(key);
  }
}

export const lazyDataStore = new LazyDataStore();

테스팅 전략

Runes 기반 상태를 테스트하는 방법입니다.

// stores/counterStore.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { CounterStore } from './counterStore.svelte.js';

describe('CounterStore', () => {
  let store;
  
  beforeEach(() => {
    store = new CounterStore();
  });
  
  it('should initialize with zero', () => {
    expect(store.count).toBe(0);
  });
  
  it('should increment count', () => {
    store.increment();
    expect(store.count).toBe(1);
  });
  
  it('should decrement count', () => {
    store.decrement();
    expect(store.count).toBe(-1);
  });
  
  it('should reset count', () => {
    store.increment();
    store.increment();
    store.reset();
    expect(store.count).toBe(0);
  });
});

디버깅 도구 활용

// stores/debuggableStore.svelte.js
class DebuggableStore {
  state = $state({
    user: null,
    posts: [],
    loading: false
  });
  
  constructor() {
    // 개발 환경에서만 디버깅 활성화
    if (import.meta.env.DEV) {
      $inspect(this.state).with((type, value) => {
        console.group(`🔍 Store ${type}`);
        console.log('State:', JSON.stringify(value, null, 2));
        console.trace('Stack trace');
        console.groupEnd();
      });
    }
  }
  
  updateUser(user) {
    this.state.user = user;
  }
  
  addPost(post) {
    this.state.posts.push(post);
  }
}

모범 사례 요약

  1. 명확한 소유권: 각 상태가 어느 컴포넌트나 스토어에 속하는지 명확히 정의
  2. 최소한의 전역 상태: 정말 필요한 경우에만 전역 상태 사용
  3. 단방향 데이터 흐름: 가능한 한 props와 이벤트로 데이터 전달
  4. 성능 고려: 큰 데이터나 자주 변경되지 않는 데이터는 $state.raw 사용
  5. 타입 안전성: TypeScript와 함께 사용하여 타입 안전성 확보
  6. 테스트 가능성: 상태 로직을 별도 클래스나 함수로 분리하여 테스트 용이성 확보

이러한 패턴들을 조합하여 복잡한 애플리케이션의 상태를 효과적으로 관리할 수 있습니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment