본문으로 건너뛰기

10. 스토어와 상태 관리

컴포넌트 간 상태 공유는 현대적인 웹 애플리케이션 개발의 핵심 과제입니다. Svelte의 스토어 시스템은 반응형 상태를 컴포넌트 외부에서 관리할 수 있게 하여, 복잡한 상태 관리도 간단하고 직관적으로 처리할 수 있습니다. 이 장에서는 Svelte의 내장 스토어부터 커스텀 스토어, 그리고 대규모 애플리케이션을 위한 고급 상태 관리 패턴까지 완전히 마스터해보겠습니다.


10.1 Svelte 스토어 기초

writable 스토어

writable 스토어는 읽기와 쓰기가 모두 가능한 가장 기본적인 스토어입니다. 여러 컴포넌트에서 공유해야 하는 상태를 관리할 때 사용하며, Redux나 Vuex보다 훨씬 간단한 API를 제공합니다. .svelte 파일뿐만 아니라 일반 JavaScript 파일에서도 사용할 수 있어 상태 로직을 모듈화하기 쉽습니다.

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

// 기본 writable 스토어
export const count = writable(0);

// 초기값을 가진 스토어
export const user = writable({
name: '',
email: '',
isLoggedIn: false,
});

// 배열 스토어
export const todos = writable([]);

// 설정 스토어
export const settings = writable({
theme: 'light',
language: 'ko',
notifications: true,
});
Counter.svelte
<!-- Counter.svelte -->
<script>
import { count } from './stores.js';

// 스토어 값 직접 사용 (자동 구독)
function increment() {
count.update(n => n + 1);
}

function decrement() {
count.update(n => n - 1);
}

function reset() {
count.set(0);
}
</script>

<div class="counter">
<h2>카운터: {$count}</h2>
<button onclick="{increment}">+1</button>
<button onclick="{decrement}">-1</button>
<button onclick="{reset}">리셋</button>
</div>

<style>
.counter {
text-align: center;
padding: 2rem;
}

button {
margin: 0 0.5rem;
padding: 0.5rem 1rem;
}
</style>

스토어 메서드 활용

TodoList.svelte
<!-- TodoList.svelte -->
<script>
import { todos } from './stores.js';

let newTodo = '';

function addTodo() {
if (newTodo.trim()) {
todos.update(items => [
...items,
{
id: Date.now(),
text: newTodo,
completed: false,
},
]);
newTodo = '';
}
}

function toggleTodo(id) {
todos.update(items =>
items.map(item =>
item.id === id
? { ...item, completed: !item.completed }
: item
)
);
}

function removeTodo(id) {
todos.update(items =>
items.filter(item => item.id !== id)
);
}
</script>

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

<ul>
{#each $todos as todo (todo.id)}
<li class:completed="{todo.completed}">
<input
type="checkbox"
checked="{todo.completed}"
onchange="{()"
=""
/>
toggleTodo(todo.id)} />
<span>{todo.text}</span>
<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.5;
}
</style>

readable 스토어

readable 스토어는 외부에서 값을 변경할 수 없는 읽기 전용 스토어입니다. 시간, 위치 정보, WebSocket 연결 등 외부 소스에서 오는 데이터를 관리할 때 유용합니다. 생성 시 start 함수를 통해 초기화 로직을 정의하고, 정리 함수를 반환하여 리소스를 관리합니다.

readable-stores.js
// readable-stores.js
import { readable } from 'svelte/store';

// 현재 시간 스토어
export const time = readable(
new Date(),
function start(set) {
const interval = setInterval(() => {
set(new Date());
}, 1000);

return function stop() {
clearInterval(interval);
};
}
);

// 사용자 위치 스토어
export const location = readable(null, function start(set) {
let watchId;

if ('geolocation' in navigator) {
watchId = navigator.geolocation.watchPosition(
position => {
set({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
});
},
error => {
set({ error: error.message });
}
);
}

return function stop() {
if (watchId) {
navigator.geolocation.clearWatch(watchId);
}
};
});

// 온라인 상태 스토어
export const online = readable(
navigator.onLine,
function start(set) {
function updateOnlineStatus() {
set(navigator.onLine);
}

window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);

return function stop() {
window.removeEventListener(
'online',
updateOnlineStatus
);
window.removeEventListener(
'offline',
updateOnlineStatus
);
};
}
);
Clock.svelte
<!-- Clock.svelte -->
<script>
import {
time,
online,
location,
} from './readable-stores.js';

