스토어(Stores): 컴포넌트 간 반응형 상태 공유

스토어(Stores): 컴포넌트 간 반응형 상태 공유

여러 컴포넌트가 같은 값을 읽고 바꿔야 할 때가 있어요. 예를 들어 사용자 정보나 알림 개수를 여러 화면이 함께 참조하는 상황이죠. Svelte에서 이런 컴포넌트 간 반응형 상태를 다루는 전통적인 방법이 스토어(store) 예요.

스토어는 간단한 store contract 를 통해 값에 대한 반응형 접근을 허용하는 객체예요. svelte/store 모듈이 이 계약을 만족하는 최소한의 구현을 제공해요. 이 글에서는 스토어를 쓰는 법과 svelte/store 의 핵심 함수들을 정리할게요.

출처: Svelte 공식 문서 — Stores

기본 사용법

스토어 참조가 있으면, 컴포넌트 안에서 그 값에 $ 문자를 붙여 접근할 수 있어요. Svelte가 $ 접두사가 붙은 변수를 선언하고, 컴포넌트 초기화 시점에 스토어를 구독하며, 적절한 시점에 구독 해제까지 처리해 줘요.

$ 접두사가 붙은 변수에 할당하면 그 변수가 writable store여야 하고, 스토어의 .set 메서드를 호출하게 돼요. 스토어는 컴포넌트 최상위 레벨에 선언해야 하고, if 블록이나 함수 안에는 넣으면 안 돼요. 스토어가 아닌 로컬 변수에는 $ 접두사를 붙이면 안 돼요.

<script>
	import { writable } from 'svelte/store';

	const count = writable(0);
	console.log($count); // logs 0

	count.set(1);
	console.log($count); // logs 1

	$count = 2;
	console.log($count); // logs 2
</script>

스토어를 써야 할 때

Svelte 5 이전에는 스토어가 컴포넌트 간 반응형 상태를 만들거나 로직을 분리하는 대표적인 수단이었어요. 룬이 등장하면서 이런 용도는 크게 줄었어요.

  • 로직 추출: 룬의 보편적 반응성을 활용하는 게 더 좋아요. 컴포넌트 최상위뿐 아니라 .svelte.js/.svelte.ts 파일에서도 룬을 쓸 수 있어요.
  • 공유 상태: 필요한 값들을 담은 $state 객체를 만들고 그 상태를 조작하면 돼요.
/// file: state.svelte.js
export const userState = $state({
	name: 'name',
	/* ... */
});
<!--- file: App.svelte --->
<script>
	import { userState } from './state.svelte.js';
</script>

<p>User name: {userState.name}</p>
<button onclick={() => {
	userState.name = 'new name';
}}>
	change name
</button>

스토어는 여전히 복잡한 비동기 데이터 흐름을 다루거나, 값을 갱신·변화를 듣는 과정을 수동으로 더 잘 제어해야 할 때 좋은 선택이에요. RxJS에 익숙하고 그 지식을 재사용하고 싶다면 $ 접두사가 유용하게 느껴질 거예요.

svelte/store

svelte/store 모듈은 store contract를 충족하는 최소한의 구현을 제공해요. 외부에서 갱신할 수 있는 스토어, 내부에서만 갱신할 수 있는 스토어, 그리고 스토어를 조합·파생하는 함수들을 담고 있어요.

writable

외부 컴포넌트에서 값을 설정할 수 있는 스토어를 만드는 함수예요. setupdate 메서드를 가진 객체로 생성돼요.

  • set — 인자 하나(설정할 값)를 받아요. 현재 값과 같지 않을 때만 스토어 값을 그 인자로 설정해요.
  • update — 인자 하나(콜백)를 받아요. 콜백은 기존 스토어 값을 인자로 받고 새 값을 반환해요.
/// file: store.js
import { writable } from 'svelte/store';

const count = writable(0);

count.subscribe((value) => {
	console.log(value);
}); // logs '0'

count.set(1); // logs '1'

count.update((n) => n + 1); // logs '2'

두 번째 인자로 함수를 넘기면, 구독자가 0에서 1로 늘어날 때(1에서 2로 늘 때는 아님) 그 함수가 호출돼요. 이 함수는 스토어 값을 바꾸는 set 과, 기존 값에서 새 값을 계산하는 update 를 받아요. 그리고 구독자 수가 1에서 0으로 줄 때 호출되는 stop 함수를 반환해야 해요.

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

const count = writable(0, () => {
	console.log('got a subscriber');
	return () => console.log('no more subscribers');
});

count.set(1); // does nothing

const unsubscribe = count.subscribe((value) => {
	console.log(value);
}); // logs 'got a subscriber', then '1'

unsubscribe(); // logs 'no more subscribers'

