본문으로 건너뛰기

4. Rune 시스템

Svelte 5는 완전히 새로운 반응성 시스템인 Rune을 도입하여 보다 명확하고 강력한 상태 관리를 제공합니다. 기존의 마법적인 반응성에서 벗어나 신호 기반의 명시적 반응성으로 변경되어 성능과 개발 경험이 크게 향상되었습니다. 이 장에서는 Rune 시스템의 핵심 개념과 실무에서 활용하는 방법을 완전히 마스터해보겠습니다.


4.1 Rune 시스템 소개

Svelte 4에서 5로의 변화

Svelte 5는 반응성 시스템을 근본적으로 재설계했습니다. 기존의 컴파일 타임 반응성에서 신호 기반의 런타임 반응성으로 변경하여 더 정확하고 효율적인 업데이트를 제공합니다. Rune 시스템을 통해 .svelte 파일 밖에서도 반응성을 사용할 수 있는 "Universal Reactivity"를 달성했습니다.

주요 변화 비교

특징Svelte 4Svelte 5
상태 선언let count = 0let count = $state(0)
계산된 값$: doubled = count * 2let doubled = $derived(...)
부수 효과$: console.log(count)$effect(() => ...)
Props 받기export let namelet { name } = $props()
반응성 범위.svelte 파일 내부만어디서나 (.js/.ts 파일 포함)
의존성 추적컴파일 타임런타임

호환성과 마이그레이션

Svelte 5는 기존 Svelte 4 코드와 완전히 호환됩니다. 기존 문법은 "Legacy Mode"로 계속 지원되며, Rune을 사용하면 "Runes Mode"로 전환됩니다. 점진적 마이그레이션이 가능하여 컴포넌트별로 개별적으로 Rune을 도입할 수 있습니다.

<!-- Svelte 4 방식 (Legacy Mode) - 여전히 작동 -->
<script>
let count = 0;
$: doubled = count * 2;

export let name;
</script>

<button on:click="{()" ="">
count++}> {name}: {count} (두배: {doubled})
</button>

<!-- Svelte 5 방식 (Runes Mode) -->
<script>
let count = $state(0);
let doubled = $derived(count * 2);

let { name } = $props();
</script>

<button onclick="{()" ="">
count++}> {name}: {count} (두배: {doubled})
</button>

신호 기반 반응성 시스템

Signals의 개념

Signals는 KnockoutJS에서 시작된 반응성 패턴으로, Solid.js, Preact, Angular 등 많은 프레임워크가 채택한 현대적 상태 관리 방식입니다. Svelte 5에서는 Signal을 내부 구현 세부사항으로 숨겨서 개발자가 직접 다루지 않고도 그 이점을 누릴 수 있습니다. 이를 통해 세밀한 DOM 업데이트와 뛰어난 성능을 달성할 수 있습니다.

Fine-grained Reactivity

기존 Svelte 4는 컴포넌트 단위로 변경사항을 추적했지만, Svelte 5는 개별 변수 단위로 정확히 추적합니다. 큰 리스트에서 하나의 아이템만 변경되어도 해당 아이템만 업데이트하여 성능이 크게 향상됩니다. 가상 DOM을 사용하지 않으면서도 React보다 훨씬 효율적인 업데이트가 가능합니다.

<script>
let items = $state([
{ id: 1, name: 'Apple', count: 5 },
{ id: 2, name: 'Banana', count: 3 },
{ id: 3, name: 'Cherry', count: 8 },
]);

function updateItem(id) {
const item = items.find(item => item.id === id);
if (item) {
item.count += 1; // 이 아이템만 정확히 업데이트됩니다
}
}
</script>

{#each items as item (item.id)}
<div>
{item.name}: {item.count}
<button onclick="{()" ="">updateItem(item.id)}>+1</button>
</div>
{/each}

Universal Reactivity의 장점

// counter.js - 일반 JavaScript 파일에서도 반응성 사용 가능
export function createCounter(initial = 0) {
let count = $state(initial);
let doubled = $derived(count * 2);

return {
get count() {
return count;
},
get doubled() {
return doubled;
},
increment: () => count++,
reset: () => (count = 0),
};
}
Counter.svelte
<!-- Counter.svelte -->
<script>
import { createCounter } from './counter.js';

const counter = createCounter(10);
</script>

