[Svelte] 구독 기능을 이용한 토스트 메시지 구현
소개
GitHub - bingual/sveltekit5: svelte5, 기타 라이브러리 테스트
svelte5, 기타 라이브러리 테스트. Contribute to bingual/sveltekit5 development by creating an account on GitHub.
github.com
해당 프로젝트는 최신 `Svelte 5`와 `SvelteKit`을 기반으로 구축된 프로젝트로, 클린 코드와 유지 보수를 중점적으로 고려한 웹 애플리케이션입니다.
성능 최적화, 재사용 가능한 컴포넌트 설계, 모듈화, 메모리 누수 관리, 중복 제거, 테스트 코드 작성을 목표로 하여 효율적인 개발 환경을 구성하는데 초점을 맞춘 기능 테스트가 목표입니다.
`Svelte 5` 는 2024년 10월 19일 정식으로 릴리즈 되었습니다.
한국에서는 `React`, `Vue`, `Next`와는 다르게 인지도가 낮지만 빠르고 편리하며 프레임워크 내에서 자체적으로 제공하는 기능이 많아 개발 경험이 좋기에 해외에서는 핫한 프레임워크 중 하나입니다.
현재 사용하는 `Svelte 5` 및 기타 라이브러리들은 베타 버전도 포함하고 있기에 버그가 있을 수 있습니다.
앞으로 연재될 Svelte 5 포스팅들은 단순히 기능을 구현하는 것만이 아니라 어떻게 해야 효율적으로 기능들을 구현하고 사용 및 관리하는지에 대해 설명을 진행할 예정입니다.
Sveltekit, Prisma, Tailwind, Auth 등등 해당 프로젝트에 사용되는 라이브러리에 대한 설명은 없으므로 공식문서를 참고 바랍니다.
프로젝트 구조
prisma/ # Prisma 관련 설정 및 파일 폴더
e2e/ # E2E 테스트 코드 폴더
src/ # 소스 코드 루트 폴더
├── lib/ # 재사용 가능한 라이브러리 코드 폴더
│ ├── auth.ts # Oauth 코드
│ ├── prisma.ts # Prisma 인스턴스 코드
│ ├── supabaseClient.ts # Supabase 관련 코드
│ ├── utils/ # 유틸리티 코드 폴더
│ │ ├── mapping.ts # 매핑 코드
│ │ ├── prismaTypes.ts # Prisma 커스텀 타입 코드
│ │ ├── stores.ts # 스토어 코드
│ │ ├── variables.ts # 기타 코드
│ │ └── variables.server.ts # 서버 전용 기타 코드
│ │ └── schema.ts # Zod 유효성 검사 코드
│ └── components/ # Svelte 컴포넌트 폴더
├── routes/ # SvelteKit 라우팅 폴더
└── tests/ # 유닛 테스트 코드 폴더
명시되지 않은 코드 구조가 있을 수 있습니다.
내용 요약
설명은 간단하게 진행되니 자세한 설명은 참조하는 링크를 참고해 주세요.
- 로컬 스토리지 매니저를 구현합니다.
- 구현한 로컬 스토리지 매니저를 사용해 `svelte`의 스토어 구독 시스템을 이용해 영구 스토어를 구현해 봅시다.
- 영구 스토어를 이용해 새로고침 해도 유지되는 토스트 메시지를 만들어 봅시다.
영구 스토어 구현
`variables.ts`
export const localStorageManager = () => {
if (!browser) {
return {
saveToLocalStorage: () => undefined,
loadFromLocalStorage: () => undefined,
clearLocalStorage: () => undefined,
};
}
const saveToLocalStorage = (key: string, value: any) => {
const jsonValue = JSON.stringify(value);
localStorage.setItem(key, jsonValue);
};
const loadFromLocalStorage = (key: string) => {
const jsonValue = localStorage.getItem(key);
return jsonValue ? JSON.parse(jsonValue) : null;
};
const clearLocalStorage = () => {
localStorage.clear();
};
return {
saveToLocalStorage,
loadFromLocalStorage,
clearLocalStorage,
};
};
로컬 스토리지를 관리하는 클로저를 먼저 구성해 줍니다.
클로저로 관리하는 이유는??
자바스크립트는 함수형 언어이자 객체 지향적 특성을 모두 가지고 있습니다. 하지만 함수 기반 접근이 더 표준적이며, 클로저를 사용하면 상태와 동작을 캡슐화해 모듈적으로 관리할 수 있어 유지보수와 확장성 측면에서 더 유연합니다.
이렇게 로컬 스토리지를 세이브, 로드, 클리어하는 함수를 정의하고 불러와서 사용할 겁니다.
`stores.ts`
const persistentStore = (key: string, startValue: any) => {
const { loadFromLocalStorage, saveToLocalStorage } = localStorageManager();
const storedValue = loadFromLocalStorage(key);
const store = writable(storedValue ? storedValue : startValue);
store.subscribe((value) => {
saveToLocalStorage(key, value);
});
return store;
};
로컬 스토리지 매니저 함수에서 구조 분해할당으로 필요한 함수를 선언해 줍니다.
https://svelte.dev/docs/svelte/svelte-store
`writable`은 svelte의 스토어 시스템입니다. 구독을 통해 업데이트와 읽기가 모두 가능한 스토어이죠.
`writable`은 기본 값을 가질 수 있으며 페이지가 언마운트가 되면 값은 초기화됩니다. 이 특성 때문에 항상 값을 유지하기 위해선 로컬 스토리지를 사용하여 다시 불러올 필요성이 있습니다.
`subscribe`은 구독 함수입니다. 선언한 `writable`의 값이 변경될 때마다 동작하기 때문에 내부 스코프에 필요한 로직을 작성할 수 있습니다.
현재 코드의 경우에는 `loadFromLocalStorage`를 통해 스토어의 기본 값을 설정해 주고 해당 스코어의 값이 변경될 때마다`saveToLocalStorage` 함수를 통해 로컬 스토리지의 값을 업데이트해 줍니다.
중요한 점!
`subscribe`은 메모리 릭을 방지하기 위해서 사용하지 않게 되는 경우에는 `unsubscribe` 해줘야 합니다.
하지만 이 함수는 로컬 스토리지의 `Key` 가 항상 존재할 것을 기대하고 사용하기 때문에 따로 구독 취소는 하지 않습니다.
토스트 메시지 구현
` stores.ts `
export const toastStore = () => {
type Toast = {
id: number;
message: string;
counter: number;
status: boolean;
};
const toasts: Writable<Toast[]> = persistentStore('toasts', []);
const addToast = (message: string, counter = 4) => {
const id = Date.now();
toasts?.update((toasts) => [...toasts, { id, message, counter, status: true }]);
decrementCounter(id);
};
const decrementCounter = (id: number) => {
const interval = setInterval(() => {
toasts?.update((toasts) => {
return map(toasts, (toast) => {
if (toast.id === id) {
if (toast.counter <= 1) {
clearInterval(interval);
return { ...toast, status: false };
}
return { ...toast, counter: toast.counter - 1 };
}
return toast;
});
});
}, 1000);
};
const restoreCounters = () => {
const currentToasts = get(toasts);
currentToasts.forEach((toast) => {
if (toast.status && toast.counter > 0) {
decrementCounter(toast.id);
}
});
};
const removeToast = (id: number) => {
toasts?.update((toasts) => filter(toasts, (toast) => toast.id !== id));
};
restoreCounters();
return {
toasts,
addToast,
removeToast,
};
};
앞서 구현한 영구 스토어 함수를 이용해서 페이지가 언마운트 되더라도 토스트 메시지의 상태가 유지되게끔 클로저를 작성할 겁니다.
토스트 메시지를 관리할 `toasts` 변수를 선언합니다.
`addToast`: 함수를 통해 `toasts` 스토어에 내부 값을 업데이트해 줍니다. `update` 메서드는 콜백을 사용하여 현재 스토어의 값을 가져와서 재설정하게 해주는 메서드입니다. 이 경우에는 현재 `toasts`를 전개하고 마지막 열에 새로운 토스트 상태를 추가해 줍니다.
`decrementCounter`: 핵심이 되는 함수입니다. 앞서 정의한 `addToast`에서 호출하여 1초 간격으로 토스트 메시지의 상태를 업데이트하며 관리합니다. 카운트가 0이 되면 status가 false가 되며 이 토스트 메시지는 유효하지 않게끔 만들 수 있겠습니다.
`restoreCounters`: 페이지가 마운트 됐을 때 현재 토스트 메시지가 유효하다면 다시 `decrementCounter `을 사용해서 카운트를 진행해 줍니다.
`removeToast`: 유효하지 않는 토스트를 삭제하는 함수입니다. filter는 바닐라 자바스크립트의 filter가 아닌 rameda 라이브러리입니다. 커링과 파이프 라인을 설정할 수 있게 해주는 유용한 타입스크립트 기반 라이브러리이죠.
`stores.ts`
type ToastStore = ReturnType<typeof toastStore>;
export const useContext = () => {
const toastStore = getContext<ToastStore>('toastStore');
return {
toastStore,
};
};
https://svelte.dev/docs/svelte/context
`context`는 컴포넌트 부모 자식의 계층식 `props` 전달을 하는 불편한 방식을 사용하는 게 아니라 상태 값을 전역으로 관리할 수 있게 해주는 기능입니다.
svelte 또한 set, get context를 제공해 주는데요 타입까지 자동추론 해주지는 않으니 `useContext`라는 커스텀 클로저를 만들어서 관리해 주도록 합니다.
`index.layout.svelte`
<script lang="ts">
...
let { children } = $props();
setContext('toastStore', toastStore());
const {
toastStore: ContextToastStore,
} = useContext();
const { toasts } = ContextToastStore;
...
</script>
...
{#if $toasts}
<Toast />
{/if}
...
https://svelte.dev/docs/kit/routing
svelte에서는 루트 경로의 +layout.page에서 모든 페이지 코드를 중앙관리 할 수 있습니다. 편의상 `index.layout.svelte`라고 정의하겠습니다.
이제 `setContext`를 통해서 Context를 등록하고 커스텀 클로저인 `useContext`에서 정의한 메서드를 불러온다면 전역으로 사용할 수 있겠네요.
`toasts`가 존재할 때 `Toast` 컴포넌트를 렌더링 하고 생명주기를 관리해 주도록 합시다.
`components/layouts/Toast.svelte`
<script lang="ts">
import { Toast } from 'svelte-5-ui-lib';
import { CheckCircleSolid } from 'flowbite-svelte-icons';
import { useContext } from '$lib/utils/stores';
const { toastStore } = useContext();
const { toasts, removeToast } = toastStore;
</script>
<div class="fixed right-0 top-14 z-[9999] flex flex-col items-end space-y-4 p-4">
{#if $toasts && $toasts.length > 0}
{#each $toasts as toast (toast.id)}
<Toast
class="rounded-lg border border-primary-600"
dismissable={false}
toastStatus={toast.status}
onoutroend={() => removeToast(toast.id)}
>
{#snippet icon()}
<CheckCircleSolid class="h-5 w-5" />
{/snippet}
{toast.message}
</Toast>
{/each}
{/if}
</div>
`svelte-5-ui-lib`를 사용해서 미리 정의된 디자인의 컴포넌트를 사용하고 커스텀해서 토스트 메시지 컴포넌트를 구현합니다.
`svelte-5-ui-lib`은 아직 개발 진행 중인 베타 버전이기 때문에 의도치 않은 버그가 존재할 수 있으며 `docs` 또한 아직 미완성입니다. 하지만 해당 프로젝트는 최신 기술 테스트 프로젝트이기 때문에 항상 최신 기술과 버전들을 사용하고 이슈를 찾고 해결하게 될 것이니 감안해 주세요.
`onoutroend`: 애니메이션이 종료될 때 호출 되는 이벤트입니다. `toastStatus`이랑 연계가 되는데요. 만약 해당 토스트 메시지가 유효하지 않게 된다면 애니메이션이 종료되고 `removeToast`를 통해 해당 토스트에 해당하는 id를 삭제 함으로써 토스트 메시지를 관리할 수 있게 됩니다.
마무리
다음 포스팅에선 모달 컴포넌트를 세팅하고 조금 더 상태 값을 유연하게 다룰 수 있는 내용을 다룹니다.