writable 값은 파괴되면(예: 페이지 새로고침) 사라져요. localStorage 같은 곳에 직접 동기화하는 로직을 짜면 돼요.

readable

외부에서 값을 설정할 수 없는 스토어를 만들어요. 첫 번째 인자가 초기값이고, 두 번째 인자는 writable 의 두 번째 인자와 같아요.

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

하나 이상의 스토어에서 파생 스토어를 만들어요. 콜백은 첫 구독자가 구독할 때 처음 실행되고, 그 후로는 스토어 의존성이 바뀔 때마다 실행돼요.

가장 단순한 형태는 단일 스토어를 받아 파생 값을 반환하는 거예요.

import { derived } from 'svelte/store';

const doubled = derived(a, ($a) => $a * 2);

콜백은 두 번째 인자 set 과 선택적 세 번째 인자 update 를 받아 값을 비동기로 설정할 수 있어요. 이 경우 set/update 가 처음 호출되기 전까지 쓸 파생 스토어의 초기값을 세 번째 인자로 derived 에 넘길 수도 있어요. 초기값을 지정하지 않으면 undefined 예요.

import { derived } from 'svelte/store';

const delayed = derived(
	a,
	($a, set) => {
		setTimeout(() => set($a), 1000);
	},
	2000
);

const delayedIncrement = derived(a, ($a, set, update) => {
	set($a);
	setTimeout(() => update((x) => x + 1), 1000);
	// every time $a produces a value, this produces two
	// values, $a immediately and then $a + 1 a second later
});

콜백에서 함수를 반환하면, a) 콜백이 다시 실행되거나 b) 마지막 구독자가 구독을 해제할 때 그 함수가 호출돼요.

import { derived } from 'svelte/store';

const tick = derived(
	frequency,
	($frequency, set) => {
		const interval = setInterval(() => {
			set(Date.now());
		}, 1000 / $frequency);

		return () => {
			clearInterval(interval);
		};
	},
	2000
);

두 경우 모두 첫 번째 인자로 단일 스토어 대신 배열을 넘길 수 있어요.

import { derived } from 'svelte/store';

const summed = derived([a, b], ([$a, $b]) => $a + $b);

const delayed = derived([a, b], ([$a, $b], set) => {
	setTimeout(() => set($a + $b), 1000);
});

readonly

이 단순한 도우미 함수는 스토어를 읽기 전용으로 만들어요. 원본 스토어의 변화는 여전히 이 read-only 스토어로 구독할 수 있어요.

import { readonly, writable } from 'svelte/store';

const writableStore = writable(1);
const readableStore = readonly(writableStore);

readableStore.subscribe(console.log);

writableStore.set(2); // console: 2
// @errors: 2339
readableStore.set(2); // ERROR

get

일반적으로 스토어 값은 구독해서 시간에 따라 변하는 값을 읽는 게 좋아요. 가끔 구독하지 않은 스토어의 값을 한 번 꺼내야 할 때가 있는데, get 이 그 일을 해요.

⚠️ get 은 구독을 만들고 값을 읽고 구독을 해제하는 방식으로 동작해요. 그래서 성능이 중요한 코드 경로에서는 권장하지 않아요.

import { get } from 'svelte/store';

const value = get(store);

Store contract

store = { subscribe: (subscription: (value: any) => void) => (() => void), set?: (value: any) => void }

svelte/store 에 의존하지 않고 직접 스토어를 만들 수도 있어요. store contract만 구현하면 돼요.

  1. 스토어는 .subscribe 메서드를 가져야 하고, 그 인자로 구독 함수를 받아요. .subscribe 가 호출되면 이 구독 함수는 스토어의 현재 값과 함께 즉시·동기적으로 호출돼야 해요. 이후 스토어 값이 바뀔 때마다 활성 구독 함수들이 동기적으로 호출돼야 해요.
  2. .subscribe 는 구독 해제 함수를 반환해야 해요. 구독 해제 함수를 호출하면 해당 구독은 중단되고, 그 구독 함수는 더 이상 호출되지 않아야 해요.
  3. 스토어는 선택적으로 .set 메서드를 가질 수 있어요. .set 은 스토어의 새 값을 인자로 받고, 활성 구독 함수를 모두 동기적으로 호출해요. 이런 스토어를 writable store 라고 불러요.

RxJS Observable과의 호환을 위해, .subscribe 는 구독 해제 함수 대신 .unsubscribe 메서드를 가진 객체를 반환해도 돼요. 다만 .subscribe 가 (Observable 스펙상 필수는 아닌) 동기 호출을 하지 않으면, 그때까지 Svelte는 스토어 값을 undefined 로 볼 수 있어요.

더 알아보기