<div>
<p>Count: {counter.count}</p>
<p>Doubled: {counter.doubled}</p>
<button onclick="{counter.increment}">+1</button>
<button onclick="{counter.reset}">Reset</button>
</div>

실습해보기: Svelte REPL에서 이 코드를 직접 실행해보세요!


4.2 $state Rune

반응형 상태 선언

$state Rune은 반응형 상태를 명시적으로 선언하는 핵심 도구입니다. 기존의 마법적인 let 변수와 달리 명확하게 반응성을 표시하여 코드의 의도를 분명히 합니다. 원시 값, 객체, 배열 모든 타입에서 사용할 수 있으며 깊은 중첩 구조도 자동으로 반응형이 됩니다.

기본 사용법

<script>
// 원시 값
let count = $state(0);
let message = $state('Hello Svelte 5');
let isVisible = $state(true);

// 기본값 없이 선언 (undefined로 초기화)
let username = $state();

function handleClick() {
count += 1;
message = `클릭 횟수: ${count}`;
isVisible = !isVisible;
}
</script>

<button onclick="{handleClick}">클릭</button>

{#if isVisible}
<p>{message}</p>
{/if}

타입 안전성 (TypeScript)

<script lang="ts">
// 타입 명시적 선언
let count: number = $state(0);
let user: User | null = $state(null);

interface User {
id: number;
name: string;
email: string;
}

// 제네릭 타입 사용
let items: string[] = $state([]);
let data: Record<string, any> = $state({});
</script>

객체와 배열 상태 관리

$state로 선언된 객체나 배열은 모든 속성이 자동으로 반응형이 됩니다. 중첩된 객체의 깊은 속성을 변경해도 UI가 즉시 업데이트되며, 배열의 메서드들도 반응성을 유지합니다. 이는 Svelte 4에서 필요했던 복잡한 불변성 패턴을 대부분 제거해줍니다.

객체 상태 관리

<script>
let user = $state({
profile: {
name: '김개발',
age: 28,
skills: ['JavaScript', 'Svelte', 'TypeScript'],
},
settings: {
theme: 'dark',
notifications: true,
},
});

function updateProfile(key, value) {
user.profile[key] = value; // 직접 수정 가능
}

function addSkill() {
user.profile.skills.push('React'); // 배열 직접 수정
}

function toggleTheme() {
user.settings.theme =
user.settings.theme === 'dark' ? 'light' : 'dark';
}
</script>

<div class="user-profile">
<h2>{user.profile.name}</h2>
<p>나이: {user.profile.age}세</p>

<div>
<h3>스킬</h3>
<ul>
{#each user.profile.skills as skill}
<li>{skill}</li>
{/each}
</ul>
<button onclick="{addSkill}">스킬 추가</button>
</div>

<div>
<p>테마: {user.settings.theme}</p>
<button onclick="{toggleTheme}">테마 변경</button>
</div>
</div>

배열 상태 관리

<script>
let todos = $state([
{ id: 1, text: 'Svelte 5 학습하기', completed: false },
{
id: 2,
text: 'Rune 시스템 이해하기',
completed: true,
},
]);

let nextId = $state(3);
let newTodoText = $state('');

function addTodo() {
if (newTodoText.trim()) {
todos.push({
id: nextId++,
text: newTodoText,
completed: false,
});
newTodoText = '';
}
}

function toggleTodo(id) {
const todo = todos.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed;
}
}

function removeTodo(id) {
const index = todos.findIndex(t => t.id === id);
if (index !== -1) {
todos.splice(index, 1); // 배열에서 제거
}
}
</script>

<div class="todo-app">
<form onsubmit|preventDefault="{addTodo}">
<input
bind:value="{newTodoText}"
placeholder="새 할 일 입력"
required
/>
<button type="submit">추가</button>
</form>

<ul>
{#each todos as todo (todo.id)}
<li class:completed="{todo.completed}">
<label>
<input
type="checkbox"
checked="{todo.completed}"
onchange="{()"
=""
/>
toggleTodo(todo.id)} /> {todo.text}
</label>
<button onclick="{()" ="">
removeTodo(todo.id)}>삭제
</button>
</li>
{/each}
</ul>

<p>
총 {todos.length}개, 완료 {todos.filter(t =>
t.completed).length}개
</p>
</div>

