17.7 실전 고수 팁
Svelte와 SvelteKit을 실전 프로젝트에서 효과적으로 활용하기 위한 고급 패턴과 최적화 기법을 소개합니다.
$lib 별칭 경로 활용
SvelteKit은 src/lib 폴더를 $lib로 참조할 수 있습니다. 깊은 상대 경로를 피하고 코드를 명확하게 만듭니다.
src/lib/
├── components/
│ ├── ui/
│ │ ├── Button.svelte
│ │ ├── Modal.svelte
│ │ └── Input.svelte
│ └── layout/
│ ├── Header.svelte
│ └── Footer.svelte
├── stores/
│ ├── auth.js
│ └── theme.js
├── utils/
│ ├── date.js
│ ├── format.js
│ └── validation.js
├── server/ # 서버 전용 코드 (클라이언트에 노출 안 됨)
│ ├── db.js
│ └── email.js
└── types.d.ts # TypeScript 타입 정의
<!-- 상대 경로 대신 $lib 사용 -->
<script>
// 비권장
import Button from '../../../components/ui/Button.svelte';
import { formatDate } from '../../../../utils/date.js';
// 권장
import Button from '$lib/components/ui/Button.svelte';
import { formatDate } from '$lib/utils/date.js';
</script>
추가 별칭 설정
// svelte.config.js
export default {
kit: {
alias: {
$lib: 'src/lib',
$components: 'src/lib/components',
$stores: 'src/lib/stores',
$utils: 'src/lib/utils',
$types: 'src/lib/types.d.ts',
},
},
};
$props() Rune — 컴포넌트 Props
Svelte 5에서 컴포넌트 props는 $props() Rune으로 선언합니다.
<!-- Button.svelte -->
<script>
// 기본 props 선언
let {
label = '클릭',
variant = 'primary',
size = 'medium',
disabled = false,
onclick,
} = $props();
const sizeClasses = {
small: 'btn-sm',
medium: 'btn-md',
large: 'btn-lg',
};
</script>
<button
class="btn btn-{variant} {sizeClasses[size]}"
{disabled}
{onclick}
>
{label}
</button>
Rest props (나머지 props)
<!-- Input.svelte -->
<script>
// 알려진 props만 추출, 나머지는 input 요소에 전달
let { label, value = $bindable(''), ...restProps } = $props();
</script>
<div class="field">
{#if label}
<label>{label}</label>
{/if}
<!-- ...restProps로 placeholder, type, maxlength 등 자동 전달 -->
<input bind:value {...restProps} />
</div>
TypeScript와 props
<!-- Card.svelte -->
<script lang="ts">
interface Props {
title: string;
description?: string;
imageUrl?: string;
href?: string;
variant?: 'default' | 'featured' | 'compact';
onclick?: () => void;
}
let {
title,
description = '',
imageUrl,
href,
variant = 'default',
onclick,
}: Props = $props();
</script>
$bindable() — 양방향 바인딩 Props
부모 컴포넌트에서 bind: 지시자를 사용할 수 있도록 props를 양방향 바인딩 가능하게 만듭니다.
<!-- TextInput.svelte -->
<script>
let { value = $bindable(''), placeholder = '', ...rest } = $props();
</script>
<input bind:value {placeholder} {...rest} />
<!-- 부모 컴포넌트 -->
<script>
import TextInput from '$lib/components/TextInput.svelte';
let username = $state('');
let email = $state('');
</script>
<!-- bind:value로 양방향 바인딩 가능 -->
<TextInput bind:value={username} placeholder="사용자명" />
<TextInput bind:value={email} placeholder="이메일" type="email" />
<p>입력: {username} / {email}</p>
체크박스 그룹 컴포넌트
<!-- CheckboxGroup.svelte -->
<script>
let {
options,
selected = $bindable([]),
label = '',
} = $props();
</script>
<fieldset>
{#if label}<legend>{label}</legend>{/if}
{#each options as option}
<label>
<input
type="checkbox"
bind:group={selected}
value={option.value}
/>
{option.label}
</label>
{/each}
</fieldset>
이벤트 전달 vs Callback Props
Svelte 5에서는 이벤트 전달 방식이 단순화되었습니다.
Svelte 4 이벤트 전달 (레거시)
<!-- Svelte 4 -->
<button on:click>클릭</button> <!-- 이벤트 자동 전달 -->
Svelte 5 Callback Props (권장)
<!-- ModalDialog.svelte -->
<script>
let { isOpen = false, onclose, onconfirm, title, children } = $props();
</script>
{#if isOpen}
<div class="modal-overlay" onclick={onclose}>
<div class="modal" onclick|stopPropagation>
<header>
<h2>{title}</h2>
<button onclick={onclose}>×</button>
</header>
<div class="body">
{@render children?.()}
</div>
<footer>
<button onclick={onclose}>취소</button>
<button onclick={onconfirm} class="primary">확인</button>
</footer>
</div>
</div>
{/if}
<!-- 부모 -->
<script>
import ModalDialog from '$lib/components/ModalDialog.svelte';
let showModal = $state(false);
let result = $state('');
function handleConfirm() {
result = '확인됨';
showModal = false;
}
</script>
<button onclick={() => showModal = true}>모달 열기</button>
<ModalDialog
isOpen={showModal}
title="확인하시겠습니까?"
onclose={() => showModal = false}
onconfirm={handleConfirm}
>
<p>이 작업은 되돌릴 수 없습니다.</p>
</ModalDialog>
성능 최적화
\{#key} — 컴포넌트 강제 재마운트
<script>
let userId = $state(1);
</script>
<!-- userId가 바뀔 때 UserProfile을 완전히 재생성 -->
{#key userId}
<UserProfile {userId} />
{/key}
{@const} — 블록 내 연산 최적화
{#each products as product}
{@const subtotal = product.price * product.quantity}
{@const discount = subtotal > 100000 ? subtotal * 0.1 : 0}
{@const final = subtotal - discount}
<div>
<span>{product.name}</span>
<span>{final.toLocaleString()}원</span>
{#if discount > 0}
<span class="discount">({discount.toLocaleString()}원 할인)</span>
{/if}
</div>
{/each}
지연 로딩 (Lazy Loading)
<!-- SvelteKit에서 동적 임포트 -->
<script>
import { onMount } from 'svelte';
let HeavyChart = $state(null);
onMount(async () => {
// 컴포넌트가 마운트된 후 동적으로 로드
const module = await import('$lib/components/HeavyChart.svelte');
HeavyChart = module.default;
});
</script>
{#if HeavyChart}
<svelte:component this={HeavyChart} data={chartData} />
{:else}
<div class="skeleton-chart">차트 로딩 중...</div>
{/if}
Svelte 5 스니펫(\{#snippet})과 렌더 태그({@render})
Svelte 5에서 도입된 스니펫은 재사용 가능한 마크업 조각을 정의합니다. slot의 진화된 버전입니다.
기본 스니펫
<!-- DataTable.svelte -->
<script>
let { data, columns, rowActions } = $props();
</script>
<table>
<thead>
<tr>
{#each columns as col}
<th>{col.label}</th>
{/each}
{#if rowActions}
<th>액션</th>
{/if}
</tr>
</thead>
<tbody>
{#each data as row}
<tr>
{#each columns as col}
<td>{row[col.key]}</td>
{/each}
{#if rowActions}
<td>{@render rowActions(row)}</td>
{/if}
</tr>
{/each}
</tbody>
</table>
<!-- 사용 측 -->
<script>
import DataTable from '$lib/components/DataTable.svelte';
import { goto } from '$app/navigation';
const users = [
{ id: 1, name: '홍길동', email: 'hong@example.com', role: 'admin' },
{ id: 2, name: '김영희', email: 'kim@example.com', role: 'user' },
];
const columns = [
{ key: 'name', label: '이름' },
{ key: 'email', label: '이메일' },
{ key: 'role', label: '역할' },
];
</script>
<DataTable {data={users}} {columns}>
{#snippet rowActions(row)}
<button onclick={() => goto(`/admin/users/${row.id}`)}>수정</button>
<button onclick={() => deleteUser(row.id)} class="danger">삭제</button>
{/snippet}
</DataTable>
컴포넌트 내 스니펫 재사용
<!-- 같은 파일 내 스니펫 정의 및 재사용 -->
<script>
let items = $state(['사과', '바나나', '체리']);
</script>
{#snippet item(text, index)}
<li class="item">
<span class="index">{index + 1}</span>
<span>{text}</span>
</li>
{/snippet}
<ul>
{#each items as text, i}
{@render item(text, i)}
{/each}
</ul>
폴더 구조 베스트 프랙티스
src/
├── lib/
│ ├── components/
│ │ ├── ui/ # 원자적 UI 컴포넌트 (Button, Input 등)
│ │ ├── forms/ # 폼 관련 컴포넌트
│ │ ├── layout/ # 레이아웃 컴포넌트 (Header, Sidebar)
│ │ └── features/ # 기능별 컴포넌트 (CartWidget, AuthModal)
│ ├── stores/
│ │ ├── auth.svelte.js # 인증 상태
│ │ ├── cart.svelte.js # 장바구니
│ │ └── ui.svelte.js # UI 상태 (모달, 토스트 등)
│ ├── utils/
│ │ ├── api.js # API 클라이언트
│ │ ├── date.js # 날짜 포맷팅
│ │ └── validation.js # 유효성 검사
│ ├── server/ # 서버 전용 (절대 클라이언트에 노출 안 됨)
│ │ ├── db.js
│ │ └── auth.js
│ └── types/ # TypeScript 타입
│ └── index.d.ts
└── routes/
├── (marketing)/ # 마케팅 페이지 그룹
│ ├── +layout.svelte
│ ├── +page.svelte
│ └── about/
├── (app)/ # 앱 기능 그룹 (인증 필요)
│ ├── +layout.server.js # 인증 확인
│ ├── dashboard/
│ └── settings/
└── api/ # API 엔드포인트
└── v1/
테스팅 (Vitest + @testing-library/svelte)
설치
npm install -D @testing-library/svelte @testing-library/jest-dom vitest jsdom
vite.config.js 테스트 설정
import { defineConfig } from 'vite';
import { sveltekit } from '@sveltejs/kit/vite';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
globals: true,
environment: 'jsdom',
setupFiles: ['./src/setupTests.js'],
},
});
// src/setupTests.js
import '@testing-library/jest-dom';
컴포넌트 테스트
// src/lib/components/Counter.test.js
import { render, screen, fireEvent } from '@testing-library/svelte';
import { describe, it, expect } from 'vitest';
import Counter from './Counter.svelte';
describe('Counter 컴포넌트', () => {
it('초기값을 렌더링한다', () => {
render(Counter, { props: { initialCount: 5, label: '테스트' } });
expect(screen.getByText(/테스트: 5/)).toBeInTheDocument();
});
it('+1 버튼 클릭 시 값이 증가한다', async () => {
render(Counter, { props: { initialCount: 0 } });
const button = screen.getByText('+1');
await fireEvent.click(button);
expect(screen.getByText(/1/)).toBeInTheDocument();
});
it('초기화 버튼이 값을 리셋한다', async () => {
render(Counter, { props: { initialCount: 10 } });
const incrementBtn = screen.getByText('+1');
const resetBtn = screen.getByText('초기화');
await fireEvent.click(incrementBtn);
await fireEvent.click(resetBtn);
expect(screen.getByText(/10/)).toBeInTheDocument();
});
});
스토어 테스트
// src/lib/stores/counter.test.js
import { describe, it, expect } from 'vitest';
import { get } from 'svelte/store';
import { counter } from './counter.js';
describe('counter 스토어', () => {
it('초기값이 올바르다', () => {
expect(get(counter)).toBe(10);
});
it('increment가 동작한다', () => {
counter.increment();
expect(get(counter)).toBe(11);
});
it('reset이 초기값으로 돌아간다', () => {
counter.reset();
expect(get(counter)).toBe(10);
});
});
Svelte Inspector
개발 중 컴포넌트를 클릭하면 해당 소스 파일을 VS Code에서 즉시 엽니다.
// vite.config.js
import { sveltekit } from '@sveltejs/kit/vite';
export default {
plugins: [
sveltekit({
inspector: {
toggleKeyCombo: 'meta-shift', // 단축키 설정
showToggleButton: 'always', // 버튼 항상 표시
toggleButtonPos: 'bottom-left', // 버튼 위치
},
}),
],
};
배포: Cloudflare Pages
# 1. 어댑터 설치
npm install -D @sveltejs/adapter-cloudflare
# 2. svelte.config.js 수정
# (adapter-cloudflare로 변경)
# 3. 빌드
npm run build
# 4. Cloudflare Pages에 연결
# GitHub 저장소를 Cloudflare Pages에 연결하면 자동 배포
// svelte.config.js
import adapter from '@sveltejs/adapter-cloudflare';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
},
};
Cloudflare Pages 환경 변수
// src/routes/api/data/+server.js
export async function GET({ platform }) {
// Cloudflare 환경에서 KV, D1, R2 등 사용
const data = await platform?.env?.MY_KV?.get('key');
return json({ data });
}
배포: Vercel
# 어댑터 설치
npm install -D @sveltejs/adapter-vercel
// svelte.config.js
import adapter from '@sveltejs/adapter-vercel';
export default {
kit: {
adapter: adapter({
runtime: 'edge', // Edge Runtime 사용 (선택)
}),
},
};
실전 예제: 재사용 가능한 Toast 알림 시스템
// src/lib/stores/toast.svelte.js
let toasts = $state([]);
let idCounter = 0;
export function addToast(message, type = 'info', duration = 3000) {
const id = ++idCounter;
toasts.push({ id, message, type });
setTimeout(() => {
removeToast(id);
}, duration);
return id;
}
export function removeToast(id) {
const index = toasts.findIndex(t => t.id === id);
if (index !== -1) toasts.splice(index, 1);
}
export { toasts };
<!-- src/lib/components/ToastContainer.svelte -->
<script>
import { fly, fade } from 'svelte/transition';
import { flip } from 'svelte/animate';
import { toasts, removeToast } from '$lib/stores/toast.svelte.js';
</script>
<div class="toast-container" aria-live="polite">
{#each toasts as toast (toast.id)}
<div
class="toast toast-{toast.type}"
role="alert"
animate:flip
in:fly={{ x: 300, duration: 300 }}
out:fade={{ duration: 200 }}
>
<span>{toast.message}</span>
<button onclick={() => removeToast(toast.id)} aria-label="닫기">×</button>
</div>
{/each}
</div>
<style>
.toast-container {
position: fixed;
bottom: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 9999;
}
.toast {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 8px;
min-width: 280px;
max-width: 400px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
color: white;
}
.toast span { flex: 1; }
.toast button { background: none; border: none; color: white; cursor: pointer; opacity: 0.8; }
.toast-info { background: #3b82f6; }
.toast-success { background: #22c55e; }
.toast-warning { background: #f59e0b; }
.toast-error { background: #ef4444; }
</style>
<!-- src/routes/+layout.svelte (루트 레이아웃에 추가) -->
<script>
import ToastContainer from '$lib/components/ToastContainer.svelte';
let { children } = $props();
</script>
{@render children()}
<ToastContainer />
<!-- 어느 페이지에서나 사용 -->
<script>
import { addToast } from '$lib/stores/toast.svelte.js';
</script>
<button onclick={() => addToast('저장되었습니다!', 'success')}>저장</button>
<button onclick={() => addToast('오류가 발생했습니다.', 'error', 5000)}>에러 테스트</button>
Svelte 5 마이그레이션 체크리스트
기존 Svelte 4 프로젝트를 Svelte 5로 마이그레이션할 때:
# 자동 마이그레이션 도구
npx sv migrate svelte-5
| Svelte 4 | Svelte 5 | 비고 |
|---|---|---|
let x = 0 (컴포넌트 최상위) | let x = $state(0) | Runes 파일에서 |
$: y = x * 2 | let y = $derived(x * 2) | |
$: { sideEffect() } | $effect(() => { sideEffect() }) | |
export let name | let { name } = $props() | |
on:click={fn} | onclick={fn} | HTML 이벤트 속성 |
<slot> | {@render children()} | 스니펫 기반 |
createEventDispatcher | Callback props |
고수 팁 모음
팁 1: 반응형 미디어 쿼리
<!-- src/lib/stores/mediaQuery.svelte.js -->
<script module>
function createMediaQuery(query) {
let matches = $state(false);
if (typeof window !== 'undefined') {
const mq = window.matchMedia(query);
matches = mq.matches;
mq.addEventListener('change', (e) => { matches = e.matches; });
}
return { get matches() { return matches; } };
}
export const isMobile = createMediaQuery('(max-width: 768px)');
export const isDark = createMediaQuery('(prefers-color-scheme: dark)');
</script>
팁 2: 폼 유효성 검사 라이브러리 연동
npm install sveltekit-superforms zod
<script>
import { superForm } from 'sveltekit-superforms';
import { zodClient } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
const schema = z.object({
name: z.string().min(2, '이름은 2자 이상이어야 합니다.'),
email: z.string().email('유효한 이메일을 입력해주세요.'),
});
const { form, errors, enhance } = superForm(data.form, {
validators: zodClient(schema),
});
</script>
<form use:enhance method="POST">
<input bind:value={$form.name} name="name" />
{#if $errors.name}<p>{$errors.name}</p>{/if}
<input bind:value={$form.email} name="email" type="email" />
{#if $errors.email}<p>{$errors.email}</p>{/if}
<button>제출</button>
</form>
팁 3: 접근성(a11y) 내장 경고 활용
Svelte 컴파일러는 기본적으로 접근성 문제를 경고합니다:
<!-- 이런 경고를 무시하지 마세요 -->
<!-- a11y: <img> 요소에는 alt 속성이 필요합니다 -->
<img src="..." />
<!-- 올바른 작성 -->
<img src="..." alt="상품 이미지" />
<!-- 경고를 의도적으로 무시할 때 -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div onclick={handler}>...</div>
정리
| 팁 | 핵심 내용 |
|---|---|
$lib 별칭 | 깔끔한 임포트 경로 |
$props() | 명시적 props 선언 |
$bindable() | 양방향 바인딩 허용 |
| Callback props | 이벤트 전달보다 명확 |
{#snippet} | 재사용 가능한 마크업 조각 |
{@render} | 스니펫 호출 |
.svelte.js | 컴포넌트 외부 Runes |
| 어댑터 선택 | 배포 환경에 맞게 |
| Vitest | 빠른 단위/컴포넌트 테스트 |
Svelte 5와 SvelteKit을 마스터했습니다! 다음 단계로 Solid.js(Ch 18)나 Qwik(Ch 19)을 탐험해보세요.