본문으로 건너뛰기
Advertisement

17.4 템플릿 문법

Svelte의 템플릿 문법은 HTML을 기반으로 하면서, 동적 콘텐츠를 위한 특수 블록과 지시자를 제공합니다. 모든 로직 블록은 {#...}, {:...}, {/...} 형식을 따릅니다.


Svelte 템플릿 기본

<script>
let name = $state('Svelte');
let version = $state(5);
</script>

<!-- 표현식 삽입 -->
<h1>안녕하세요, {name}!</h1>
<p>버전: {version}</p>

<!-- JavaScript 표현식 사용 가능 -->
<p>{name.toUpperCase()} v{version * 1.0}</p>

<!-- HTML 안전하지 않은 렌더링 (주의!) -->
<p>{@html '<strong>굵은 텍스트</strong>'}</p>

{@html}은 XSS 공격에 취약할 수 있으므로, 신뢰할 수 있는 콘텐츠에만 사용하세요.


\{#if} — 조건 렌더링

<script>
let score = $state(75);
let isLoggedIn = $state(false);
let userRole = $state('user');
</script>

<!-- 기본 if -->
{#if score >= 90}
<p>🏆 우수</p>
{:else if score >= 70}
<p>✅ 합격</p>
{:else if score >= 50}
<p>⚠️ 보통</p>
{:else}
<p>❌ 불합격</p>
{/if}

<!-- 인증 예제 -->
{#if isLoggedIn}
{#if userRole === 'admin'}
<AdminPanel />
{:else}
<UserDashboard />
{/if}
{:else}
<LoginForm />
{/if}

\{#each} — 루프 렌더링

기본 사용법

<script>
let fruits = $state(['사과', '바나나', '체리', '포도']);

let users = $state([
{ id: 1, name: '홍길동', age: 25 },
{ id: 2, name: '김영희', age: 30 },
{ id: 3, name: '이철수', age: 28 },
]);
</script>

<!-- 기본 each -->
<ul>
{#each fruits as fruit}
<li>{fruit}</li>
{/each}
</ul>

<!-- 인덱스 포함 -->
<ol>
{#each fruits as fruit, index}
<li>{index + 1}. {fruit}</li>
{/each}
</ol>

<!-- 객체 배열 -->
{#each users as user}
<div>{user.name} ({user.age}세)</div>
{/each}

<!-- 구조 분해 -->
{#each users as { id, name, age }}
<div key={id}>{name} - {age}세</div>
{/each}

키(Key)를 사용한 효율적 렌더링

키를 지정하면 Svelte가 각 항목을 식별하여 효율적으로 DOM을 업데이트합니다.

<script>
let todos = $state([
{ id: 1, text: '장보기', done: false },
{ id: 2, text: '운동하기', done: true },
{ id: 3, text: '독서', done: false },
]);

function addTodo() {
todos.unshift({ id: Date.now(), text: '새 할일', done: false });
}

function removeTodo(id) {
todos = todos.filter(t => t.id !== id);
}
</script>

<!-- (todo.id)가 키 -->
{#each todos as todo (todo.id)}
<div>
<input type="checkbox" bind:checked={todo.done} />
<span class:done={todo.done}>{todo.text}</span>
<button onclick={() => removeTodo(todo.id)}>삭제</button>
</div>
{:else}
<p>할 일이 없습니다.</p>
{/each}

키가 없으면 Svelte는 인덱스 기반으로 업데이트하여 항목 추가/제거 시 예상치 못한 동작이 발생할 수 있습니다.


\{#await} — 비동기 처리

<script>
async function fetchUser(id) {
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
if (!res.ok) throw new Error('사용자를 찾을 수 없습니다.');
return res.json();
}

let userId = $state(1);
let userPromise = $derived(fetchUser(userId));
</script>

{#await userPromise}
<!-- 로딩 중 -->
<p>불러오는 중...</p>
{:then user}
<!-- 성공 -->
<div>
<h2>{user.name}</h2>
<p>이메일: {user.email}</p>
<p>전화: {user.phone}</p>
</div>
{:catch error}
<!-- 오류 -->
<p class="error">오류: {error.message}</p>
{/await}

<button onclick={() => userId = userId % 10 + 1}>다음 사용자</button>

단순화된 await (결과만 필요할 때)

<script>
let dataPromise = fetch('/api/data').then(r => r.json());
</script>

<!-- 로딩 상태 없이 결과만 처리 -->
{#await dataPromise then data}
<p>{data.message}</p>
{/await}

\{#key} — 강제 재렌더링

값이 변경될 때 컴포넌트를 완전히 재생성합니다.

<script>
import { fly } from 'svelte/transition';
let currentPage = $state(1);
</script>

<!-- currentPage가 바뀔 때마다 내용이 새로 생성됨 -->
{#key currentPage}
<div in:fly={{ x: 200 }}>
<h2>페이지 {currentPage}</h2>
<PageContent page={currentPage} />
</div>
{/key}

<button onclick={() => currentPage++}>다음 페이지</button>

{@const} — 로컬 상수 선언

블록 내에서 재사용할 계산 값을 상수로 선언합니다.

<script>
let products = $state([
{ name: '노트북', price: 1200000, quantity: 2 },
{ name: '마우스', price: 35000, quantity: 5 },
]);
</script>

{#each products as product}
{@const subtotal = product.price * product.quantity}
{@const formatted = subtotal.toLocaleString('ko-KR')}
<div>
<span>{product.name}</span>
<span>{formatted}원 ({product.quantity}개)</span>
</div>
{/each}

이벤트 핸들링

Svelte 5 이벤트 문법

Svelte 5는 표준 HTML 이벤트 속성과 동일한 방식을 사용합니다.

<script>
let count = $state(0);
let log = $state([]);

function handleClick() {
count++;
}

function handleInput(event) {
log.push(event.target.value);
}

// 이벤트 객체 직접 사용
function handleMouseMove(event) {
console.log(event.clientX, event.clientY);
}
</script>

<!-- 함수 참조 -->
<button onclick={handleClick}>클릭 ({count})</button>

<!-- 인라인 핸들러 -->
<button onclick={() => count++}>인라인 (+1)</button>

<!-- 이벤트 객체 전달 -->
<input oninput={handleInput} />
<div onmousemove={handleMouseMove}>마우스 이동 영역</div>

<!-- 폼 제출 -->
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
<button type="submit">제출</button>
</form>

Svelte 4 vs Svelte 5 이벤트 문법 비교

<!-- Svelte 4 (레거시, 여전히 동작) -->
<button on:click={handler}>클릭</button>
<input on:input={handler} />
<form on:submit|preventDefault={handler}>...</form>

<!-- Svelte 5 (권장) -->
<button onclick={handler}>클릭</button>
<input oninput={handler} />
<form onsubmit={(e) => { e.preventDefault(); handler(e); }}>...</form>

바인딩

bind:value — 양방향 바인딩

<script>
let text = $state('');
let number = $state(0);
let isChecked = $state(false);
let selectedOption = $state('option1');
let selectedMultiple = $state([]);
</script>

<!-- 텍스트 입력 -->
<input bind:value={text} placeholder="텍스트 입력" />
<p>입력값: {text}</p>

<!-- 숫자 (자동으로 number 타입 변환) -->
<input type="number" bind:value={number} />
<p>두 배: {number * 2}</p>

<!-- 체크박스 -->
<input type="checkbox" bind:checked={isChecked} />
<p>체크됨: {isChecked}</p>

<!-- select -->
<select bind:value={selectedOption}>
<option value="option1">옵션 1</option>
<option value="option2">옵션 2</option>
</select>

<!-- 다중 select -->
<select multiple bind:value={selectedMultiple}>
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
</select>

bind:this — DOM 요소 참조

<script>
import { onMount } from 'svelte';

let canvas;
let inputEl;

onMount(() => {
// DOM 요소에 직접 접근
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ff3e00';
ctx.fillRect(10, 10, 100, 100);

inputEl.focus();
});
</script>

<canvas bind:this={canvas} width="200" height="200"></canvas>
<input bind:this={inputEl} placeholder="자동 포커스" />

그룹 바인딩

<script>
let selectedColors = $state([]);
let selectedSize = $state('M');
</script>

<!-- 체크박스 그룹 -->
<fieldset>
<legend>색상 선택 (복수 선택 가능)</legend>
{#each ['빨강', '초록', '파랑', '노랑'] as color}
<label>
<input type="checkbox" bind:group={selectedColors} value={color} />
{color}
</label>
{/each}
</fieldset>
<p>선택된 색상: {selectedColors.join(', ') || '없음'}</p>

<!-- 라디오 그룹 -->
<fieldset>
<legend>사이즈 선택</legend>
{#each ['S', 'M', 'L', 'XL'] as size}
<label>
<input type="radio" bind:group={selectedSize} value={size} />
{size}
</label>
{/each}
</fieldset>
<p>선택된 사이즈: {selectedSize}</p>

트랜지션

기본 트랜지션

<script>
import { fade, fly, slide, scale, blur, draw } from 'svelte/transition';
import { flip } from 'svelte/animate';
import { quintOut } from 'svelte/easing';

let visible = $state(true);
let items = $state(['사과', '바나나', '체리']);

function addItem() {
items.unshift('새 항목 ' + Date.now());
}

function removeItem(index) {
items.splice(index, 1);
}
</script>

<!-- fade: 서서히 나타나고 사라짐 -->
{#if visible}
<div transition:fade={{ duration: 300 }}>
페이드 트랜지션
</div>
{/if}

<!-- fly: 방향과 함께 이동 -->
{#if visible}
<div transition:fly={{ y: -20, duration: 400 }}>
플라이 트랜지션
</div>
{/if}

<!-- slide: 슬라이드 -->
{#if visible}
<div transition:slide>
슬라이드 트랜지션
</div>
{/if}

<!-- in/out 개별 설정 -->
{#if visible}
<div in:fly={{ x: -200 }} out:fade>
비대칭 트랜지션
</div>
{/if}

<button onclick={() => visible = !visible}>토글</button>

animate:flip — 목록 애니메이션

<script>
import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition';

let items = $state([
{ id: 1, name: '사과' },
{ id: 2, name: '바나나' },
{ id: 3, name: '체리' },
{ id: 4, name: '포도' },
]);

function remove(id) {
items = items.filter(item => item.id !== id);
}

function shuffle() {
items = [...items].sort(() => Math.random() - 0.5);
}
</script>

<button onclick={shuffle}>셔플</button>

<ul>
{#each items as item (item.id)}
<li animate:flip={{ duration: 300 }} transition:fly={{ x: 200 }}>
{item.name}
<button onclick={() => remove(item.id)}>×</button>
</li>
{/each}
</ul>

실전 예제 1: 검색 필터 목록

<!-- SearchFilter.svelte -->
<script>
import { fly } from 'svelte/transition';

const ALL_ITEMS = [
{ id: 1, name: '노트북', category: '전자제품', price: 1200000 },
{ id: 2, name: '아이폰', category: '전자제품', price: 1500000 },
{ id: 3, name: '청바지', category: '의류', price: 89000 },
{ id: 4, name: '운동화', category: '신발', price: 150000 },
{ id: 5, name: '백팩', category: '가방', price: 75000 },
{ id: 6, name: '태블릿', category: '전자제품', price: 800000 },
{ id: 7, name: '후드티', category: '의류', price: 55000 },
{ id: 8, name: '부츠', category: '신발', price: 220000 },
];

const CATEGORIES = ['전체', ...new Set(ALL_ITEMS.map(i => i.category))];

let searchQuery = $state('');
let selectedCategory = $state('전체');
let sortBy = $state('name');

let filteredItems = $derived.by(() => {
let result = ALL_ITEMS;

// 카테고리 필터
if (selectedCategory !== '전체') {
result = result.filter(item => item.category === selectedCategory);
}

// 검색어 필터
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter(item => item.name.toLowerCase().includes(query));
}

// 정렬
return [...result].sort((a, b) => {
if (sortBy === 'name') return a.name.localeCompare(b.name);
if (sortBy === 'price-asc') return a.price - b.price;
if (sortBy === 'price-desc') return b.price - a.price;
return 0;
});
});

const formatPrice = (n) => n.toLocaleString('ko-KR') + '원';
</script>

<div class="search-page">
<h1>상품 검색</h1>

<div class="filters">
<input
bind:value={searchQuery}
placeholder="상품명 검색..."
class="search-input"
/>

<div class="category-btns">
{#each CATEGORIES as cat}
<button
class:active={selectedCategory === cat}
onclick={() => selectedCategory = cat}
>
{cat}
</button>
{/each}
</div>

<select bind:value={sortBy}>
<option value="name">이름순</option>
<option value="price-asc">가격 낮은순</option>
<option value="price-desc">가격 높은순</option>
</select>
</div>

<p class="count">{filteredItems.length}개 상품</p>

{#if filteredItems.length === 0}
<div class="empty" in:fly={{ y: 20 }}>
<p>검색 결과가 없습니다.</p>
</div>
{:else}
<div class="grid">
{#each filteredItems as item (item.id)}
<div class="card" in:fly={{ y: 20, duration: 200 }}>
<div class="category-badge">{item.category}</div>
<h3>{item.name}</h3>
<p class="price">{formatPrice(item.price)}</p>
</div>
{/each}
</div>
{/if}
</div>

<style>
.search-page { max-width: 800px; margin: 0 auto; padding: 1rem; }

.filters {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}

.search-input {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}

.category-btns { display: flex; gap: 0.5rem; flex-wrap: wrap; }

.category-btns button {
padding: 0.25rem 0.75rem;
border: 1px solid #ccc;
border-radius: 999px;
background: white;
cursor: pointer;
}

.category-btns button.active {
background: #ff3e00;
color: white;
border-color: #ff3e00;
}

.count { color: #666; }

.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
}

.card {
border: 1px solid #eee;
border-radius: 8px;
padding: 1rem;
position: relative;
}

.category-badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: #eee;
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-size: 0.75rem;
}

.price { color: #ff3e00; font-weight: bold; }
.empty { text-align: center; padding: 2rem; color: #999; }
</style>

실전 예제 2: 이미지 갤러리 (await + 트랜지션)

<!-- ImageGallery.svelte -->
<script>
import { fade, scale } from 'svelte/transition';

const UNSPLASH_TOPICS = ['nature', 'architecture', 'food', 'travel', 'technology'];

let selectedTopic = $state('nature');
let selectedImage = $state(null);

async function fetchImages(topic) {
// 실제로는 Unsplash API 등 사용
// 여기서는 placeholder 사용
await new Promise(resolve => setTimeout(resolve, 500));
return Array.from({ length: 12 }, (_, i) => ({
id: i + 1,
src: `https://picsum.photos/seed/${topic}-${i}/400/300`,
alt: `${topic} image ${i + 1}`,
}));
}

let imagesPromise = $derived(fetchImages(selectedTopic));
</script>

<div class="gallery-page">
<h1>이미지 갤러리</h1>

<nav>
{#each UNSPLASH_TOPICS as topic}
<button
class:active={selectedTopic === topic}
onclick={() => selectedTopic = topic}
>
{topic}
</button>
{/each}
</nav>

{#await imagesPromise}
<div class="loading">
{#each Array(12) as _, i}
<div class="skeleton" style="animation-delay: {i * 50}ms"></div>
{/each}
</div>
{:then images}
<div class="grid" in:fade={{ duration: 300 }}>
{#each images as image (image.id)}
<button class="thumb" onclick={() => selectedImage = image}>
<img src={image.src} alt={image.alt} loading="lazy" />
</button>
{/each}
</div>
{:catch error}
<p class="error">이미지를 불러올 수 없습니다: {error.message}</p>
{/await}
</div>

<!-- 라이트박스 -->
{#if selectedImage}
<div
class="lightbox"
transition:fade={{ duration: 200 }}
onclick={() => selectedImage = null}
>
<img
src={selectedImage.src.replace('/400/300', '/1200/900')}
alt={selectedImage.alt}
in:scale={{ duration: 300 }}
/>
<button class="close" onclick={() => selectedImage = null}>×</button>
</div>
{/if}

<style>
nav { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
nav button { padding: 0.4rem 1rem; border: 1px solid #ccc; border-radius: 999px; background: white; cursor: pointer; }
nav button.active { background: #ff3e00; color: white; border-color: #ff3e00; }

.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.5rem;
}

.thumb { border: none; padding: 0; cursor: pointer; overflow: hidden; border-radius: 4px; }
.thumb img { width: 100%; height: 150px; object-fit: cover; transition: transform 0.2s; display: block; }
.thumb:hover img { transform: scale(1.05); }

.loading {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.5rem;
}

.skeleton {
height: 150px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}

@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}

.lightbox {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}

.lightbox img { max-width: 90vw; max-height: 90vh; object-fit: contain; }
.close {
position: absolute;
top: 1rem;
right: 1rem;
background: white;
border: none;
width: 36px;
height: 36px;
border-radius: 50%;
font-size: 1.2rem;
cursor: pointer;
}
</style>

고수 팁

팁 1: 이벤트 수식어 대체 (Svelte 5)

<!-- Svelte 4 수식어 -->
<button on:click|stopPropagation|preventDefault={handler}>

<!-- Svelte 5 대체 -->
<button onclick={(e) => { e.stopPropagation(); e.preventDefault(); handler(e); }}>

팁 2: 커스텀 트랜지션 함수

<script>
function typewriter(node, { speed = 40 }) {
const text = node.textContent;
const duration = text.length * speed;
return {
duration,
tick: (t) => {
const i = Math.trunc(text.length * t);
node.textContent = text.slice(0, i);
},
};
}
</script>

{#if visible}
<p transition:typewriter={{ speed: 30 }}>
타자기 효과로 나타나는 텍스트입니다!
</p>
{/if}

팁 3: \{#each} 성능 최적화

키(key)는 항상 고유하고 안정적인 값을 사용해야 합니다. 배열 인덱스를 키로 사용하면 항목 추가/제거 시 문제가 발생합니다.

<!-- 나쁜 예 -->
{#each items as item, i (i)} <!-- 인덱스는 키로 부적합 -->

<!-- 좋은 예 -->
{#each items as item (item.id)} <!-- 고유 ID 사용 -->

정리

구문역할
{#if}...{:else}...{/if}조건부 렌더링
{#each}...(key)...{/each}반복 렌더링
{#await}...{:then}...{:catch}...{/await}비동기 처리
{#key value}...{/key}강제 재렌더링
{@const}블록 내 로컬 상수
{@html}HTML 직접 삽입
bind:value양방향 데이터 바인딩
transition:진입/이탈 트랜지션
animate:flip목록 재정렬 애니메이션

다음 장에서는 Svelte 스토어(Store)를 통한 전역 상태 관리를 알아봅니다.

Advertisement