Svelte의 Store는 컴포넌트 간 상태 공유와 반응형 데이터 관리를 위한 핵심 기능입니다. 이 가이드에서는 Svelte Store의 다양한 기능, SvelteKit이 제공하는 기본 store, 그리고 Svelte 5의 runes와의 관계에 대해 상세히 알아보겠습니다.
writable은 가장 기본적인 store 타입으로, 외부에서 값을 설정할 수 있는 store입니다.
주요 특징:
set()메서드: 새로운 값으로 직접 설정update()메서드: 콜백 함수를 통해 기존 값을 기반으로 업데이트subscribe()메서드: 값 변경 시 콜백 실행
기본 사용법:
import { writable } from 'svelte/store';
const count = writable(0);
// 구독
count.subscribe((value) => {
console.log(value); // 0
});
// 값 설정
count.set(1); // 1
// 값 업데이트
count.update((n) => n + 1); // 2컴포넌트에서 사용:
<script>
import { writable } from 'svelte/store';
const count = writable(0);
console.log($count); // $ 접두사로 자동 구독
$count = 2; // 직접 할당도 가능
</script>
<button on:click={() => $count++}>
{$count}
</button>구독자 수에 따른 시작/정지 함수:
const count = writable(0, () => {
console.log('첫 번째 구독자 등록');
return () => console.log('마지막 구독자 해제');
});readable은 외부에서 값을 설정할 수 없는 읽기 전용 store입니다. 주로 외부 데이터 소스나 이벤트와 연동할 때 사용합니다.
주요 특징:
- 외부에서
set()불가능 - 내부 로직으로만 값 변경
- 타이머, API 호출, 이벤트 리스너 등과 연동
시간 예제:
import { readable } from 'svelte/store';
const time = readable(new Date(), (set) => {
set(new Date());
const interval = setInterval(() => {
set(new Date());
}, 1000);
return () => clearInterval(interval);
});토글 예제:
const ticktock = readable('tick', (set, update) => {
const interval = setInterval(() => {
update(sound => sound === 'tick' ? 'tock' : 'tick');
}, 1000);
return () => clearInterval(interval);
});derived는 하나 이상의 다른 store로부터 계산된 값을 제공하는 store입니다.
주요 특징:
- 의존하는 store가 변경될 때 자동으로 재계산
- 동기 및 비동기 계산 지원
- 여러 store를 조합 가능
단일 store 파생:
import { derived } from 'svelte/store';
const doubled = derived(a, ($a) => $a * 2);비동기 파생:
const delayed = derived(
a,
($a, set) => {
setTimeout(() => set($a), 1000);
},
2000 // 초기값
);여러 store 조합:
const summed = derived([a, b], ([$a, $b]) => $a + $b);
const delayed = derived([a, b], ([$a, $b], set) => {
setTimeout(() => set($a + $b), 1000);
});get 함수는 구독 없이 store의 현재 값을 동기적으로 가져올 때 사용합니다.
주요 특징:
- 일회성 값 조회
- 구독/구독 해제 자동 처리
- 핫 코드 경로에서는 권장하지 않음
사용법:
import { get } from 'svelte/store';
const value = get(store);주의사항:
- 내부적으로 구독 → 값 읽기 → 구독 해제 과정을 거침
- 반복적인 호출은 성능에 영향을 줄 수 있음
커스텀 store는 기본 store에 추가 메서드를 제공하여 도메인별 로직을 캡슐화합니다.
기본 패턴:
function createCount() {
const { subscribe, set, update } = writable(0);
return {
subscribe,
increment: () => update(n => n + 1),
decrement: () => update(n => n - 1),
reset: () => set(0)
};
}
const count = createCount();고급 예제:
function createTodos() {
const { subscribe, set, update } = writable([]);
return {
subscribe,
add: (text) => update(todos => [...todos, {
id: Date.now(),
text,
done: false
}]),
remove: (id) => update(todos => todos.filter(t => t.id !== id)),
toggle: (id) => update(todos => todos.map(t =>
t.id === id ? { ...t, done: !t.done } : t
)),
clear: () => set([])
};
}readonly 함수는 writable store를 읽기 전용으로 만들어 외부에서의 수정을 방지합니다.
import { readonly, writable } from 'svelte/store';
const writableStore = writable(1);
const readableStore = readonly(writableStore);
readableStore.subscribe(console.log);
writableStore.set(2); // 정상 동작
// readableStore.set(2); // 에러 - set 메서드 없음Context API를 통해 컴포넌트 트리 깊숙이 store를 전달할 수 있습니다.
// 부모 컴포넌트
import { setContext } from 'svelte';
import { writable } from 'svelte/store';
const userStore = writable({ name: 'John' });
setContext('user', userStore);// 자식 컴포넌트
import { getContext } from 'svelte';
const userStore = getContext('user');SvelteKit은 애플리케이션의 상태와 네비게이션 정보를 제공하는 여러 내장 store를 제공합니다. Svelte 5와 SvelteKit 2.12부터는 $app/state 모듈을 권장하며, 이전 버전에서는 $app/stores를 사용합니다.
현재 페이지의 정보를 담고 있는 readable store입니다.
타입:
const page: Readable<{
url: URL;
params: Record<string, string>;
route: { id: string | null };
status: number;
error: Error | null;
data: Record<string, any>;
form: any;
}>;사용법:
<script>
import { page } from '$app/stores';
</script>
<p>현재 URL: {$page.url.pathname}</p>
<p>라우트 파라미터: {JSON.stringify($page.params)}</p>현재 진행 중인 네비게이션 정보를 제공하는 readable store입니다.
타입:
const navigating: Readable<Navigation | null>;
interface Navigation {
from: URL | null;
to: URL | null;
type: 'enter' | 'form' | 'leave' | 'link' | 'goto' | 'popstate';
delta?: number; // popstate인 경우에만
}사용법:
<script>
import { navigating } from '$app/stores';
</script>
{#if $navigating}
<p>네비게이션 중: {$navigating.from?.pathname} → {$navigating.to?.pathname}</p>
{/if}앱의 새 버전이 배포되었는지 확인하는 readable store입니다.
타입:
const updated: Readable<boolean> & {
check(): Promise<boolean>;
};사용법:
<script>
import { updated } from '$app/stores';
</script>
{#if $updated}
<div class="toast">
새 버전이 있습니다.
<button on:click={() => location.reload()}>새로고침</button>
</div>
{/if}서버 사이드에서 store들에 접근할 때 사용하는 함수입니다.
import { getStores } from '$app/stores';
const { page, navigating, updated } = getStores();Svelte 5의 runes 시스템과 호환되는 새로운 상태 객체들입니다.
현재 페이지 정보를 담은 반응형 객체입니다.
특징:
- runes와 완전 호환
- 레거시 반응성 구문에서는 변경사항이 반영되지 않음
- 서버에서는 렌더링 중에만 읽기 가능
사용법:
<script>
import { page } from '$app/state';
</script>
<p>현재 URL: {page.url.pathname}</p>
<p>라우트 파라미터: {JSON.stringify(page.params)}</p>진행 중인 네비게이션을 나타내는 읽기 전용 객체입니다.
타입:
const navigating: Navigation | {
from: null;
to: null;
type: null;
willUnload: null;
delta: null;
complete: null;
};사용법:
<script>
import { navigating } from '$app/state';
</script>
{#if navigating.from}
<p>네비게이션 중...</p>
{/if}앱 업데이트 상태를 나타내는 반응형 값입니다.
타입:
const updated: {
get current(): boolean;
check(): Promise<boolean>;
};사용법:
<script>
import { updated } from '$app/state';
</script>
{#if updated.current}
<div class="update-notification">
새 버전이 있습니다!
<button on:click={() => location.reload()}>새로고침</button>
</div>
{/if}
<button on:click={() => updated.check()}>
업데이트 확인
</button><script>
import { page } from '$app/state'; // 또는 '$app/stores'
</script>
<nav>
<a href="/" class:active={page.url.pathname === '/'}>홈</a>
<a href="/about" class:active={page.url.pathname === '/about'}>소개</a>
<a href="/contact" class:active={page.url.pathname === '/contact'}>연락처</a>
</nav>
<style>
.active {
font-weight: bold;
color: blue;
}
</style><script>
import { navigating } from '$app/state';
</script>
{#if navigating.from}
<div class="loading-bar">
<div class="progress"></div>
</div>
{/if}
<style>
.loading-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: #f0f0f0;
z-index: 9999;
}
.progress {
height: 100%;
background: #007acc;
animation: loading 2s ease-in-out infinite;
}
@keyframes loading {
0% { width: 0%; }
50% { width: 70%; }
100% { width: 100%; }
}
</style><script>
import { page } from '$app/state';
// 폼 액션 후 결과 데이터 접근
$: if (page.form?.success) {
console.log('폼 제출 성공!');
}
</script>
{#if page.form?.errors}
<div class="errors">
{#each Object.entries(page.form.errors) as [field, error]}
<p>{field}: {error}</p>
{/each}
</div>
{/if}Svelte 5에서는 새로운 runes 시스템이 도입되어 반응형 상태 관리 방식이 크게 변화했습니다. 하지만 store는 여전히 유효하며, 특정 상황에서는 더 적합할 수 있습니다.
주요 Runes:
$state(): 반응형 상태 선언$derived(): 파생된 값 계산$effect(): 부수 효과 처리$props(): 컴포넌트 속성 선언
Runes 사용 권장 상황:
- 컴포넌트 내부 상태 관리
- 간단한 전역 상태 관리
- TypeScript와의 호환성이 중요한 경우
- 성능이 중요한 경우
Store 사용 권장 상황:
- 복잡한 비동기 데이터 스트림
- 기존 RxJS 지식 활용
- 세밀한 구독 제어가 필요한 경우
- 라이브러리 호환성이 중요한 경우
Svelte 4 (Store):
// store.js
import { writable } from 'svelte/store';
export const count = writable(0);<!-- Component.svelte -->
<script>
import { count } from './store.js';
</script>
<button on:click={() => $count++}>
{$count}
</button>Svelte 5 (Runes):
// state.svelte.js
let count = $state(0);
export function getCount() {
return count;
}
export function setCount(newCount) {
count = newCount;
}
export function incrementCount() {
count++;
}<!-- Component.svelte -->
<script>
import { getCount, incrementCount } from './state.svelte.js';
</script>
<button on:click={incrementCount}>
{getCount()}
</button>Getter/Setter 객체 패턴:
// counter.svelte.js
let count = $state(0);
export const counter = {
get value() {
return count;
},
set value(newCount) {
count = newCount;
},
increment() {
count++;
},
decrement() {
count--;
},
reset() {
count = 0;
}
};사용법:
<script>
import { counter } from './counter.svelte.js';
</script>
<div>
<button on:click={counter.decrement}>-</button>
<span>{counter.value}</span>
<button on:click={counter.increment}>+</button>
<button on:click={counter.reset}>Reset</button>
</div>// TodoStore.svelte.js
class TodoStore {
#todos = $state([]);
get todos() {
return this.#todos;
}
get completed() {
return this.#todos.filter(todo => todo.completed);
}
get pending() {
return this.#todos.filter(todo => !todo.completed);
}
add(text) {
this.#todos.push({
id: Date.now(),
text,
completed: false
});
}
toggle(id) {
const todo = this.#todos.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed;
}
}
remove(id) {
this.#todos = this.#todos.filter(t => t.id !== id);
}
}
export const todoStore = new TodoStore();Svelte 4:
<script>
export let initialValue = 0;
let count = initialValue;
</script>Svelte 5:
<script>
let { initialValue = 0 } = $props();
let count = $state(initialValue);
</script><script>
let { initialValue = 0 } = $props();
let count = $state(initialValue);
// initialValue가 변경될 때 count도 업데이트
$effect(() => {
count = initialValue;
});
</script><script>
let { value = $bindable(), onchange } = $props();
function handleInput(event) {
value = event.target.value;
onchange?.(value);
}
</script>
<input {value} on:input={handleInput} />Svelte 5의 가장 큰 장점 중 하나는 runes가 컴포넌트 외부에서도 작동한다는 것입니다.
// utils.svelte.js
let theme = $state('light');
let user = $state(null);
export const appState = {
get theme() { return theme; },
set theme(value) { theme = value; },
get user() { return user; },
set user(value) { user = value; },
get isLoggedIn() {
return user !== null;
},
toggleTheme() {
theme = theme === 'light' ? 'dark' : 'light';
}
};
// 부수 효과도 가능
$effect(() => {
document.body.className = theme;
});// auth.svelte.js
let currentUser = $state(null);
export const auth = {
get user() { return currentUser; },
async login(credentials) {
const user = await api.login(credentials);
currentUser = user;
return user;
},
logout() {
currentUser = null;
api.logout();
}
};// navigation.svelte.js
import { auth } from './auth.svelte.js';
let currentRoute = $state('/');
export const navigation = {
get route() { return currentRoute; },
navigate(path) {
// 인증이 필요한 경로 체크
if (path.startsWith('/admin') && !auth.user) {
currentRoute = '/login';
} else {
currentRoute = path;
}
}
};
// auth 상태 변화에 반응
$effect(() => {
if (!auth.user && currentRoute.startsWith('/admin')) {
currentRoute = '/login';
}
});Store와 runes는 함께 사용할 수 있습니다.
import { writable } from 'svelte/store';
const store = writable(0);
let value = $state(0);
// Store 변화를 runes로 동기화
store.subscribe(v => value = v);
export { value, store };function runeToStore(getValue) {
let subscribers = [];
let currentValue = getValue();
$effect(() => {
const newValue = getValue();
if (newValue !== currentValue) {
currentValue = newValue;
subscribers.forEach(fn => fn(newValue));
}
});
return {
subscribe(fn) {
subscribers.push(fn);
fn(currentValue);
return () => {
subscribers = subscribers.filter(s => s !== fn);
};
}
};
}
// 사용 예
let count = $state(0);
const countStore = runeToStore(() => count);- 더 세밀한 반응성 제어
- 불필요한 재계산 방지
- 더 나은 TypeScript 지원
- 런타임 오버헤드 감소
- 성숙한 생태계
- 복잡한 비동기 패턴 지원
- 기존 코드와의 호환성
- 명시적인 구독 관리
- 점진적 마이그레이션: 새로운 기능은 runes로, 기존 코드는 store 유지
- 핵심 상태부터: 가장 자주 사용되는 상태부터 runes로 변환
- 테스트 우선: 마이그레이션 전 충분한 테스트 코드 작성
- 성능 측정: 마이그레이션 전후 성능 비교
- 팀 교육: 새로운 패턴에 대한 팀 내 교육 진행
// cart.js
import { writable, derived } from 'svelte/store';
function createCart() {
const { subscribe, set, update } = writable([]);
return {
subscribe,
addItem: (product) => update(items => {
const existing = items.find(item => item.id === product.id);
if (existing) {
return items.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...items, { ...product, quantity: 1 }];
}),
removeItem: (id) => update(items => items.filter(item => item.id !== id)),
updateQuantity: (id, quantity) => update(items =>
items.map(item =>
item.id === id ? { ...item, quantity } : item
)
),
clear: () => set([])
};
}
export const cart = createCart();
export const cartTotal = derived(cart, $cart =>
$cart.reduce((total, item) => total + (item.price * item.quantity), 0)
);
export const cartItemCount = derived(cart, $cart =>
$cart.reduce((count, item) => count + item.quantity, 0)
);// cart.svelte.js
let items = $state([]);
export const cart = {
get items() { return items; },
get total() {
return items.reduce((total, item) => total + (item.price * item.quantity), 0);
},
get itemCount() {
return items.reduce((count, item) => count + item.quantity, 0);
},
addItem(product) {
const existing = items.find(item => item.id === product.id);
if (existing) {
existing.quantity++;
} else {
items.push({ ...product, quantity: 1 });
}
},
removeItem(id) {
items = items.filter(item => item.id !== id);
},
updateQuantity(id, quantity) {
const item = items.find(item => item.id === id);
if (item) {
item.quantity = quantity;
}
},
clear() {
items = [];
}
};// api-store.js
import { writable, derived } from 'svelte/store';
function createApiStore(url) {
const loading = writable(false);
const error = writable(null);
const data = writable(null);
const fetch = async () => {
loading.set(true);
error.set(null);
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch');
const result = await response.json();
data.set(result);
} catch (err) {
error.set(err.message);
} finally {
loading.set(false);
}
};
return {
loading: { subscribe: loading.subscribe },
error: { subscribe: error.subscribe },
data: { subscribe: data.subscribe },
fetch,
refresh: fetch
};
}
export const userStore = createApiStore('/api/user');// api-state.svelte.js
class ApiState {
#loading = $state(false);
#error = $state(null);
#data = $state(null);
constructor(url) {
this.url = url;
}
get loading() { return this.#loading; }
get error() { return this.#error; }
get data() { return this.#data; }
async fetch() {
this.#loading = true;
this.#error = null;
try {
const response = await fetch(this.url);
if (!response.ok) throw new Error('Failed to fetch');
this.#data = await response.json();
} catch (err) {
this.#error = err.message;
} finally {
this.#loading = false;
}
}
refresh() {
return this.fetch();
}
}
export const userApi = new ApiState('/api/user');// form-state.svelte.js
function createFormState(initialValues = {}) {
let values = $state({ ...initialValues });
let errors = $state({});
let touched = $state({});
let isSubmitting = $state(false);
return {
get values() { return values; },
get errors() { return errors; },
get touched() { return touched; },
get isSubmitting() { return isSubmitting; },
get isValid() {
return Object.keys(errors).length === 0;
},
setValue(field, value) {
values[field] = value;
touched[field] = true;
this.validate(field);
},
setError(field, error) {
errors[field] = error;
},
clearError(field) {
delete errors[field];
},
validate(field) {
// 검증 로직 구현
if (!values[field]) {
this.setError(field, 'Required field');
} else {
this.clearError(field);
}
},
async submit(onSubmit) {
isSubmitting = true;
try {
await onSubmit(values);
} catch (error) {
console.error('Submit error:', error);
} finally {
isSubmitting = false;
}
},
reset() {
values = { ...initialValues };
errors = {};
touched = {};
isSubmitting = false;
}
};
}
export { createFormState };// 잘못된 예
let unsubscribe;
onMount(() => {
unsubscribe = store.subscribe(value => {
// 처리 로직
});
});
// onDestroy에서 unsubscribe 호출 누락
// 올바른 예
onMount(() => {
const unsubscribe = store.subscribe(value => {
// 처리 로직
});
return unsubscribe; // onDestroy에서 자동 호출
});// 브라우저에서만 실행되는 store
import { browser } from '$app/environment';
import { writable } from 'svelte/store';
export const clientOnlyStore = writable(
browser ? localStorage.getItem('key') : null
);
if (browser) {
clientOnlyStore.subscribe(value => {
localStorage.setItem('key', value);
});
}// 문제가 있는 코드
let count = $state(0);
export { count }; // 값이 고정됨
// 올바른 코드
let count = $state(0);
export function getCount() { return count; }
export function setCount(value) { count = value; }// 무한 루프 위험
let count = $state(0);
$effect(() => {
count++; // 이렇게 하면 안됨!
});
// 올바른 사용
let count = $state(0);
$effect(() => {
console.log('Count changed:', count);
// 읽기만 하고 수정하지 않음
});// Store 버전
const expensiveComputation = derived(
[store1, store2],
([$store1, $store2], set) => {
// 비용이 큰 계산을 debounce
const timeoutId = setTimeout(() => {
set(heavyCalculation($store1, $store2));
}, 300);
return () => clearTimeout(timeoutId);
}
);
// Runes 버전
let input1 = $state('');
let input2 = $state('');
let debouncedResult = $state('');
let timeoutId;
$effect(() => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
debouncedResult = heavyCalculation(input1, input2);
}, 300);
});| 상황 | 권장 방식 | 이유 |
|---|---|---|
| 새 프로젝트 (Svelte 5) | Runes | 더 나은 성능과 TypeScript 지원 |
| 기존 프로젝트 마이그레이션 | 점진적 전환 | 안정성과 호환성 |
| 복잡한 비동기 로직 | Store | 성숙한 패턴과 도구 |
| 간단한 상태 관리 | Runes | 더 직관적이고 간단함 |
| 라이브러리 개발 | Store | 더 넓은 호환성 |
| 성능이 중요한 앱 | Runes | 더 효율적인 반응성 |
-
Store는 여전히 유효: Svelte 5에서도 store는 완전히 지원되며, 특정 상황에서는 더 적합할 수 있습니다.
-
Runes의 장점: 더 명시적이고 성능이 좋으며, TypeScript와 잘 호환됩니다.
-
점진적 마이그레이션: 기존 프로젝트는 점진적으로 runes로 전환할 수 있습니다.
-
상황에 맞는 선택: 프로젝트의 요구사항에 따라 적절한 방식을 선택하는 것이 중요합니다.
-
Universal Reactivity: Runes는 컴포넌트 외부에서도 작동하여 더 유연한 상태 관리를 가능하게 합니다.
- Svelte 공식 문서 - Stores
- Svelte 공식 문서 - Runes
- SvelteKit 공식 문서 - $app/stores
- SvelteKit 공식 문서 - $app/state
- Svelte 5 Migration Guide
이 가이드를 통해 Svelte Store의 다양한 기능과 Svelte 5 runes와의 관계를 이해하고, 프로젝트에 적합한 상태 관리 방식을 선택할 수 있기를 바랍니다.