17.5 스토어(Store)
Svelte 스토어는 컴포넌트 트리 밖에서 상태를 관리하는 메커니즘입니다. 여러 컴포넌트가 공유해야 하는 전역 상태, 예를 들어 사용자 인증 정보, 테마 설정, 장바구니 등에 활용합니다.
Svelte 스토어 개념
Svelte 스토어는 간단한 **계약(contract)**을 따릅니다. 스토어는 다음 인터페이스를 가진 객체입니다:
interface Store<T> {
subscribe(callback: (value: T) => void): () => void;
// set, update는 writable에만 있음
}
subscribe(): 값이 변경될 때마다 콜백 호출. 구독 해제 함수를 반환set(): 새 값으로 설정 (writable만)update(): 이전 값을 받아 새 값을 반환하는 함수로 업데이트 (writable만)
writable() — 쓰기 가능 스토어
// src/lib/stores/count.js
import { writable } from 'svelte/store';
// 초기값 0인 writable 스토어 생성
export const count = writable(0);
<!-- Counter.svelte -->
<script>
import { count } from '$lib/stores/count.js';
function increment() {
count.update(n => n + 1);
}
function decrement() {
count.update(n => n - 1);
}
function reset() {
count.set(0);
}
</script>
<!-- $ 접두사로 자동 구독 (컴포넌트에서만 사용 가능) -->
<p>현재 값: {$count}</p>
<button onclick={increment}>+1</button>
<button onclick={decrement}>-1</button>
<button onclick={reset}>초기화</button>
수동 구독 (컴포넌트 외부)
import { count } from '$lib/stores/count.js';
// 수동 구독
const unsubscribe = count.subscribe(value => {
console.log('count 변경됨:', value);
});
// 구독 해제 (메모리 누수 방지)
unsubscribe();
readable() — 읽기 전용 스토어
외부에서 값을 변경할 수 없는 스토어입니다. 타이머, 위치 정보, 브라우저 이벤트 등에 활용합니다.
// src/lib/stores/time.js
import { readable } from 'svelte/store';
// 현재 시간 스토어 (1초마다 업데이트)
export const currentTime = readable(new Date(), (set) => {
const interval = setInterval(() => {
set(new Date());
}, 1000);
// 클린업 함수 (구독자가 없어지면 호출)
return () => clearInterval(interval);
});
<!-- Clock.svelte -->
<script>
import { currentTime } from '$lib/stores/time.js';
let timeString = $derived(
$currentTime.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
);
</script>
<p>현재 시간: {timeString}</p>
마우스 위치 readable 스토어
// src/lib/stores/mouse.js
import { readable } from 'svelte/store';
export const mousePosition = readable({ x: 0, y: 0 }, (set) => {
function handleMouseMove(event) {
set({ x: event.clientX, y: event.clientY });
}
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
});
derived() — 파생 스토어
하나 이상의 스토어에서 계산된 값을 가지는 스토어입니다.
// src/lib/stores/cart.js
import { writable, derived } from 'svelte/store';
export const cartItems = writable([]);
// 단일 스토어에서 파생
export const cartCount = derived(
cartItems,
($items) => $items.length
);
export const cartTotal = derived(
cartItems,
($items) => $items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
// 여러 스토어에서 파생
import { writable } from 'svelte/store';
export const discountRate = writable(0);
export const finalTotal = derived(
[cartTotal, discountRate],
([$total, $discount]) => $total * (1 - $discount)
);
$ 자동 구독 문법
컴포넌트 내에서 $ 접두사를 붙이면 스토어를 자동으로 구독하고 컴포넌트가 제거될 때 자동으로 구독을 해제합니다.
<script>
import { count } from '$lib/stores/count.js';
import { cartItems, cartTotal, finalTotal } from '$lib/stores/cart.js';
</script>
<!-- $를 붙이면 자동 구독 -->
<p>카운트: {$count}</p>
<p>장바구니: {$cartItems.length}개</p>
<p>합계: {$cartTotal}원</p>
<p>최종: {$finalTotal}원</p>
<!-- $를 양방향 바인딩에도 사용 가능 -->
<input bind:value={$count} type="number" />
주의:
$자동 구독은 컴포넌트의 최상위 레벨 스크립트에서만 사용 가능합니다. 함수 내부나 모듈 스크립트에서는 수동으로subscribe()를 사용해야 합니다.
커스텀 스토어 패턴
스토어에 비즈니스 로직을 캡슐화할 수 있습니다.
// src/lib/stores/counter.js
import { writable } from 'svelte/store';
function createCounter(initialValue = 0) {
const { subscribe, set, update } = writable(initialValue);
return {
subscribe, // 필수: 외부에서 $counter로 읽기 위해
increment: () => update(n => n + 1),
decrement: () => update(n => n - 1),
reset: () => set(initialValue),
setTo: (value) => set(value),
double: () => update(n => n * 2),
};
}
export const counter = createCounter(10);
<!-- 컴포넌트에서 사용 -->
<script>
import { counter } from '$lib/stores/counter.js';
</script>
<p>값: {$counter}</p>
<button onclick={counter.increment}>+1</button>
<button onclick={counter.decrement}>-1</button>
<button onclick={counter.double}>×2</button>
<button onclick={counter.reset}>초기화</button>
실전 예제 1: 테마 스토어
// src/lib/stores/theme.svelte.js
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
const THEMES = ['light', 'dark', 'system'];
function createThemeStore() {
// 브라우저에서 저장된 테마 불러오기
const saved = browser ? localStorage.getItem('theme') : null;
const initial = saved ?? 'system';
const { subscribe, set } = writable(initial);
function applyTheme(theme) {
if (!browser) return;
let resolved = theme;
if (theme === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
document.documentElement.setAttribute('data-theme', resolved);
localStorage.setItem('theme', theme);
set(theme);
}
return {
subscribe,
setTheme: applyTheme,
toggle: () => {
let current;
subscribe(v => current = v)();
applyTheme(current === 'dark' ? 'light' : 'dark');
},
};
}
export const theme = createThemeStore();
<!-- ThemeToggle.svelte -->
<script>
import { theme } from '$lib/stores/theme.svelte.js';
</script>
<div class="theme-switcher">
{#each ['light', 'dark', 'system'] as t}
<button
class:active={$theme === t}
onclick={() => theme.setTheme(t)}
>
{#if t === 'light'}☀️{:else if t === 'dark'}🌙{:else}🖥️{/if}
{t}
</button>
{/each}
</div>
<style>
.theme-switcher { display: flex; gap: 0.5rem; }
button { padding: 0.5rem 1rem; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-secondary); cursor: pointer; }
button.active { background: var(--primary); color: white; }
</style>
실전 예제 2: 사용자 인증 스토어
// src/lib/stores/auth.js
import { writable, derived } from 'svelte/store';
const createAuthStore = () => {
const user = writable(null);
const loading = writable(false);
const error = writable(null);
const isAuthenticated = derived(user, $user => !!$user);
const isAdmin = derived(user, $user => $user?.role === 'admin');
async function login(credentials) {
loading.set(true);
error.set(null);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
if (!res.ok) throw new Error('로그인 실패');
const userData = await res.json();
user.set(userData);
return { success: true };
} catch (err) {
error.set(err.message);
return { success: false, error: err.message };
} finally {
loading.set(false);
}
}
async function logout() {
await fetch('/api/auth/logout', { method: 'POST' });
user.set(null);
error.set(null);
}
async function checkSession() {
loading.set(true);
try {
const res = await fetch('/api/auth/me');
if (res.ok) {
const userData = await res.json();
user.set(userData);
}
} catch {
user.set(null);
} finally {
loading.set(false);
}
}
return {
user: { subscribe: user.subscribe },
loading: { subscribe: loading.subscribe },
error: { subscribe: error.subscribe },
isAuthenticated,
isAdmin,
login,
logout,
checkSession,
};
};
export const auth = createAuthStore();
<!-- LoginForm.svelte -->
<script>
import { auth } from '$lib/stores/auth.js';
import { goto } from '$app/navigation';
let username = $state('');
let password = $state('');
async function handleSubmit(e) {
e.preventDefault();
const result = await auth.login({ username, password });
if (result.success) {
goto('/dashboard');
}
}
</script>
<form onsubmit={handleSubmit}>
<h2>로그인</h2>
{#if $auth.error}
<p class="error">{$auth.error}</p>
{/if}
<input bind:value={username} placeholder="사용자명" required />
<input bind:value={password} type="password" placeholder="비밀번호" required />
<button type="submit" disabled={$auth.loading}>
{$auth.loading ? '로그인 중...' : '로그인'}
</button>
</form>
<!-- NavBar.svelte -->
<script>
import { auth } from '$lib/stores/auth.js';
</script>
<nav>
<a href="/">홈</a>
{#if $auth.isAuthenticated}
<a href="/dashboard">대시보드</a>
{#if $auth.isAdmin}
<a href="/admin">관리자</a>
{/if}
<span>{$auth.user?.name}</span>
<button onclick={auth.logout}>로그아웃</button>
{:else}
<a href="/login">로그인</a>
{/if}
</nav>
Svelte 5에서의 스토어 vs Runes 비교
Svelte 5에서는 Runes가 많은 스토어 사용 사례를 대체할 수 있습니다.
| 사용 사례 | 권장 방법 |
|---|---|
| 컴포넌트 내부 상태 | $state() Rune |
| 컴포넌트 간 공유 상태 | Svelte 스토어 또는 $state를 .svelte.js에 |
| 서드파티 라이브러리 연동 | Svelte 스토어 (계약 호환) |
| SSR 환경 | 스토어 (SvelteKit의 서버/클라이언트 분리) |
// Runes로 글로벌 상태 (src/lib/globalState.svelte.js)
let count = $state(0);
export function getCount() { return count; }
export function setCount(v) { count = v; }
export function increment() { count++; }
<!-- 어느 컴포넌트에서나 -->
<script>
import { getCount, increment } from '$lib/globalState.svelte.js';
</script>
<p>전역 카운트: {getCount()}</p>
<button onclick={increment}>+1</button>
전역 상태 관리 패턴 비교
// 패턴 1: 단순 writable 스토어
import { writable } from 'svelte/store';
export const items = writable([]);
// 패턴 2: 커스텀 스토어 (비즈니스 로직 캡슐화)
function createItemsStore() {
const { subscribe, update } = writable([]);
return {
subscribe,
add: (item) => update(items => [...items, item]),
remove: (id) => update(items => items.filter(i => i.id !== id)),
};
}
export const items = createItemsStore();
// 패턴 3: Runes 기반 (Svelte 5, .svelte.js 파일)
let _items = $state([]);
export const items = {
get value() { return _items; },
add(item) { _items.push(item); },
remove(id) { _items = _items.filter(i => i.id !== id); },
};
고수 팁
팁 1: 스토어 초기값 지연 로드
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
// 브라우저에서만 localStorage 접근
const storedValue = browser ? JSON.parse(localStorage.getItem('settings') ?? 'null') : null;
export const settings = writable(storedValue ?? {
theme: 'light',
language: 'ko',
notifications: true,
});
// 변경 시 자동 저장
if (browser) {
settings.subscribe(value => {
localStorage.setItem('settings', JSON.stringify(value));
});
}
팁 2: 스토어 타입 안전성 (TypeScript)
// src/lib/stores/typed.ts
import { writable, derived, type Readable } from 'svelte/store';
interface User {
id: number;
name: string;
email: string;
role: 'user' | 'admin';
}
export const user = writable<User | null>(null);
export const isAdmin: Readable<boolean> = derived(
user,
$user => $user?.role === 'admin' ?? false
);
팁 3: 스토어 조합
import { derived } from 'svelte/store';
import { cartItems } from './cart.js';
import { user } from './auth.js';
// 여러 스토어를 조합한 파생 스토어
export const checkoutData = derived(
[cartItems, user],
([$items, $user]) => ({
items: $items,
userId: $user?.id,
canCheckout: $items.length > 0 && !!$user,
total: $items.reduce((s, i) => s + i.price * i.quantity, 0),
})
);
정리
| 스토어 타입 | 함수 | 용도 |
|---|---|---|
writable | writable(value) | 읽기/쓰기 상태 |
readable | readable(value, start) | 외부 소스 데이터 (타이머 등) |
derived | derived(stores, fn) | 다른 스토어에서 계산된 값 |
| 커스텀 스토어 | subscribe 포함 객체 | 비즈니스 로직 캡슐화 |
$ 자동 구독 | 컴포넌트에서 $store | 보일러플레이트 제거 |
다음 장에서는 SvelteKit의 라우팅, 데이터 로드, 레이아웃, 어댑터를 알아봅니다.