17.3 반응성 원리
Svelte의 핵심은 **반응성(Reactivity)**입니다. 상태가 변경되면 DOM이 자동으로 업데이트됩니다. Svelte 5는 Runes 시스템을 도입하여 이 반응성을 더 명확하고 강력하게 만들었습니다.
Svelte 4 반응성 (레거시)
Svelte 4에서는 단순한 변수 할당으로 반응성이 동작했습니다.
<!-- Svelte 4 방식 -->
<script>
let count = 0; // 변수 선언만으로 반응형
$: doubled = count * 2; // 반응형 선언 ($:)
$: {
// 반응형 블록 - count 변경 시 실행
console.log(`count가 ${count}로 변경됨`);
}
function increment() {
count++; // 할당으로 반응성 트리거
}
</script>
<p>Count: {count}, Doubled: {doubled}</p>
<button on:click={increment}>+1</button>
Svelte 4의 한계
let변수가 항상 반응형인지 아닌지 불명확 (컴포넌트 밖에서는 비반응형)$:문법이 JavaScript 표준이 아니라 직관적이지 않음- 배열/객체 변경 시 재할당이 필요한 경우가 있어 혼란스러움
.js파일에서는 사용 불가
Svelte 5 Runes 소개
Runes는 Svelte 5에서 도입된 함수처럼 생긴 특수 기호입니다. $로 시작하며, 컴파일러가 이를 특별하게 처리합니다.
<!-- Svelte 5 Runes 방식 -->
<script>
let count = $state(0); // 명시적 반응형 상태
let doubled = $derived(count * 2); // 명시적 파생 값
$effect(() => {
console.log(`count가 ${count}로 변경됨`);
});
</script>
<p>Count: {count}, Doubled: {doubled}</p>
<button onclick={() => count++}>+1</button>
$state() — 반응형 상태 선언
$state()는 가장 기본적인 Rune으로, 반응형 상태를 선언합니다.
기본 사용법
<script>
// 원시 타입
let count = $state(0);
let name = $state('홍길동');
let isActive = $state(false);
// 객체 (깊은 반응성)
let user = $state({
name: '홍길동',
age: 30,
address: {
city: '서울',
district: '강남구',
},
});
// 배열 (깊은 반응성)
let items = $state(['사과', '바나나', '체리']);
</script>
<!-- 객체 프로퍼티 직접 변경 가능 -->
<button onclick={() => user.age++}>나이 증가</button>
<button onclick={() => user.address.city = '부산'}>도시 변경</button>
<button onclick={() => items.push('포도')}>항목 추가</button>
깊은 반응성 (Deep Reactivity)
$state()로 선언된 객체/배열은 중첩된 프로퍼티 변경도 감지됩니다.
<script>
let todo = $state({
text: '장보기',
done: false,
subtasks: [
{ text: '우유 사기', done: false },
{ text: '빵 사기', done: false },
],
});
// 중첩된 프로퍼티 변경 → 자동 업데이트
function toggleSubtask(index) {
todo.subtasks[index].done = !todo.subtasks[index].done; // 가능!
}
</script>
클래스와 함께 사용
<script>
class Counter {
count = $state(0);
step = $state(1);
get doubled() {
return $derived(this.count * 2);
}
increment() {
this.count += this.step;
}
reset() {
this.count = 0;
}
}
const counter = new Counter();
</script>
<p>Count: {counter.count}</p>
<button onclick={() => counter.increment()}>+{counter.step}</button>
$derived() — 파생 값
$derived()는 다른 반응형 값에서 계산되는 값을 선언합니다. 의존하는 값이 바뀌면 자동으로 재계산됩니다.
기본 사용법
<script>
let price = $state(10000);
let quantity = $state(3);
let discountRate = $state(0.1);
// 단순 파생
let total = $derived(price * quantity);
// 여러 값에 의존하는 파생
let discountedTotal = $derived(total * (1 - discountRate));
// 포맷팅 포함 파생
let formattedTotal = $derived(
new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' })
.format(discountedTotal)
);
</script>
<p>단가: {price}원</p>
<p>수량: {quantity}개</p>
<p>합계: {total}원</p>
<p>할인 후: {formattedTotal}</p>
$derived.by() — 복잡한 파생 로직
여러 줄의 로직이 필요한 경우:
<script>
let numbers = $state([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
let threshold = $state(5);
let analysis = $derived.by(() => {
const filtered = numbers.filter(n => n > threshold);
const sum = filtered.reduce((a, b) => a + b, 0);
const avg = filtered.length ? sum / filtered.length : 0;
return { filtered, sum, avg: avg.toFixed(2) };
});
</script>
<input type="range" min="0" max="10" bind:value={threshold} />
<p>임계값: {threshold}</p>
<p>필터된 값: {analysis.filtered.join(', ')}</p>
<p>합계: {analysis.sum}, 평균: {analysis.avg}</p>
$effect() — 사이드 이펙트
$effect()는 반응형 상태가 변경될 때 실행될 사이드 이펙트를 정의합니다. React의 useEffect와 유사하지만, 의존성 배열 없이 자동으로 의존성을 추적합니다.
기본 사용법
<script>
let count = $state(0);
let title = $state('Svelte 앱');
// count나 title이 변경될 때마다 실행
$effect(() => {
document.title = `${title} - 카운트: ${count}`;
});
</script>
클린업 함수
<script>
let enabled = $state(false);
let position = $state({ x: 0, y: 0 });
$effect(() => {
if (!enabled) return;
function handleMouseMove(e) {
position.x = e.clientX;
position.y = e.clientY;
}
window.addEventListener('mousemove', handleMouseMove);
// 클린업 함수 반환 (effect가 재실행되기 전 또는 컴포넌트 제거 시 호출)
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
});
</script>
<label>
<input type="checkbox" bind:checked={enabled} />
마우스 추적 활성화
</label>
{#if enabled}
<p>위치: ({position.x}, {position.y})</p>
{/if}
$effect.pre() — DOM 업데이트 전 실행
<script>
import { tick } from 'svelte';
let messages = $state([]);
let chatContainer;
// DOM 업데이트 전 스크롤 위치 기억
$effect.pre(() => {
messages; // 의존성 추적
// DOM 업데이트 전 실행
});
// DOM 업데이트 후 스크롤 처리
$effect(() => {
if (chatContainer) {
chatContainer.scrollTop = chatContainer.scrollHeight;
}
});
</script>
$state.snapshot() — 스냅샷
반응형 객체를 일반 JavaScript 객체로 변환합니다 (직렬화, JSON 전송 등에 활용).
<script>
let form = $state({
username: '',
email: '',
password: '',
});
async function handleSubmit() {
// 반응형 프록시 대신 일반 객체 전송
const data = $state.snapshot(form);
console.log(data); // { username: '...', email: '...', password: '...' }
await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
}
</script>
배열/객체 반응성 처리
배열 변경
<script>
let items = $state(['사과', '바나나']);
// 모두 반응성 트리거됨
function examples() {
items.push('체리'); // 추가
items.pop(); // 제거
items.splice(1, 1, '딸기'); // 교체
items[0] = '수박'; // 인덱스 직접 변경
items = [...items, '포도']; // 재할당도 가능
}
</script>
Svelte 4 배열 업데이트 주의사항 (비교)
<!-- Svelte 4에서는 재할당 필요했음 -->
<script>
let items = ['사과', '바나나'];
function addItem() {
items.push('체리');
items = items; // 재할당으로 반응성 강제 트리거 (필요했음)
}
</script>
<!-- Svelte 5에서는 재할당 불필요 -->
<script>
let items = $state(['사과', '바나나']);
function addItem() {
items.push('체리'); // 자동으로 반응성 트리거됨
}
</script>
.svelte.js 파일에서 Runes 사용
Runes는 .svelte 파일 밖에서도 사용할 수 있습니다. .svelte.js 또는 .svelte.ts 확장자를 사용합니다.
// src/lib/counter.svelte.js
export class Counter {
#count = $state(0);
#step;
constructor(step = 1) {
this.#step = step;
}
get count() {
return this.#count;
}
increment() {
this.#count += this.#step;
}
reset() {
this.#count = 0;
}
}
<!-- 컴포넌트에서 사용 -->
<script>
import { Counter } from '$lib/counter.svelte.js';
const counter = new Counter(2);
</script>
<button onclick={() => counter.increment()}>+{counter.count}</button>
실전 예제 1: 반응형 카운터
<!-- ReactiveCounter.svelte -->
<script>
let count = $state(0);
let step = $state(1);
let history = $state([]);
let isNegative = $derived(count < 0);
let absCount = $derived(Math.abs(count));
let historyText = $derived(
history.slice(-5).reverse().join(' → ') || '없음'
);
$effect(() => {
// count가 변경될 때마다 히스토리 기록
if (history.length > 0) {
// 처음 마운트 시에는 기록 안 함
}
});
function change(delta) {
history.push(count);
count += delta;
}
function reset() {
history.push(count);
count = 0;
}
</script>
<div class="counter" class:negative={isNegative}>
<h2 class:text-red={isNegative}>
{isNegative ? '-' : ''}{absCount}
</h2>
<div class="controls">
<button onclick={() => change(-step)}>-{step}</button>
<input type="number" bind:value={step} min="1" max="10" />
<button onclick={() => change(step)}>+{step}</button>
</div>
<button onclick={reset}>초기화</button>
<p class="history">최근 기록: {historyText}</p>
</div>
<style>
.counter {
text-align: center;
padding: 2rem;
border: 2px solid #4caf50;
border-radius: 12px;
max-width: 300px;
margin: 0 auto;
}
.counter.negative {
border-color: #f44336;
}
h2 {
font-size: 3rem;
margin: 0;
}
.text-red {
color: #f44336;
}
.controls {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: center;
margin: 1rem 0;
}
input[type="number"] {
width: 60px;
text-align: center;
padding: 0.25rem;
}
.history {
font-size: 0.85rem;
color: #666;
}
</style>
실전 예제 2: 반응형 장바구니
<!-- ShoppingCart.svelte -->
<script>
const PRODUCTS = [
{ id: 1, name: '노트북', price: 1200000 },
{ id: 2, name: '마우스', price: 35000 },
{ id: 3, name: '키보드', price: 89000 },
{ id: 4, name: '모니터', price: 350000 },
];
let cart = $state([]);
let discountCode = $state('');
const DISCOUNT_CODES = { 'SVELTE10': 0.10, 'SAVE20': 0.20 };
let validDiscount = $derived(DISCOUNT_CODES[discountCode] ?? 0);
let cartTotal = $derived(
cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
let discountAmount = $derived(cartTotal * validDiscount);
let finalTotal = $derived(cartTotal - discountAmount);
let itemCount = $derived(cart.reduce((sum, item) => sum + item.quantity, 0));
$effect(() => {
// 장바구니가 변경될 때마다 localStorage 저장
localStorage.setItem('cart', JSON.stringify($state.snapshot(cart)));
});
function addToCart(product) {
const existing = cart.find(item => item.id === product.id);
if (existing) {
existing.quantity++;
} else {
cart.push({ ...product, quantity: 1 });
}
}
function removeFromCart(id) {
const index = cart.findIndex(item => item.id === id);
if (index !== -1) cart.splice(index, 1);
}
function updateQuantity(id, quantity) {
const item = cart.find(item => item.id === id);
if (item) {
if (quantity <= 0) removeFromCart(id);
else item.quantity = quantity;
}
}
const formatPrice = (n) =>
new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(n);
</script>
<div class="shop">
<section class="products">
<h2>상품 목록</h2>
{#each PRODUCTS as product}
<div class="product-card">
<span>{product.name}</span>
<span>{formatPrice(product.price)}</span>
<button onclick={() => addToCart(product)}>장바구니 추가</button>
</div>
{/each}
</section>
<section class="cart">
<h2>장바구니 ({itemCount}개)</h2>
{#if cart.length === 0}
<p>장바구니가 비었습니다.</p>
{:else}
{#each cart as item (item.id)}
<div class="cart-item">
<span>{item.name}</span>
<div class="qty-control">
<button onclick={() => updateQuantity(item.id, item.quantity - 1)}>-</button>
<span>{item.quantity}</span>
<button onclick={() => updateQuantity(item.id, item.quantity + 1)}>+</button>
</div>
<span>{formatPrice(item.price * item.quantity)}</span>
<button onclick={() => removeFromCart(item.id)}>×</button>
</div>
{/each}
<div class="discount">
<input
bind:value={discountCode}
placeholder="할인 코드 입력"
/>
{#if validDiscount > 0}
<span class="valid">✓ {validDiscount * 100}% 할인 적용!</span>
{/if}
</div>
<div class="totals">
<p>소계: {formatPrice(cartTotal)}</p>
{#if discountAmount > 0}
<p class="discount-line">할인: -{formatPrice(discountAmount)}</p>
{/if}
<p class="final"><strong>최종: {formatPrice(finalTotal)}</strong></p>
</div>
{/if}
</section>
</div>
<style>
.shop {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
padding: 1rem;
max-width: 800px;
margin: 0 auto;
}
h2 { margin-top: 0; }
.product-card, .cart-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-bottom: 1px solid #eee;
}
.product-card span:first-child,
.cart-item span:first-child {
flex: 1;
}
.qty-control {
display: flex;
align-items: center;
gap: 0.25rem;
}
.discount {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
align-items: center;
}
.valid { color: green; font-size: 0.9rem; }
.discount-line { color: #e53e3e; }
.totals {
border-top: 2px solid #333;
margin-top: 1rem;
padding-top: 0.5rem;
}
.final { font-size: 1.1rem; }
</style>
Svelte 4 vs Svelte 5 비교표
| 기능 | Svelte 4 | Svelte 5 |
|---|---|---|
| 반응형 변수 | let x = 0 | let x = $state(0) |
| 파생 값 | $: y = x * 2 | let y = $derived(x * 2) |
| 사이드 이펙트 | $: { console.log(x) } | $effect(() => { console.log(x) }) |
| Props | export let name | let { name } = $props() |
| 이벤트 핸들러 | on:click={handler} | onclick={handler} |
| 배열 반응성 | push(); arr = arr; | push() 직접 가능 |
| 외부 파일 | 사용 불가 | .svelte.js에서 사용 가능 |
고수 팁
팁 1: 과도한 $effect 지양
<!-- 나쁜 예: $effect로 파생 값 계산 -->
<script>
let count = $state(0);
let doubled = $state(0);
$effect(() => {
doubled = count * 2; // 비권장
});
</script>
<!-- 좋은 예: $derived 사용 -->
<script>
let count = $state(0);
let doubled = $derived(count * 2); // 권장
</script>
팁 2: 상태 초기화 패턴
<script>
// 초기값을 함수로 계산할 때
let items = $state(loadFromStorage());
function loadFromStorage() {
try {
return JSON.parse(localStorage.getItem('items') ?? '[]');
} catch {
return [];
}
}
</script>
팁 3: 반응성 디버깅
<script>
let count = $state(0);
$effect(() => {
// 개발 중 반응성 추적
console.log('[effect] count =', count);
});
</script>
정리
| Rune | 역할 | 사용 시기 |
|---|---|---|
$state() | 반응형 상태 선언 | 변경 가능한 값 |
$derived() | 파생 값 계산 | 다른 상태에서 계산되는 값 |
$derived.by() | 복잡한 파생 로직 | 여러 줄 계산이 필요할 때 |
$effect() | 사이드 이펙트 | DOM 외부 작업, API 호출 등 |
$state.snapshot() | 스냅샷 생성 | 직렬화, JSON 전송 등 |
다음 장에서는 Svelte의 템플릿 문법(로직 블록, 바인딩, 이벤트)을 살펴봅니다.