<style>
.completed {
text-decoration: line-through;
opacity: 0.6;
}
</style>

중첩된 반응성

Svelte 5의 $state는 완전한 깊은 반응성을 제공합니다. 객체나 배열의 중첩 구조가 아무리 깊어도 모든 변경사항이 자동으로 추적되며 UI에 반영됩니다. 이는 복잡한 데이터 구조를 다룰 때 매우 강력한 기능입니다.

<script>
let appState = $state({
user: {
profile: {
personal: {
name: '홍길동',
age: 30,
},
contact: {
email: 'hong@example.com',
phones: [
{ type: 'mobile', number: '010-1234-5678' },
{ type: 'work', number: '02-9876-5432' },
],
},
},
preferences: {
theme: 'dark',
language: 'ko',
notifications: {
email: true,
push: false,
sms: true,
},
},
},
ui: {
modals: {
profile: { isOpen: false },
settings: { isOpen: false },
},
},
});

function updateNestedValue(path, value) {
// 깊은 중첩 경로에서도 직접 수정 가능
if (path === 'user.profile.personal.name') {
appState.user.profile.personal.name = value;
} else if (
path === 'user.preferences.notifications.email'
) {
appState.user.preferences.notifications.email = value;
}
}

function addPhone() {
appState.user.profile.contact.phones.push({
type: 'home',
number: '02-1111-2222',
});
}
</script>

<div>
<h2>{appState.user.profile.personal.name}</h2>
<p>나이: {appState.user.profile.personal.age}</p>

<h3>연락처</h3>
{#each appState.user.profile.contact.phones as phone}
<p>{phone.type}: {phone.number}</p>
{/each}

<button onclick="{addPhone}">전화번호 추가</button>

<h3>알림 설정</h3>
<label>
<input
type="checkbox"
bind:checked="{appState.user.preferences.notifications.email}"
/>
이메일 알림
</label>
</div>

실습해보기: Svelte REPL에서 중첩된 상태 관리를 직접 실험해보세요!


4.3 $derived Rune

계산된 값 만들기

$derived Rune은 다른 상태에 의존하는 계산된 값을 생성합니다. 의존성이 변경될 때만 재계산되며, 메모이제이션이 내장되어 있어 성능이 우수합니다. 기존의 $: 반응형 구문보다 명확하고 예측 가능한 동작을 제공합니다.

기본 사용법

<script>
let firstName = $state('홍');
let lastName = $state('길동');
let age = $state(25);

// 단순 계산된 값
let fullName = $derived(firstName + lastName);
let isAdult = $derived(age >= 18);
let birthYear = $derived(new Date().getFullYear() - age);

// 조건부 계산
let ageGroup = $derived(
age < 13
? '어린이'
: age < 20
? '청소년'
: age < 65
? '성인'
: '시니어'
);

function updateAge(newAge) {
age = newAge;
}
</script>

<div>
<h2>{fullName}</h2>
<p>{age}세 ({birthYear}년생)</p>
<p>연령대: {ageGroup}</p>
<p>{isAdult ? '성인' : '미성년자'}</p>

<input
type="range"
min="0"
max="100"
value="{age}"
oninput="{(e)"
=""
/>
updateAge(Number(e.target.value))} />
</div>

복잡한 계산 로직

<script>
let products = $state([
{
id: 1,
name: '노트북',
price: 1500000,
quantity: 2,
category: 'electronics',
},
{
id: 2,
name: '마우스',
price: 50000,
quantity: 1,
category: 'electronics',
},
{
id: 3,
name: '책',
price: 20000,
quantity: 3,
category: 'books',
},
]);

let discountRate = $state(0.1); // 10% 할인
let selectedCategory = $state('all');

// 필터링된 상품
let filteredProducts = $derived(
selectedCategory === 'all'
? products
: products.filter(
p => p.category === selectedCategory
)
);

// 총 가격 계산
let totalPrice = $derived(
filteredProducts.reduce(
(sum, product) =>
sum + product.price * product.quantity,
0
)
);

// 할인된 가격
let discountedPrice = $derived(
totalPrice * (1 - discountRate)
);

// 절약된 금액
let savedAmount = $derived(totalPrice - discountedPrice);

