본문으로 건너뛰기
Advertisement

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),
})
);

정리

스토어 타입함수용도
writablewritable(value)읽기/쓰기 상태
readablereadable(value, start)외부 소스 데이터 (타이머 등)
derivedderived(stores, fn)다른 스토어에서 계산된 값
커스텀 스토어subscribe 포함 객체비즈니스 로직 캡슐화
$ 자동 구독컴포넌트에서 $store보일러플레이트 제거

다음 장에서는 SvelteKit의 라우팅, 데이터 로드, 레이아웃, 어댑터를 알아봅니다.

Advertisement