프로젝트/Fullstack

[Svelte] 효율적인 컴포넌트 관리법

bingual 2024. 11. 28. 15:55
반응형

소개

 

 

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/        # 유틸리티 코드 폴더
│   └── components/  # Svelte 컴포넌트 폴더
├── routes/          # SvelteKit 라우팅 폴더
└── tests/           # 유닛 테스트 코드 폴더

명시되지 않은 코드 구조가 있을 수 있습니다.

 

 

내용 요약

설명은 간단하게 진행되니 자세한 설명은 참조하는 링크를 참고해 주세요.

  • `Sidebar.svelte`에서 `$derived`을 이용한 동적 데이터 할당 예제
  • `Sidebar.svelte`에서 데이터를 효율적으로 렌더링 하는 예제
  • `SetMemo.svelte`에서 `custorm snippet`을 활용하는 예제

 

$derived와 객체 렌더링

$derived란??

https://svelte.dev/docs/svelte/$derived

`svelte 5` 에서 새로 추가된 룬 시스템입니다. `$derived`에 할당된 참조되는 대상의 값이 동적으로 변할 때 같이 업데이트되게 해주는 함수입니다.

`Sidebar.svelte`

<script lang="ts">
  ...
  const userInfo = $derived($page.data.session?.user);
  ...
</script>


예를 들어서 `userInfo`변수는 `$derived`을 사용하고 있고 사용자의 세션 데이터를 관리하고 있습니다.

사용자가 로그아웃이나 로그인을 하게 된다면 각각 다른 로직이 적용되고 렌더링 되어야 하는 경우가 있겠죠? 이때 `$derived`은 사용자의 상태를 감지하고 추적해서 현재 상태를 참조할 수 있게 할 수 있죠.


`Sidebar.svelte`

<script lang="ts">
  ...
  const userInfo = $derived($page.data.session?.user);

  const spanClass = 'flex-1 ms-3 whitespace-nowrap';
  const iconClass =
    'h-5 w-5 text-gray-500 transition duration-75 group-hover:text-gray-900 dark:text-gray-400 dark:group-hover:text-white';

  const sidebarUi = uiHelpers();
  let isOpen = $state(false);
  const closeDemoSidebar = sidebarUi.close;

  interface Option {
    border: boolean;
  }

  interface ChildItem {
    childLabel: string;
    childHref: string;
  }

  interface Item {
    label: string;
    href?: string;
    icon: any;
    children?: ChildItem[];
  }

  interface Section {
    options: Option[];
    items: Item[];
  }

  const sections: Section[] = $derived([
    {
      options: [{ border: false }],
      items: [
        { label: '대쉬보드', href: '/', icon: ChartOutline },
        ...(userInfo ? [{ label: '메모', href: '/memo', icon: PenNibOutline }] : []),
      ],
    },
    {
      options: [{ border: true }],
      items: [
        ...(dev && userInfo
          ? [
              {
                label: '테스트',
                icon: LightbulbOutline,
                children: [{ childLabel: '데이터 생성/제거', childHref: '/tests/fake-data' }],
              },
            ]
          : []),
        {
          label: '설정',
          icon: CogOutline,
          children: [{ childLabel: '계정', childHref: '#' }],
        },
      ],
    },
  ]);

  $effect(() => {
    isOpen = sidebarUi.isOpen;
  });
</script>

 


전체적인 `script` 코드를 봐봅시다. 

`sections`변수 또한 `$derived`을 사용하고 있습니다. 이 경우에는 이전에 선언된 `userInfo`의 값이 변경될 때 그에 따라서 `sections` 내부에 `items` 값이 동적으로 변하게 됩니다. 

페이지가 마운트 되고 `sections`에 객체가 할당된 상태로 렌더링이 될 텐데 `$state`을 `sections`에 사용하게 된다면 `userInfo` 값이 변한다 하더라고 항상 마운트 됐을 때 당시의 `sections` 값 만을 참조하게 됩니다.

 

그렇기 때문에 특정 데이터가 변경될 때 부분적인 데이터 렌더링이 다시 필요해지게 되겠죠? 이런 경우에 `$derived`을 사용할 수 있습니다.

