[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/ # 유틸리티 코드 폴더
│ └── 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