프로젝트/Fullstack

[Svelte] 구독 기능을 이용한 토스트 메시지 구현

bingual 2024. 11. 26. 17:34
반응형

소개

 

 

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를 삭제 함으로써 토스트 메시지를 관리할 수 있게 됩니다.

 

마무리



다음 포스팅에선 모달 컴포넌트를 세팅하고 조금 더 상태 값을 유연하게 다룰 수 있는 내용을 다룹니다.