독립적으로 변수에 할당하고 사용한다면 코드를 한눈에 파악가능하고 유지보수 하기 쉽겠지만 이렇게 긴 코드들을 전부 `$effect`에 사용하게 되면 굉장히 무거워질 겁니다.

 


`Sidevar.svelte`

<SidebarButton onclick={sidebarUi.toggle} class="mb-2" />
<div class="relative">
  <Sidebar
    class="top-[61px] z-50 h-full"
    isSingle={false}
    backdrop={false}
    {isOpen}
    closeSidebar={closeDemoSidebar}
    params={{ x: -50, duration: 50 }}
    position="fixed"
    activeClass="p-2"
    nonActiveClass="p-2"
  >
    {#each sections as section}
      <SidebarGroup border={section.options[0].border}>
        {#each section.items as item}
          {#if item.children}
            <SidebarDropdownWrapper label={item.label} btnClass="p-2">
              {#snippet iconSlot()}
                <item.icon class={iconClass} />
              {/snippet}
              {#each item.children as child}
                <SidebarItem label={child.childLabel} href={child.childHref} />
              {/each}
            </SidebarDropdownWrapper>
          {:else}
            <SidebarItem {spanClass} label={item.label} href={item.href}>
              {#snippet iconSlot()}
                <item.icon class={iconClass} />
              {/snippet}
            </SidebarItem>
          {/if}
        {/each}
      </SidebarGroup>
    {/each}
  </Sidebar>
</div>


이전에 선언된 `sections` 변수를 사용하여 해당 객체를 위와 같이 렌더링 할 수 있습니다.

물론 `script` 단에서 객체를 구성하지 않고 템플릿 코드단에서 `if`문을 활용한 하드코딩된 렌더링 방법을 쓸 수 도 있겠습니다. 하지만 그럴 경우 중복이 발생하고 하나를 수정하면 끝날 문제를 여러 개를 수정해야 끝나게 될 수 있으니 유지보수 하기가 힘들어집니다.

자주 변경될 필요가 없고 반복적인 구성이 거의 없는 경우라면 템플릿 코드에서 하드코딩 하는 방법이 더 직관적이고 편할 수 도 있습니다.

각각 장단점이 있기 때문에 그에 맞춰서 코드를 구성하는 게 중요한 것이죠.

 

snippet을 사용한 렌더링

 

 

snippet이란??

https://svelte.dev/docs/svelte/snippet

`svelte 5`에서 새로 추가된 렌더링 시스템입니다. 중복을 제거하고 유지보수를 쉽게 해주는 템플릿 전용 코드입니다.

이전에 Modal Componet를 동적으로 렌더링 할 때도 그리고 위에 챕터에서도 사용한 것을 확인할 수 있죠.
이번 챕터에서는 커스텀한 `snippet`을 어떠한 경우에 사용하고 어떻게 사용할 수 있는지 살펴보도록 합시다.


`SetMemo.svelte`

<script lang="ts">
  ...
  let memoData: MemoWithImages | undefined = $derived($modalProps?.data);
  let filePreviews: Writable<FilePreview[]> = writable([]);
 ...
</script> 
    ...
    {#if memoData?.images[0]?.url}
      <Label class="mb-2 mt-5 space-y-2"><span class="text-green-700">등록된 이미지</span></Label>
      <div class="grid grid-cols-4 gap-4 sm:grid-cols-8">
        {#each memoData.images as images, index}
          <Img
            imgClass="object-cover w-24 h-24"
            src={images.url.startsWith('https://') ? images.url : getPublicUrl(images.url)}
            alt={String(index)}
            shadow="md"
          />
        {/each}
      </div>
    {/if}

    {#if !isEmpty($filePreviews)}
      <Label class="mb-2 mt-5 space-y-2"
        ><span class={clsx(memoData ? 'text-red-700' : 'text-green-700')}
          >{actionMap($modalProps?.action).imageLabel}할 이미지</span
        ></Label
      >
      <div class="grid grid-cols-4 gap-4 sm:grid-cols-8">
        {#each $filePreviews as { src, alt }}
          <Img imgClass="object-cover w-24 h-24" {src} {alt} shadow="md" />
        {/each}
      </div>
    {/if}
    ...



템플릿에서 위와 같이 코드를 렌더링 한다면 어떠한 문제가 생길까요?

분명 두 가지 다 크게 다를 게 없는 코드인데 중복이 발생하고 있는 걸 확인할 수 있습니다.

위쪽 라인에서 클래스 스타일을 변경한다고 한다면 아래쪽도 똑같이 맞춰서 변경해주어야 하겠죠? 이런 게 여러 개 더 있다면  `n` 만큼 더 반복해줘야 할 겁니다.

그렇다고 이런 걸 컴포넌트로 관리하기도 애매하고 객체로 관리해서 렌더링 하기도 애매해집니다.


`SetMemo.svelte`

<script lang="ts">
  ...
  let memoData: MemoWithImages | undefined = $derived($modalProps?.data);
  let filePreviews: Writable<FilePreview[]> = writable([]);
 ...
</script> 
    ...
    <!-- 스니펫에서는 현재 타입 지정 불가능 (정확히는 IDE에서 오류나고 적용 한다해도 유지보수 매우 불편해짐)  -->
    {#snippet imageSection(labelClass, label, images, filePreviews)}
      {@const imgClass = clsx('w-24 h-24 object-cover')}

      <Label class="mb-2 mt-5 space-y-2"><span class={labelClass}>{label}</span></Label>
      <div class="grid grid-cols-4 gap-4 sm:grid-cols-8">
        {#if images}
          {#each images as { url }, index}
            <Img
              {imgClass}
              src={url.startsWith('https://') ? url : getPublicUrl(url)}
              alt={String(index)}
              shadow="md"
            />
          {/each}
        {/if}

        {#if filePreviews}
          {#each filePreviews as { src, alt }}
            <Img {imgClass} {src} {alt} shadow="md" />
          {/each}
        {/if}
      </div>
    {/snippet}

    {#if memoData?.images[0]?.url}
      {@render imageSection('text-green-700', '등록된 이미지', memoData?.images, undefined)}
    {/if}

    {#if !isEmpty($filePreviews)}
      {@render imageSection(
        clsx(memoData ? 'text-red-700' : 'text-green-700'),
        `${actionMap($modalProps?.action).imageLabel}할 이미지`,
        undefined,
        $filePreviews,
      )}
    {/if}
    ...


`snippet`을 사용하니 중복이 제거되었고 제공하는 값에 따라 동적으로 렌더링을 할 수 있게 됩니다.

하지만 현재 WebStorm IDE에서 `svelte 5`의 `snippet`을 사용할 때 단점이 하나 존재하는데요. 타입을 지정하는 게 까다롭습니다.

인라인이나 원시 타입 지정하는 게 불가능해서 전부 인터페이스나 타입 클래스를 만들어서 할당해야하죠. 

마무리

 `svelte 5` 공식 문서와 `svelte 4` 문법을 `svelte 5`으로 변환한 예제를 보여주는 사이트 두 가지를 추천하고 이번 포스팅은 여기서 마무리짓겠습니다.

다음 포스팅은 `svelte 5`를 이용한 메모리 관리 방법입니다.


https://svelte.dev/docs/svelte/overview 

 

Overview • Docs • Svelte

Svelte is a framework for building user interfaces on the web. It uses a compiler to turn declarative components written in HTML, CSS and JavaScript... App function greet() { alert('Welcome to Svelte!'); } click me button { font-size: 2em; } ...into lean,

svelte.dev

 

https://component-party.dev/?f=svelte4,svelte5

 

Component Party

Web component JS frameworks overview by their syntax and features: Svelte 5, React, Vue 3, Angular Renaissance, Angular, Lit, Ember Octane, Solid.js, Svelte 4, Vue 2, Alpine, Ember Polaris (preview), Mithril, Aurelia 2, Qwik, Marko, Aurelia 1

component-party.dev