// 통계 정보
let stats = $derived({
totalItems: filteredProducts.length,
totalQuantity: filteredProducts.reduce(
(sum, p) => sum + p.quantity,
0
),
avgPrice:
filteredProducts.length > 0
? totalPrice /
filteredProducts.reduce(
(sum, p) => sum + p.quantity,
0
)
: 0,
});
</script>

<div class="shopping-cart">
<h2>장바구니</h2>

<div class="filters">
<label>
카테고리:
<select bind:value="{selectedCategory}">
<option value="all">전체</option>
<option value="electronics">전자제품</option>
<option value="books">도서</option>
</select>
</label>

<label>
할인율:
<input
type="range"
min="0"
max="0.5"
step="0.05"
bind:value="{discountRate}"
/>
{(discountRate * 100).toFixed(0)}%
</label>
</div>

<div class="products">
{#each filteredProducts as product (product.id)}
<div class="product">
<h3>{product.name}</h3>
<p>
가격: {product.price.toLocaleString()}원 ×
{product.quantity}
</p>
</div>
{/each}
</div>

<div class="summary">
<p>
상품 {stats.totalItems}종, 총 {stats.totalQuantity}개
</p>
<p>평균 단가: {stats.avgPrice.toLocaleString()}원</p>
<p>정가: {totalPrice.toLocaleString()}원</p>
<p>
할인가:
<strong>{discountedPrice.toLocaleString()}원</strong>
</p>
<p>절약: {savedAmount.toLocaleString()}원</p>
</div>
</div>

의존성 자동 추적

$derived는 런타임에 의존성을 자동으로 추적하므로 개발자가 명시적으로 의존성을 선언할 필요가 없습니다. 함수 호출이나 복잡한 로직 내부에서도 사용된 모든 반응형 값을 정확히 추적합니다. 이는 기존 $: 구문의 컴파일 타임 의존성 추적보다 훨씬 정확하고 유연합니다.

함수와 함께 사용하기

<script>
let numbers = $state([1, 2, 3, 4, 5]);
let filter = $state('all'); // 'all', 'even', 'odd'

// 복잡한 로직을 함수로 분리
function processNumbers(nums, filterType) {
let filtered = nums;

if (filterType === 'even') {
filtered = nums.filter(n => n % 2 === 0);
} else if (filterType === 'odd') {
filtered = nums.filter(n => n % 2 !== 0);
}

return {
values: filtered,
sum: filtered.reduce((a, b) => a + b, 0),
average:
filtered.length > 0
? filtered.reduce((a, b) => a + b, 0) /
filtered.length
: 0,
count: filtered.length,
};
}

// 의존성 자동 추적 - numbers와 filter 변경 시 자동 재계산
let result = $derived(processNumbers(numbers, filter));

function addNumber() {
numbers.push(Math.floor(Math.random() * 10) + 1);
}

function removeNumber(index) {
numbers.splice(index, 1);
}
</script>

<div>
<h2>숫자 처리기</h2>

<div>
<button onclick="{addNumber}">랜덤 숫자 추가</button>
<select bind:value="{filter}">
<option value="all">전체</option>
<option value="even">짝수</option>
<option value="odd">홀수</option>
</select>
</div>

<div>
<h3>원본 숫자:</h3>
{#each numbers as number, index}
<span>
{number}
<button onclick="{()" ="">
removeNumber(index)}>×
</button>
</span>
{/each}
</div>

<div>
<h3>결과 ({filter}):</h3>
<p>값: {result.values.join(', ')}</p>
<p>개수: {result.count}</p>
<p>합계: {result.sum}</p>
<p>평균: {result.average.toFixed(2)}</p>
</div>
</div>

$derived.by() 사용법

복잡한 비동기 로직이나 조건부 계산이 필요할 때는 $derived.by() 함수 형태를 사용할 수 있습니다.

<script>
let searchTerm = $state('');
let searchType = $state('exact'); // 'exact', 'partial', 'regex'

let data = $state([
'apple',
'banana',
'cherry',
'date',
'elderberry',
'fig',
'grape',
'honeydew',
'kiwi',
'lemon',
]);

let searchResults = $derived.by(() => {
if (!searchTerm.trim()) return data;

const term = searchTerm.toLowerCase();

try {
switch (searchType) {
case 'exact':
return data.filter(
item => item.toLowerCase() === term
);

case 'partial':
return data.filter(item =>
item.toLowerCase().includes(term)
);

case 'regex':
const regex = new RegExp(term, 'i');
return data.filter(item => regex.test(item));

default:
return data;
}
} catch (error) {
// 정규식 오류 시 빈 배열 반환
console.warn('검색 오류:', error);
return [];
}
});
</script>

<div>
<input
bind:value="{searchTerm}"
placeholder="검색어 입력"
/>

<select bind:value="{searchType}">
<option value="exact">정확히 일치</option>
<option value="partial">부분 일치</option>
<option value="regex">정규식</option>
</select>

<p>결과: {searchResults.length}개</p>

<ul>
{#each searchResults as item}
<li>{item}</li>
{/each}
</ul>
</div>

실습해보기: Svelte REPL에서 복잡한 계산과 의존성 추적을 직접 실험해보세요!


4.4 $effect Rune

부수 효과란?

Svelte에서 부수효과(side effect)는 컴포넌트의 상태 변화나 라이프사이클 이벤트에 반응하여 실행되는 추가적인 작업을 의미합니다. 이는 함수형 프로그래밍의 순수 함수 개념과 대비되는 것으로, DOM 조작, API 호출, 로깅, 타이머 설정 등 "외부 세계"와 상호작용하는 모든 작업을 포함합니다.

부수효과는 다음과 같은 특징을 가집니다:

  1. 예측 가능한 타이밍: 특정 조건이나 시점에 실행되어야 함
  2. 의존성 추적: 특정 값의 변화에 반응해야 함
  3. 정리(cleanup) 필요성: 메모리 누수 방지를 위해 적절한 정리가 필요할 수 있음

부수 효과 처리

$effect Rune은 상태 변화에 따른 부수 효과를 처리하는 핵심 도구입니다. 컴포넌트 라이프사이클과 밀접하게 연관되어 있으며, 브라우저 환경에서만 실행되므로 SSR 안전성을 보장합니다. 기존의 $: 구문이나 onMount보다 더 정확하고 예측 가능한 동작을 제공합니다.

기본 사용법

<script>
let count = $state(0);
let message = $state('시작');

// 기본 effect - count가 변경될 때마다 실행
$effect(() => {
console.log('카운트가 변경됨:', count);
document.title = `카운트: ${count}`;
});

// 복잡한 조건부 effect
$effect(() => {
if (count > 10) {
message = '높은 숫자입니다!';
console.warn('카운트가 10을 초과했습니다');
} else if (count < 0) {
message = '음수입니다!';
console.warn('카운트가 음수입니다');
} else {
message = '정상 범위입니다';
}
});

function increment() {
count++;
}

function decrement() {
count--;
}
</script>

<div>
<h2>카운터: {count}</h2>
<p>상태: {message}</p>

<button onclick="{increment}">+1</button>
<button onclick="{decrement}">-1</button>
</div>

정리 함수 (Cleanup)

$effect에서 정리가 필요한 리소스(타이머, 이벤트 리스너, 구독 등)를 사용할 때는 정리 함수를 반환해야 합니다. 컴포넌트가 언마운트되거나 의존성이 변경될 때 자동으로 정리 함수가 호출됩니다.

<script>
let isTimerActive = $state(false);
let timeElapsed = $state(0);
let intervalDelay = $state(1000);

// 타이머 effect
$effect(() => {
if (!isTimerActive) return;

console.log('타이머 시작, 간격:', intervalDelay, 'ms');

const interval = setInterval(() => {
timeElapsed += intervalDelay;
}, intervalDelay);

// 정리 함수 반환
return () => {
console.log('타이머 정리됨');
clearInterval(interval);
};
});

// 윈도우 크기 감지 effect
let windowSize = $state({ width: 0, height: 0 });

$effect(() => {
function updateSize() {
windowSize.width = window.innerWidth;
windowSize.height = window.innerHeight;
}

// 초기값 설정
updateSize();

// 이벤트 리스너 등록
window.addEventListener('resize', updateSize);

// 정리 함수
return () => {
window.removeEventListener('resize', updateSize);
};
});

function toggleTimer() {
isTimerActive = !isTimerActive;
if (isTimerActive) {
timeElapsed = 0; // 타이머 리셋
}
}
</script>

<div>
<h2>타이머 예제</h2>

<div>
<p>경과 시간: {(timeElapsed / 1000).toFixed(1)}초</p>
<p>상태: {isTimerActive ? '실행 중' : '정지'}</p>

<label>
간격 (ms):
<input
type="number"
bind:value="{intervalDelay}"
min="100"
max="5000"
step="100"
/>
</label>

<button onclick="{toggleTimer}">
{isTimerActive ? '정지' : '시작'}
</button>
</div>

<div>
<h3>윈도우 크기</h3>
<p>{windowSize.width} × {windowSize.height}</p>
</div>
</div>

라이프사이클과의 관계

$effect는 Svelte의 전통적인 라이프사이클 훅들을 대부분 대체할 수 있습니다. 컴포넌트가 마운트된 후에만 실행되며, 컴포넌트가 언마운트되기 전에 자동으로 정리됩니다. 기존의 onMount, onDestroy, afterUpdate 등의 훅보다 더 선언적이고 직관적입니다.

라이프사이클 비교

Svelte 4Svelte 5 $effect용도
onMount(fn)$effect(() => { fn() })컴포넌트 마운트 시
onDestroy(fn)$effect(() => () => fn)컴포넌트 언마운트시
afterUpdate(fn)$effect(() => { fn() })상태 변경 후
beforeUpdate(fn)직접 대체 없음업데이트 전
<script>
let data = $state(null);
let loading = $state(true);
let error = $state(null);

// 컴포넌트 마운트 시 데이터 로딩 (onMount 대체)
$effect(() => {
console.log('컴포넌트가 마운트됨');
loadData();

// 컴포넌트 언마운트 시 정리 (onDestroy 대체)
return () => {
console.log('컴포넌트가 언마운트됨');
};
});

// 데이터 변경 감지 (afterUpdate 대체)
$effect(() => {
if (data) {
console.log('데이터가 업데이트됨:', data);
// 데이터 변경 후 처리 로직
}
});

async function loadData() {
try {
loading = true;
error = null;

const response = await fetch('/api/data');
if (!response.ok) throw new Error('데이터 로딩 실패');

data = await response.json();
} catch (err) {
error = err.message;
} finally {
loading = false;
}
}
</script>

<div>
{#if loading}
<p>로딩 중...</p>
{:else if error}
<p>오류: {error}</p>
{:else if data}
<div>
<h2>데이터 로드 완료</h2>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
{/if}

<button onclick="{loadData}">다시 로드</button>
</div>

$effect.pre와 $effect.root

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

$effect.pre는 DOM이 업데이트되기 전에 실행되는 특별한 effect입니다. DOM 변경 전 측정이나 애니메이션 준비 등에 사용됩니다.

<script>
let items = $state(['A', 'B', 'C']);
let measurements = $state([]);

// DOM 업데이트 전에 현재 위치 측정
$effect.pre(() => {
measurements = Array.from(
document.querySelectorAll('.item')
).map(el => ({
rect: el.getBoundingClientRect(),
id: el.textContent,
}));
});

// DOM 업데이트 후에 애니메이션 적용
$effect(() => {
// 새로운 위치와 이전 위치 비교하여 애니메이션 적용
const currentElements =
document.querySelectorAll('.item');
currentElements.forEach((el, index) => {
const oldMeasurement = measurements.find(
m => m.id === el.textContent
);
if (oldMeasurement) {
const currentRect = el.getBoundingClientRect();
const deltaY =
oldMeasurement.rect.top - currentRect.top;

if (deltaY !== 0) {
el.style.transform = `translateY(${deltaY}px)`;
el.style.transition = 'none';

requestAnimationFrame(() => {
el.style.transform = '';
el.style.transition = 'transform 0.3s ease';
});
}
}
});
});

function shuffle() {
items = [...items].sort(() => Math.random() - 0.5);
}
</script>

<div>
<button onclick="{shuffle}">섞기</button>

<div class="list">
{#each items as item (item)}
<div class="item">{item}</div>
{/each}
</div>
</div>

<style>
.item {
padding: 1rem;
margin: 0.5rem 0;
background: #f0f0f0;
border-radius: 4px;
}
</style>

$effect.root - 루트 레벨 effect

$effect.root는 컴포넌트 외부에서 effect를 생성할 때 사용하며, 수동으로 관리해야 합니다.

// utils.js
import { $effect } from 'svelte';

export function createGlobalLogger(state) {
return $effect.root(() => {
$effect(() => {
console.log('Global state changed:', state);
});
});
}

실습해보기: Svelte REPL에서 다양한 effect 패턴을 직접 실험해보세요!


4.5 $props Rune

컴포넌트 속성 받기

$props Rune은 부모 컴포넌트로부터 전달받은 속성들을 처리하는 새로운 방식입니다. 기존의 export let 구문보다 더 명확하고 JavaScript 표준 구조 분해 할당을 사용하여 직관적입니다. TypeScript와의 통합도 훨씬 개선되어 타입 안전성이 크게 향상되었습니다.

기본 사용법

Button.svelte
<!-- Button.svelte -->
<script>
// 기본값과 함께 props 받기
let {
variant = 'primary',
size = 'medium',
disabled = false,
onclick,
children,
} = $props();

const sizeClasses = {
small: 'px-3 py-1 text-sm',
medium: 'px-4 py-2 text-base',
large: 'px-6 py-3 text-lg',
};

const variantClasses = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white',
secondary: 'bg-gray-600 hover:bg-gray-700 text-white',
outline:
'border border-blue-600 text-blue-600 hover:bg-blue-50',
};
</script>

<button
class="rounded font-medium transition-colors {sizeClasses[size]} {variantClasses[variant]}"
{disabled}
{onclick}
>
{@render children()}
</button>
App.svelte
<!-- App.svelte -->
<script>
import Button from './Button.svelte';

let count = $state(0);

function handleClick() {
count++;
}
</script>

<div>
<p>클릭 횟수: {count}</p>

<button onclick="{handleClick}">기본 버튼</button>

<button
variant="secondary"
size="large"
onclick="{handleClick}"
>
큰 보조 버튼
</button>

<button variant="outline" size="small" disabled>
비활성 버튼
</button>
</div>

나머지 속성과 속성 전달

Card.svelte
<!-- Card.svelte -->
<script>
let { title, subtitle, image, children, ...restProps } =
$props();
</script>

<div class="card" {...restProps}>
{#if image}
<img src="{image}" alt="{title}" class="card-image" />
{/if}

<div class="card-content">
<h2 class="card-title">{title}</h2>
{#if subtitle}
<p class="card-subtitle">{subtitle}</p>
{/if}

<div class="card-body">{@render children()}</div>
</div>
</div>

<style>
.card {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
overflow: hidden;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.card-image {
width: 100%;
height: 200px;
object-fit: cover;
}

.card-content {
padding: 1.5rem;
}

.card-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}

.card-subtitle {
color: #6b7280;
margin-bottom: 1rem;
}
</style>

기본값 설정

$props에서는 JavaScript의 기본 매개변수 구문을 그대로 사용할 수 있습니다. 복잡한 기본값이나 계산된 기본값도 지원하며, 함수나 객체도 기본값으로 설정할 수 있습니다.

UserProfile.svelte
<!-- UserProfile.svelte -->
<script>
let {
user,
showEmail = true,
showPhone = false,
theme = 'light',
onEdit = () => console.log('편집 클릭'),
formatters = {
name: name => name.toUpperCase(),
phone: phone =>
phone.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3'),
},
permissions = {
canEdit: true,
canDelete: false,
canView: true,
},
} = $props();

// 계산된 기본값
let displayName = $derived(
formatters.name ? formatters.name(user.name) : user.name
);
</script>

<div class="profile {theme}">
<div class="profile-header">
<img
src="{user.avatar}"
alt="{user.name}"
class="avatar"
/>
<h2>{displayName}</h2>
</div>

<div class="profile-info">
{#if showEmail && user.email}
<p><strong>이메일:</strong> {user.email}</p>
{/if} {#if showPhone && user.phone}
<p>
<strong>전화:</strong> {formatters.phone(user.phone)}
</p>
{/if}
</div>

{#if permissions.canEdit}
<button onclick="{onEdit}" class="edit-button">
편집
</button>
{/if}
</div>

타입 안전성 (TypeScript)

TypeScript를 사용할 때 $props는 완전한 타입 안전성을 제공합니다. 인터페이스나 타입 정의를 통해 props의 구조를 명확히 정의할 수 있으며, 컴파일 타임에 타입 검증이 이루어집니다.

FormInput.svelte
<!-- FormInput.svelte -->
<script lang="ts">
interface Props {
label: string;
type?: 'text' | 'email' | 'password' | 'number';
value?: string | number;
placeholder?: string;
required?: boolean;
disabled?: boolean;
error?: string;
onchange?: (value: string | number) => void;
oninput?: (value: string | number) => void;
}

let {
label,
type = 'text',
value = '',
placeholder,
required = false,
disabled = false,
error,
onchange,
oninput,
}: Props = $props();

let inputValue = $state(value);

// value prop이 변경되면 inputValue도 업데이트
$effect(() => {
inputValue = value;
});

function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
const newValue =
type === 'number'
? Number(target.value)
: target.value;
inputValue = newValue;

if (oninput) {
oninput(newValue);
}
}

function handleChange(event: Event) {
const target = event.target as HTMLInputElement;
const newValue =
type === 'number'
? Number(target.value)
: target.value;

if (onchange) {
onchange(newValue);
}
}
</script>

<div class="form-group">
<label class="form-label" class:required> {label} </label>

<input
class="form-input"
class:error
{type}
{placeholder}
{required}
{disabled}
value="{inputValue}"
oninput="{handleInput}"
onchange="{handleChange}"
/>

{#if error}
<span class="error-message">{error}</span>
{/if}
</div>

<style>
.form-group {
margin-bottom: 1rem;
}

.form-label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
color: #374151;
}

.form-label.required::after {
content: ' *';
color: #ef4444;
}

.form-input {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
}

.form-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

.form-input.error {
border-color: #ef4444;
}

.error-message {
display: block;
margin-top: 0.25rem;
font-size: 0.875rem;
color: #ef4444;
}
</style>

사용 예시

RegisterForm.svelte
<!-- RegisterForm.svelte -->
<script lang="ts">
import FormInput from './FormInput.svelte';

let formData = $state({
name: '',
email: '',
password: '',
age: 0
});

let errors = $state({
name: '',
email: '',
password: ''
});

function handleNameChange(value: string | number) {
formData.name = String(value);
errors.name = formData.name.length < 2 ? '이름은 2글자 이상이어야 합니다' : '';
}

function handleEmailChange(value: string | number) {
formData.email = String(value);
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
errors.email = !emailRegex.test(formData.email) ? '유효한 이메일 주소를 입력하세요' : '';
}

function handlePasswordChange(value: string | number) {
formData.password = String(value);
errors.password = formData.password.length < 8 ? '비밀번호는 8자 이상이어야 합니다' : '';
}
</script>

<form>
<FormInput
label="이름"
type="text"
value={formData.name}
placeholder="이름을 입력하세요"
required
error={errors.name}
onchange={handleNameChange}
/>

<FormInput
label="이메일"
type="email"
value={formData.email}
placeholder="email@example.com"
required
error={errors.email}
onchange={handleEmailChange}
/>

<FormInput
label="비밀번호"
type="password"
value={formData.password}
required
error={errors.password}
onchange={handlePasswordChange}
/>

<FormInput
label="나이"
type="number"
value={formData.age}
onchange={(value) => formData.age = Number(value)}
/>
</form>

실습해보기: Svelte REPL에서 타입 안전한 props 시스템을 직접 구현해보세요!


정리

Svelte 5의 Rune 시스템을 완전히 마스터했습니다! 이제 다음과 같은 핵심 개념들을 이해했습니다:

핵심 요약

  • $state: 명시적 반응형 상태 선언으로 깊은 반응성과 Universal Reactivity 제공
  • $derived: 자동 의존성 추적과 메모이제이션을 통한 효율적인 계산된 값
  • $effect: 부수 효과 처리와 정리 함수를 통한 안전한 리소스 관리
  • $props: 타입 안전하고 직관적인 컴포넌트 속성 관리

실무 활용 팁

  • Rune은 선택적으로 도입 가능하며 기존 코드와 완전 호환
  • 복잡한 상태 관리나 공유 로직에서 Rune의 진가 발휘
  • TypeScript와 함께 사용할 때 최대한의 타입 안전성 확보

다음 단계: 5장 "이벤트 처리"에서는 Svelte 5에서 개선된 이벤트 시스템과 폼 처리 방법을 알아보겠습니다. DOM 이벤트부터 커스텀 이벤트까지 모든 상호작용을 마스터해보세요!