function formatTime(date) {
return new Intl.DateTimeFormat('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(date);
}
</script>

<div class="status-panel">
<div class="clock">
<h3>현재 시간</h3>
<p>{formatTime($time)}</p>
</div>

<div class="network">
<h3>네트워크 상태</h3>
<p class:online="{$online}" class:offline="{!$online}">
{$online ? '온라인' : '오프라인'}
</p>
</div>

{#if $location}
<div class="location">
<h3>현재 위치</h3>
{#if $location.error}
<p>위치 오류: {$location.error}</p>
{:else}
<p>위도: {$location.latitude.toFixed(4)}</p>
<p>경도: {$location.longitude.toFixed(4)}</p>
{/if}
</div>
{/if}
</div>

<style>
.status-panel {
display: grid;
gap: 1rem;
}

.online {
color: #10b981;
}

.offline {
color: #ef4444;
}
</style>

derived 스토어

derived 스토어는 하나 이상의 다른 스토어로부터 계산된 값을 생성합니다. React의 useMemo나 Vue의 computed와 유사하지만, 컴포넌트 외부에서도 사용할 수 있습니다. 의존하는 스토어가 변경될 때만 자동으로 재계산되어 성능이 최적화됩니다.

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

// 기본 스토어들
export const price = writable(100);
export const quantity = writable(1);
export const taxRate = writable(0.1);

// 단일 스토어에서 파생
export const formattedPrice = derived(price, $price =>
new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW',
}).format($price)
);

// 여러 스토어에서 파생
export const subtotal = derived(
[price, quantity],
([$price, $quantity]) => $price * $quantity
);

// 복잡한 계산
export const total = derived(
[price, quantity, taxRate],
([$price, $quantity, $taxRate]) => {
const subtotal = $price * $quantity;
const tax = subtotal * $taxRate;
return {
subtotal,
tax,
total: subtotal + tax,
};
}
);

// 비동기 derived
export const exchangeRate = writable('USD');
export const convertedPrice = derived(
[price, exchangeRate],
async ([$price, $currency], set) => {
set({ loading: true });

try {
// API 호출 시뮬레이션
await new Promise(resolve =>
setTimeout(resolve, 500)
);

const rates = {
USD: 0.00075,
EUR: 0.00068,
JPY: 0.11,
};

set({
loading: false,
value: $price * (rates[$currency] || 1),
currency: $currency,
});
} catch (error) {
set({ loading: false, error: error.message });
}
},
{ loading: true }
);
PriceCalculator.svelte
<!-- PriceCalculator.svelte -->
<script>
import {
price,
quantity,
taxRate,
total,
convertedPrice,
exchangeRate,
} from './derived-stores.js';
</script>

<div class="calculator">
<h2>가격 계산기</h2>

<div class="input-group">
<label>
단가:
<input type="number" bind:value="{$price}" min="0" />
</label>

<label>
수량:
<input
type="number"
bind:value="{$quantity}"
min="1"
/>
</label>

<label>
세율:
<input
type="number"
bind:value="{$taxRate}"
min="0"
max="1"
step="0.01"
/>
</label>
</div>

<div class="results">
<p>소계: {$total.subtotal.toLocaleString()}원</p>
<p>세금: {$total.tax.toLocaleString()}원</p>
<p>
<strong
>총액: {$total.total.toLocaleString()}원</strong
>
</p>
</div>

<div class="currency">
<label>
환율 변환:
<select bind:value="{$exchangeRate}">
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="JPY">JPY</option>
</select>
</label>

{#if $convertedPrice.loading}
<p>변환 중...</p>
{:else if $convertedPrice.error}
<p>오류: {$convertedPrice.error}</p>
{:else}
<p>
{$convertedPrice.value.toFixed(2)}
{$convertedPrice.currency}
</p>
{/if}
</div>
</div>

<style>
.calculator {
max-width: 400px;
padding: 2rem;
}

.input-group {
display: grid;
gap: 1rem;
}

.results {
margin: 1.5rem 0;
padding: 1rem;
background: #f8fafc;
}
</style>

스토어 구독과 해제

스토어 구독은 `--- sidebar_position: 10 sidebar_label: 10. 스토어와 상태 관리


10. 스토어와 상태 관리

컴포넌트 간 상태 공유는 현대적인 웹 애플리케이션 개발의 핵심 과제입니다. Svelte의 스토어 시스템은 반응형 상태를 컴포넌트 외부에서 관리할 수 있게 하여, 복잡한 상태 관리도 간단하고 직관적으로 처리할 수 있습니다. 이 장에서는 Svelte의 내장 스토어부터 커스텀 스토어, 그리고 대규모 애플리케이션을 위한 고급 상태 관리 패턴까지 완전히 마스터해보겠습니다.


10.1 Svelte 스토어 기초

writable 스토어

writable 스토어는 읽기와 쓰기가 모두 가능한 가장 기본적인 스토어입니다. 여러 컴포넌트에서 공유해야 하는 상태를 관리할 때 사용하며, Redux나 Vuex보다 훨씬 간단한 API를 제공합니다. .svelte 파일뿐만 아니라 일반 JavaScript 파일에서도 사용할 수 있어 상태 로직을 모듈화하기 쉽습니다.

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

// 기본 writable 스토어
export const count = writable(0);

// 초기값을 가진 스토어
export const user = writable({
name: '',
email: '',
isLoggedIn: false,
});

// 배열 스토어
export const todos = writable([]);

// 설정 스토어
export const settings = writable({
theme: 'light',
language: 'ko',
notifications: true,
});
Counter.svelte
<!-- Counter.svelte -->
<script>
import { count } from './stores.js';

// 스토어 값 직접 사용 (자동 구독)
function increment() {
count.update(n => n + 1);
}

function decrement() {
count.update(n => n - 1);
}

function reset() {
count.set(0);
}
</script>

<div class="counter">
<h2>카운터: {$count}</h2>
<button onclick="{increment}">+1</button>
<button onclick="{decrement}">-1</button>
<button onclick="{reset}">리셋</button>
</div>

<style>
.counter {
text-align: center;
padding: 2rem;
}

button {
margin: 0 0.5rem;
padding: 0.5rem 1rem;
}
</style>

스토어 메서드 활용

TodoList.svelte
<!-- TodoList.svelte -->
<script>
import { todos } from './stores.js';

let newTodo = '';

function addTodo() {
if (newTodo.trim()) {
todos.update(items => [
...items,
{
id: Date.now(),
text: newTodo,
completed: false,
},
]);
newTodo = '';
}
}

function toggleTodo(id) {
todos.update(items =>
items.map(item =>
item.id === id
? { ...item, completed: !item.completed }
: item
)
);
}

function removeTodo(id) {
todos.update(items =>
items.filter(item => item.id !== id)
);
}
</script>

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

<ul>
{#each $todos as todo (todo.id)}
<li class:completed="{todo.completed}">
<input
type="checkbox"
checked="{todo.completed}"
onchange="{()"
=""
/>
toggleTodo(todo.id)} />
<span>{todo.text}</span>
<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.5;
}
</style>

readable 스토어

readable 스토어는 외부에서 값을 변경할 수 없는 읽기 전용 스토어입니다. 시간, 위치 정보, WebSocket 연결 등 외부 소스에서 오는 데이터를 관리할 때 유용합니다. 생성 시 start 함수를 통해 초기화 로직을 정의하고, 정리 함수를 반환하여 리소스를 관리합니다.

readable-stores.js
// readable-stores.js
import { readable } from 'svelte/store';

// 현재 시간 스토어
export const time = readable(
new Date(),
function start(set) {
const interval = setInterval(() => {
set(new Date());
}, 1000);

return function stop() {
clearInterval(interval);
};
}
);

// 사용자 위치 스토어
export const location = readable(null, function start(set) {
let watchId;

if ('geolocation' in navigator) {
watchId = navigator.geolocation.watchPosition(
position => {
set({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
});
},
error => {
set({ error: error.message });
}
);
}

return function stop() {
if (watchId) {
navigator.geolocation.clearWatch(watchId);
}
};
});

// 온라인 상태 스토어
export const online = readable(
navigator.onLine,
function start(set) {
function updateOnlineStatus() {
set(navigator.onLine);
}

window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);

return function stop() {
window.removeEventListener(
'online',
updateOnlineStatus
);
window.removeEventListener(
'offline',
updateOnlineStatus
);
};
}
);
Clock.svelte
<!-- Clock.svelte -->
<script>
import {
time,
online,
location,
} from './readable-stores.js';

function formatTime(date) {
return new Intl.DateTimeFormat('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(date);
}
</script>

<div class="status-panel">
<div class="clock">
<h3>현재 시간</h3>
<p>{formatTime($time)}</p>
</div>

<div class="network">
<h3>네트워크 상태</h3>
<p class:online="{$online}" class:offline="{!$online}">
{$online ? '온라인' : '오프라인'}
</p>
</div>

{#if $location}
<div class="location">
<h3>현재 위치</h3>
{#if $location.error}
<p>위치 오류: {$location.error}</p>
{:else}
<p>위도: {$location.latitude.toFixed(4)}</p>
<p>경도: {$location.longitude.toFixed(4)}</p>
{/if}
</div>
{/if}
</div>

<style>
.status-panel {
display: grid;
gap: 1rem;
}

.online {
color: #10b981;
}

.offline {
color: #ef4444;
}
</style>

derived 스토어

derived 스토어는 하나 이상의 다른 스토어로부터 계산된 값을 생성합니다. React의 useMemo나 Vue의 computed와 유사하지만, 컴포넌트 외부에서도 사용할 수 있습니다. 의존하는 스토어가 변경될 때만 자동으로 재계산되어 성능이 최적화됩니다.

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

// 기본 스토어들
export const price = writable(100);
export const quantity = writable(1);
export const taxRate = writable(0.1);

// 단일 스토어에서 파생
export const formattedPrice = derived(price, $price =>
new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW',
}).format($price)
);

// 여러 스토어에서 파생
export const subtotal = derived(
[price, quantity],
([$price, $quantity]) => $price * $quantity
);

// 복잡한 계산
export const total = derived(
[price, quantity, taxRate],
([$price, $quantity, $taxRate]) => {
const subtotal = $price * $quantity;
const tax = subtotal * $taxRate;
return {
subtotal,
tax,
total: subtotal + tax,
};
}
);

// 비동기 derived
export const exchangeRate = writable('USD');
export const convertedPrice = derived(
[price, exchangeRate],
async ([$price, $currency], set) => {
set({ loading: true });

try {
// API 호출 시뮬레이션
await new Promise(resolve =>
setTimeout(resolve, 500)
);

const rates = {
USD: 0.00075,
EUR: 0.00068,
JPY: 0.11,
};

set({
loading: false,
value: $price * (rates[$currency] || 1),
currency: $currency,
});
} catch (error) {
set({ loading: false, error: error.message });
}
},
{ loading: true }
);
PriceCalculator.svelte
<!-- PriceCalculator.svelte -->
<script>
import {
price,
quantity,
taxRate,
total,
convertedPrice,
exchangeRate,
} from './derived-stores.js';
</script>

<div class="calculator">
<h2>가격 계산기</h2>

<div class="input-group">
<label>
단가:
<input type="number" bind:value="{$price}" min="0" />
</label>

<label>
수량:
<input
type="number"
bind:value="{$quantity}"
min="1"
/>
</label>

<label>
세율:
<input
type="number"
bind:value="{$taxRate}"
min="0"
max="1"
step="0.01"
/>
</label>
</div>

<div class="results">
<p>소계: {$total.subtotal.toLocaleString()}원</p>
<p>세금: {$total.tax.toLocaleString()}원</p>
<p>
<strong
>총액: {$total.total.toLocaleString()}원</strong
>
</p>
</div>

<div class="currency">
<label>
환율 변환:
<select bind:value="{$exchangeRate}">
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="JPY">JPY</option>
</select>
</label>

{#if $convertedPrice.loading}
<p>변환 중...</p>
{:else if $convertedPrice.error}
<p>오류: {$convertedPrice.error}</p>
{:else}
<p>
{$convertedPrice.value.toFixed(2)}
{$convertedPrice.currency}
</p>
{/if}
</div>
</div>

<style>
.calculator {
max-width: 400px;
padding: 2rem;
}

.input-group {
display: grid;
gap: 1rem;
}

.results {
margin: 1.5rem 0;
padding: 1rem;
background: #f8fafc;
}
</style>

접두사를 사용한 자동 구독과 수동 구독 두 가지 방법이 있습니다. 자동 구독은 컴포넌트가 파괴될 때 자동으로 해제되지만, 수동 구독은 명시적으로 해제해야 메모리 누수를 방지할 수 있습니다. 일반 JavaScript 파일에서는 수동 구독만 사용할 수 있습니다.

Subscription.svelte
<!-- Subscription.svelte -->
<script>
import { onDestroy } from 'svelte';
import { writable } from 'svelte/store';

const counter = writable(0);
let manualValue = 0;

// 수동 구독
const unsubscribe = counter.subscribe(value => {
manualValue = value;
console.log('수동 구독 값:', value);
});

// 여러 구독 관리
const subscriptions = [];

const store1 = writable('A');
const store2 = writable('B');

subscriptions.push(
store1.subscribe(value => {
console.log('Store 1:', value);
}),
store2.subscribe(value => {
console.log('Store 2:', value);
})
);

// 정리 함수
onDestroy(() => {
unsubscribe();
subscriptions.forEach(unsub => unsub());
});

function increment() {
counter.update(n => n + 1);
}
</script>

<div>
<h3>자동 구독 vs 수동 구독</h3>

<p>자동 구독 ($ 접두사): {$counter}</p>
<p>수동 구독: {manualValue}</p>

< button onclick={increment}>증가</button>

<p>콘솔에서 구독 로그를 확인하세요</p>
</div>

Svelte 5 Rune과 스토어의 관계

Svelte 5는 Rune 시스템을 도입했지만, 스토어는 여전히 중요한 역할을 합니다. 두 시스템은 서로 보완적이며, 상황에 따라 적절히 선택하여 사용할 수 있습니다. 스토어는 SvelteKit에서 특히 중요하며, 앞으로도 계속 지원될 예정입니다.

Rune vs 스토어 비교

특징Rune ($state)스토어
사용 범위Svelte 5+모든 Svelte 버전
선언 위치컴포넌트 또는 .svelte.js어디서나 (.js, .ts)
반응성세밀한 반응성구독 기반 반응성
TypeScript완벽한 타입 추론제네릭 타입 지원
SvelteKit제한적 사용완전 지원
SSR제한적완전 지원

언제 무엇을 사용할까?

rune-vs-store.js
// Rune 사용 - 컴포넌트 내부 상태
// counter.svelte.js
export function createCounter() {
let count = $state(0);

return {
get count() {
return count;
},
increment: () => count++,
};
}

// 스토어 사용 - 전역 상태, SvelteKit
// auth-store.js
import { writable } from 'svelte/store';

export const auth = writable({
user: null,
isAuthenticated: false,
});

// SvelteKit load 함수에서
export async function load() {
return {
// 스토어는 직렬화 가능
authStore: auth,
// Rune은 직렬화 불가능
// counter: createCounter() // ❌ 에러
};
}

혼합 사용 패턴

HybridComponent.svelte
<!-- HybridComponent.svelte -->
<script>
import { userStore } from './stores.js';
import { fromStore, toStore } from 'svelte/store';

// 스토어를 Rune처럼 사용
const user = fromStore(userStore);

// 로컬 상태는 Rune 사용
let localCount = $state(0);

// 스토어와 Rune을 함께 사용
let fullName = $derived(
user.current
? `${user.current.firstName} ${user.current.lastName}`
: ''
);
</script>

<div>
<p>사용자: {fullName}</p>
<p>로컬 카운트: {localCount}</p>
<button onclick="{()" ="">localCount++}>증가</button>
</div>

마이그레이션 가이드라인

스토어에서 Rune으로 마이그레이션은 선택사항이며, 다음 경우에는 스토어를 유지하는 것이 좋습니다:

  1. SvelteKit 사용: load 함수, 서버 사이드 렌더링
  2. 레거시 코드: 기존 Svelte 3/4 프로젝트
  3. 외부 라이브러리: 스토어 기반 생태계
  4. 전역 상태: 여러 앱 인스턴스 간 공유
migration-example.js
// 기존 스토어 (계속 사용 가능)
import { writable, derived } from 'svelte/store';

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

// 새로운 Rune 방식 (선택적 마이그레이션)
// counter.svelte.js
let count = $state(0);
let doubled = $derived(count * 2);

export function getCounter() {
return {
get count() {
return count;
},
get doubled() {
return doubled;
},
increment: () => count++,
};
}

핵심 포인트: Svelte 5에서도 스토어는 완전히 지원되며, Rune과 함께 사용할 수 있습니다. 새 프로젝트에서는 Rune을 우선 고려하되, SvelteKit이나 전역 상태 관리가 필요한 경우 스토어를 사용하세요.


10.2 커스텀 스토어

비즈니스 로직을 가진 스토어

커스텀 스토어는 특정 도메인 로직을 캡슐화하여 재사용 가능한 상태 관리 모듈을 만들 수 있습니다. 스토어 컨트랙트(subscribe 메서드)를 구현하면 $ 접두사를 사용할 수 있습니다. 복잡한 비즈니스 로직과 상태 변경을 한 곳에서 관리하여 유지보수성을 높입니다.

auth-store.js
// auth-store.js
import { writable } from 'svelte/store';

function createAuthStore() {
const { subscribe, set, update } = writable({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
});

return {
subscribe,

async login(email, password) {
update(state => ({
...state,
isLoading: true,
error: null,
}));

try {
// API 호출 시뮬레이션
await new Promise(resolve =>
setTimeout(resolve, 1000)
);

if (
email === 'admin@example.com' &&
password === 'admin'
) {
const user = {
id: 1,
email,
name: '관리자',
role: 'admin',
};

set({
user,
isAuthenticated: true,
isLoading: false,
error: null,
});

// 토큰 저장 (실제 구현에서는 secure storage 사용)
localStorage.setItem(
'authToken',
'fake-jwt-token'
);

return user;
} else {
throw new Error('잘못된 인증 정보');
}
} catch (error) {
set({
user: null,
isAuthenticated: false,
isLoading: false,
error: error.message,
});
throw error;
}
},

logout() {
localStorage.removeItem('authToken');
set({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
});
},

async checkAuth() {
const token = localStorage.getItem('authToken');

if (token) {
update(state => ({ ...state, isLoading: true }));

try {
// 토큰 검증 API 호출 시뮬레이션
await new Promise(resolve =>
setTimeout(resolve, 500)
);

set({
user: {
id: 1,
email: 'admin@example.com',
name: '관리자',
},
isAuthenticated: true,
isLoading: false,
error: null,
});
} catch {
this.logout();
}
}
},
};
}

export const auth = createAuthStore();
LoginForm.svelte
<!-- LoginForm.svelte -->
<script>
import { auth } from './auth-store.js';
import { onMount } from 'svelte';

let email = '';
let password = '';

onMount(() => {
auth.checkAuth();
});

async function handleLogin(event) {
event.preventDefault();

try {
await auth.login(email, password);
alert('로그인 성공!');
} catch (error) {
// 에러는 스토어에서 처리됨
}
}
</script>

<div class="auth-container">
{#if $auth.isLoading}
<p>로딩 중...</p>
{:else if $auth.isAuthenticated}
<div class="profile">
<h2>환영합니다, {$auth.user.name}님!</h2>
<p>{$auth.user.email}</p>
<button onclick="{auth.logout}">로그아웃</button>
</div>
{:else}
<form onsubmit="{handleLogin}">
<h2>로그인</h2>

{#if $auth.error}
<p class="error">{$auth.error}</p>
{/if}

<input
type="email"
bind:value="{email}"
placeholder="이메일"
required
/>

<input
type="password"
bind:value="{password}"
placeholder="비밀번호"
required
/>

<button type="submit">로그인</button>

<p class="hint">테스트: admin@example.com / admin</p>
</form>
{/if}
</div>

<style>
.auth-container {
max-width: 400px;
padding: 2rem;
}

.error {
color: #ef4444;
background: #fef2f2;
padding: 0.5rem;
}

.hint {
font-size: 0.875rem;
color: #6b7280;
}
</style>

스토어 패턴과 모범 사례

효과적인 스토어 설계를 위한 패턴과 모범 사례를 적용하면 확장 가능하고 유지보수가 쉬운 상태 관리 시스템을 구축할 수 있습니다. 도메인별로 스토어를 분리하고, 명확한 API를 제공하며, 불변성을 유지하는 것이 중요합니다. TypeScript를 사용하면 타입 안전성까지 확보할 수 있습니다.

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

function createCartStore() {
const items = writable([]);

const total = derived(items, $items =>
$items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
);

const count = derived(items, $items =>
$items.reduce((sum, item) => sum + item.quantity, 0)
);

return {
subscribe: items.subscribe,
total: { subscribe: total.subscribe },
count: { subscribe: count.subscribe },

addItem(product) {
items.update(currentItems => {
const existingItem = currentItems.find(
item => item.id === product.id
);

if (existingItem) {
return currentItems.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}

return [
...currentItems,
{ ...product, quantity: 1 },
];
});
},

removeItem(productId) {
items.update(currentItems =>
currentItems.filter(item => item.id !== productId)
);
},

updateQuantity(productId, quantity) {
if (quantity <= 0) {
this.removeItem(productId);
return;
}

items.update(currentItems =>
currentItems.map(item =>
item.id === productId
? { ...item, quantity }
: item
)
);
},

clear() {
items.set([]);
},

getSnapshot() {
return get(items);
},
};
}

export const cart = createCartStore();

// 로컬 스토리지 동기화
cart.subscribe(items => {
if (typeof window !== 'undefined') {
localStorage.setItem('cart', JSON.stringify(items));
}
});
ShoppingCart.svelte
<!-- ShoppingCart.svelte -->
<script>
import { cart } from './cart-store.js';

const products = [
{ id: 1, name: '노트북', price: 1500000 },
{ id: 2, name: '마우스', price: 50000 },
{ id: 3, name: '키보드', price: 150000 },
];
</script>

<div class="shop">
<div class="products">
<h2>상품 목록</h2>
{#each products as product}
<div class="product-card">
<h3>{product.name}</h3>
<p>{product.price.toLocaleString()}원</p>
<button onclick="{()" ="">
cart.addItem(product)}> 장바구니 추가
</button>
</div>
{/each}
</div>

<div class="cart">
<h2>장바구니 ({$cart.count})</h2>

{#if $cart.length === 0}
<p>장바구니가 비어있습니다</p>
{:else} {#each $cart as item}
<div class="cart-item">
<span>{item.name}</span>
<input
type="number"
value="{item.quantity}"
min="0"
onchange="{e"
=""
/>
cart.updateQuantity(item.id, +e.target.value)} />
<span
>{(item.price *
item.quantity).toLocaleString()}원</span
>
<button onclick="{()" ="">
cart.removeItem(item.id)}> 삭제
</button>
</div>
{/each}

<div class="total">
<strong
>총액: {$cart.total.toLocaleString()}원</strong
>
<button onclick="{cart.clear}">비우기</button>
</div>
{/if}
</div>
</div>

<style>
.shop {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}

.product-card,
.cart-item {
padding: 1rem;
border: 1px solid #e5e7eb;
margin-bottom: 0.5rem;
}

.total {
margin-top: 1rem;
padding-top: 1rem;
border-top: 2px solid #e5e7eb;
}
</style>

10.3 복잡한 상태 관리

여러 스토어 조합

복잡한 애플리케이션에서는 여러 스토어를 조합하여 사용해야 합니다. 스토어 간의 의존성을 관리하고, 동기화를 유지하며, 일관된 상태를 보장하는 것이 중요합니다. 이벤트 기반 통신이나 미들웨어 패턴을 활용하여 스토어 간 상호작용을 구현할 수 있습니다.

app-stores.js
// app-stores.js
import { writable, derived, get } from 'svelte/store';

// 사용자 스토어
export const user = writable(null);

// 테마 스토어
export const theme = writable('light');

// 언어 스토어
export const locale = writable('ko');

// 알림 스토어
function createNotificationStore() {
const { subscribe, update } = writable([]);

return {
subscribe,

add(message, type = 'info', duration = 3000) {
const id = Date.now();
const notification = { id, message, type };

update(n => [...n, notification]);

if (duration > 0) {
setTimeout(() => this.remove(id), duration);
}

return id;
},

remove(id) {
update(n =>
n.filter(notification => notification.id !== id)
);
},

clear() {
update(() => []);
},
};
}

export const notifications = createNotificationStore();

// 앱 상태 스토어 (여러 스토어 조합)
export const appState = derived(
[user, theme, locale],
([$user, $theme, $locale]) => ({
isAuthenticated: !!$user,
user: $user,
theme: $theme,
locale: $locale,
isReady: true,
})
);

// 글로벌 로딩 상태
export const loading = writable({
auth: false,
data: false,
global: false,
});

// 액션 디스패처
export function dispatch(action, payload) {
switch (action) {
case 'LOGIN_SUCCESS':
user.set(payload.user);
notifications.add('로그인 성공', 'success');
break;

case 'LOGOUT':
user.set(null);
notifications.add('로그아웃 되었습니다', 'info');
break;

case 'CHANGE_THEME':
theme.set(payload.theme);
localStorage.setItem('theme', payload.theme);
break;

case 'CHANGE_LOCALE':
locale.set(payload.locale);
localStorage.setItem('locale', payload.locale);
break;

case 'SET_LOADING':
loading.update(state => ({
...state,
[payload.key]: payload.value,
}));
break;

default:
console.warn('Unknown action:', action);
}
}
AppShell.svelte
<!-- AppShell.svelte -->
<script>
import {
appState,
notifications,
loading,
dispatch,
} from './app-stores.js';

function handleLogin() {
dispatch('SET_LOADING', { key: 'auth', value: true });

setTimeout(() => {
dispatch('LOGIN_SUCCESS', {
user: { id: 1, name: '사용자' },
});
dispatch('SET_LOADING', {
key: 'auth',
value: false,
});
}, 1000);
}

function handleLogout() {
dispatch('LOGOUT');
}

function toggleTheme() {
const newTheme =
$appState.theme === 'light' ? 'dark' : 'light';
dispatch('CHANGE_THEME', { theme: newTheme });
}
</script>

<div class="app" data-theme="{$appState.theme}">
<header>
<h1>앱 상태 관리</h1>

<div class="controls">
{#if $appState.isAuthenticated}
<span>{$appState.user.name}님</span>
<button onclick="{handleLogout}">로그아웃</button>
{:else}
<button
onclick="{handleLogin}"
disabled="{$loading.auth}"
>
{$loading.auth ? '로그인 중...' : '로그인'}
</button>
{/if}

<button onclick="{toggleTheme}">
{$appState.theme === 'light' ? '🌙' : '☀️'}
</button>
</div>
</header>

<div class="notifications">
{#each $notifications as notification (notification.id)}
<div class="notification {notification.type}">
{notification.message}
<button onclick="{()" ="">
notifications.remove(notification.id)}> ×
</button>
</div>
{/each}
</div>

<main>
<pre>{JSON.stringify($appState, null, 2)}</pre>
</main>
</div>

<style>
.app[data-theme='dark'] {
background: #1a1a1a;
color: white;
}

.notifications {
position: fixed;
top: 20px;
right: 20px;
}

.notification {
padding: 1rem;
margin-bottom: 0.5rem;
border-radius: 4px;
}

.notification.success {
background: #10b981;
color: white;
}
</style>

상태 정규화

복잡한 데이터 구조를 효율적으로 관리하기 위해 상태를 정규화하는 것이 중요합니다. 관계형 데이터를 평탄한 구조로 저장하고, ID를 통해 참조하면 중복을 제거하고 업데이트를 간소화할 수 있습니다. Redux의 정규화 패턴을 Svelte 스토어에 적용할 수 있습니다.

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

// 정규화된 데이터 구조
const entities = writable({
users: {},
posts: {},
comments: {},
});

const currentUserId = writable(null);
const selectedPostId = writable(null);

// 헬퍼 함수
function normalizeData(data, entityType) {
const normalized = {};
data.forEach(item => {
normalized[item.id] = item;
});

entities.update(state => ({
...state,
[entityType]: { ...state[entityType], ...normalized },
}));
}

// 선택자 (Selectors)
export const currentUser = derived(
[entities, currentUserId],
([$entities, $userId]) =>
$userId ? $entities.users[$userId] : null
);

export const allPosts = derived(entities, $entities =>
Object.values($entities.posts)
);

export const currentPost = derived(
[entities, selectedPostId],
([$entities, $postId]) =>
$postId ? $entities.posts[$postId] : null
);

export const postWithAuthor = derived(
[entities, selectedPostId],
([$entities, $postId]) => {
if (!$postId) return null;

const post = $entities.posts[$postId];
if (!post) return null;

const author = $entities.users[post.authorId];
const comments = Object.values($entities.comments)
.filter(c => c.postId === $postId)
.map(comment => ({
...comment,
author: $entities.users[comment.authorId],
}));

return {
...post,
author,
comments,
};
}
);

// 액션
export const dataActions = {
loadUsers(users) {
normalizeData(users, 'users');
},

loadPosts(posts) {
normalizeData(posts, 'posts');
},

loadComments(comments) {
normalizeData(comments, 'comments');
},

updatePost(postId, updates) {
entities.update(state => ({
...state,
posts: {
...state.posts,
[postId]: {
...state.posts[postId],
...updates,
},
},
}));
},

deletePost(postId) {
entities.update(state => {
const { [postId]: deleted, ...posts } = state.posts;
return { ...state, posts };
});
},

selectPost(postId) {
selectedPostId.set(postId);
},

setCurrentUser(userId) {
currentUserId.set(userId);
},
};

// 초기 데이터 로드 시뮬레이션
export async function loadInitialData() {
// API 호출 시뮬레이션
const mockData = {
users: [
{ id: 1, name: '김철수', email: 'kim@example.com' },
{ id: 2, name: '이영희', email: 'lee@example.com' },
],
posts: [
{
id: 1,
title: '첫 포스트',
content: '내용',
authorId: 1,
},
{
id: 2,
title: '두번째 포스트',
content: '내용2',
authorId: 2,
},
],
comments: [
{ id: 1, text: '좋은 글!', postId: 1, authorId: 2 },
{ id: 2, text: '동의합니다', postId: 1, authorId: 1 },
],
};

dataActions.loadUsers(mockData.users);
dataActions.loadPosts(mockData.posts);
dataActions.loadComments(mockData.comments);
dataActions.setCurrentUser(1);
}

성능 고려사항

대규모 애플리케이션에서 스토어 성능을 최적화하는 것은 매우 중요합니다. 불필요한 구독을 피하고, 메모이제이션을 활용하며, 배치 업데이트를 통해 렌더링을 최소화해야 합니다. 스토어 업데이트 빈도를 제한하고, 필요한 경우 디바운싱이나 스로틀링을 적용합니다.

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

// 디바운스된 스토어
function debouncedWritable(initial, delay = 300) {
const store = writable(initial);
let timeout;

return {
subscribe: store.subscribe,
set(value) {
clearTimeout(timeout);
timeout = setTimeout(() => store.set(value), delay);
},
update(fn) {
clearTimeout(timeout);
timeout = setTimeout(() => store.update(fn), delay);
},
flush() {
clearTimeout(timeout);
},
};
}

// 스로틀된 스토어
function throttledWritable(initial, delay = 100) {
const store = writable(initial);
let lastUpdate = 0;

return {
subscribe: store.subscribe,
set(value) {
const now = Date.now();
if (now - lastUpdate > delay) {
store.set(value);
lastUpdate = now;
}
},
};
}

// 배치 업데이트 스토어
function batchedStore() {
const store = writable([]);
let pending = [];
let scheduled = false;

function flush() {
if (pending.length > 0) {
store.update(items => [...items, ...pending]);
pending = [];
}
scheduled = false;
}

return {
subscribe: store.subscribe,
add(item) {
pending.push(item);

if (!scheduled) {
scheduled = true;
queueMicrotask(flush);
}
},
clear() {
pending = [];
store.set([]);
},
};
}

// 메모이즈된 셀렉터
function memoizedDerived(stores, fn) {
let lastInputs = [];
let lastOutput;

return derived(stores, (values, set) => {
const changed = values.some(
(v, i) => v !== lastInputs[i]
);

if (changed) {
lastInputs = [...values];
lastOutput = fn(values);
set(lastOutput);
} else {
set(lastOutput);
}
});
}

// 사용 예제
export const searchTerm = debouncedWritable('');
export const mousePosition = throttledWritable({
x: 0,
y: 0,
});
export const logs = batchedStore();

// 무거운 계산을 메모이즈
export const expensiveComputation = memoizedDerived(
[searchTerm],
([term]) => {
console.log('무거운 계산 실행');
// 실제로는 복잡한 계산
return term.split('').reverse().join('');
}
);

정리

Svelte의 스토어와 상태 관리 시스템을 완전히 마스터했습니다! 이제 다음과 같은 핵심 개념들을 이해했습니다:

핵심 요약

  • 기본 스토어: writable, readable, derived를 통한 반응형 상태 관리와 컴포넌트 간 상태 공유
  • 커스텀 스토어: 비즈니스 로직을 캡슐화한 재사용 가능한 스토어 패턴과 모범 사례
  • 고급 패턴: 여러 스토어 조합, 상태 정규화, 성능 최적화를 통한 대규모 애플리케이션 상태 관리

실무 활용 팁

  • 간단한 상태는 컴포넌트 내부에, 공유 상태는 스토어로 관리
  • 도메인별로 스토어를 분리하여 관심사 분리 원칙 적용
  • 큰 데이터셋은 정규화하고, 자주 변경되는 값은 디바운싱/스로틀링 적용

다음 단계: 11장 "SvelteKit 소개"에서는 Svelte의 풀스택 프레임워크인 SvelteKit을 알아보겠습니다. 서버 사이드 렌더링, 라우팅, API 엔드포인트 등 현대적인 웹 애플리케이션 개발에 필요한 모든 기능을 마스터해보세요!