본문으로 건너뛰기

6. 조건부 렌더링과 리스트

동적 웹 애플리케이션의 핵심은 데이터에 따라 UI를 유연하게 변경하는 것입니다. Svelte는 {#if}, {#each}, {#await} 같은 템플릿 제어 구조를 제공하여 선언적이고 직관적인 방식으로 조건부 렌더링과 리스트 처리를 가능하게 합니다. 이 장에서는 Svelte의 강력한 제어 구조를 통해 복잡한 UI 로직을 간단하고 효율적으로 구현하는 방법을 완전히 마스터해보겠습니다.


6.1 조건부 렌더링

\{#if} 블록

{#if} 블록은 조건에 따라 DOM 요소를 렌더링하거나 제거하는 가장 기본적인 제어 구조입니다. 조건이 참일 때만 내부 콘텐츠가 렌더링되며, 거짓일 때는 DOM에서 완전히 제거되어 성능이 최적화됩니다. React의 삼항 연산자나 Vue의 v-if보다 더 읽기 쉽고 직관적인 문법을 제공합니다.

기본 사용법

<script>
let isLoggedIn = $state(false);
let username = $state('');

function login() {
isLoggedIn = true;
username = '홍길동';
}

function logout() {
isLoggedIn = false;
username = '';
}
</script>

<div>
{#if isLoggedIn}
<h2>환영합니다, {username}님!</h2>
<p>로그인되었습니다.</p>
<button onclick="{logout}">로그아웃</button>
{/if} {#if !isLoggedIn}
<h2>로그인이 필요합니다</h2>
<button onclick="{login}">로그인</button>
{/if}
</div>

복잡한 조건 처리

<script>
let score = $state(75);
let isLoading = $state(false);
let hasPermission = $state(true);

function updateScore(value) {
score = Math.max(0, Math.min(100, score + value));
}
</script>

<div>
<h2>성적 평가</h2>

<div>
<button onclick="{()" ="">
updateScore(-10)}>-10점
</button>
<span>현재 점수: {score}</span>
<button onclick="{()" ="">
updateScore(10)}>+10점
</button>
</div>

{#if isLoading}
<p>결과를 계산하는 중...</p>
{/if} {#if !isLoading && hasPermission} {#if score >= 90}
<p>등급: A (우수)</p>
{/if} {#if score >= 80 && score < 90}
<p>등급: B (양호)</p>
{/if} {#if score >= 70 && score < 80}
<p>등급: C (보통)</p>
{/if} {#if score < 70}
<p>등급: D (미흡)</p>
{/if} {/if} {#if !hasPermission}
<p>성적 조회 권한이 없습니다.</p>
{/if}
</div>

실습해보기: Svelte REPL에서 이 코드를 직접 실행해보세요!

{:else}, {:else if} 사용법

{:else}{:else if}를 사용하면 여러 조건을 체인으로 연결하여 더 깔끔한 조건부 렌더링을 구현할 수 있습니다. 하나의 블록으로 모든 조건을 처리하여 코드 중복을 줄이고 가독성을 향상시킵니다. 중첩된 {#if} 블록 대신 평탄한 구조로 복잡한 로직을 표현할 수 있습니다.

else와 else if 활용

<script>
let userStatus = $state('guest'); // 'guest', 'member', 'premium', 'admin'
let articleCount = $state(3);

function changeStatus(status) {
userStatus = status;
}
</script>

<div>
<h2>사용자 대시보드</h2>

<select bind:value="{userStatus}">
<option value="guest">게스트</option>
<option value="member">일반 회원</option>
<option value="premium">프리미엄 회원</option>
<option value="admin">관리자</option>
</select>

{#if userStatus === 'admin'}
<div>
<h3>관리자 패널</h3>
<p>모든 권한을 가지고 있습니다.</p>
<button>사용자 관리</button>
<button>시스템 설정</button>
</div>
{:else if userStatus === 'premium'}
<div>
<h3>프리미엄 콘텐츠</h3>
<p>무제한 기사를 읽을 수 있습니다.</p>
<p>이번 달 {articleCount}개의 기사를 읽었습니다.</p>
</div>
{:else if userStatus === 'member'}
<div>
<h3>일반 회원</h3>
<p>월 5개의 기사를 읽을 수 있습니다.</p>
<p>남은 기사: {Math.max(0, 5 - articleCount)}개</p>
</div>
{:else}
<div>
<h3>게스트</h3>
<p>회원가입하면 더 많은 기능을 사용할 수 있습니다.</p>
<button>회원가입</button>
</div>
{/if}
</div>

복잡한 조건 체인

<script>
let age = $state(25);
let hasTicket = $state(false);
let isVIP = $state(false);

function updateAge(value) {
age = Math.max(0, Math.min(100, value));
}
</script>

<div>
<h2>놀이공원 입장</h2>

<div>
<label>
나이:
<input
type="number"
bind:value="{age}"
min="0"
max="100"
/>
</label>

<label>
<input type="checkbox" bind:checked="{hasTicket}" />
티켓 보유
</label>

<label>
<input type="checkbox" bind:checked="{isVIP}" />
VIP 회원
</label>
</div>

{#if age < 5}
<p>5세 미만 무료 입장!</p>
{:else if age >= 5 && age < 13} {#if hasTicket}
<p>어린이 요금: 20,000원</p>
{:else}
<p>티켓을 구매해주세요. (어린이 요금: 20,000원)</p>
{/if} {:else if age >= 13 && age < 65} {#if isVIP}
<p>VIP 회원 무료 입장!</p>
{:else if hasTicket}
<p>성인 요금: 40,000원</p>
{:else}
<p>티켓을 구매해주세요. (성인 요금: 40,000원)</p>
{/if} {:else}
<p>경로 우대: 50% 할인 (20,000원)</p>
{/if}
</div>

실습해보기: Svelte REPL에서 이 코드를 직접 실행해보세요!

논리 연산자 활용

JavaScript의 논리 연산자를 활용하면 더 복잡한 조건을 간결하게 표현할 수 있습니다. &&, ||, ! 연산자를 조합하여 여러 조건을 동시에 평가하고, 삼항 연산자로 간단한 조건부 값을 설정할 수 있습니다. 이러한 패턴들은 코드를 더 간결하고 표현력 있게 만들어줍니다.

<script>
let isAuthenticated = $state(false);
let hasPermission = $state(false);
let isLoading = $state(false);
let errorMessage = $state('');

function toggleAuth() {
isAuthenticated = !isAuthenticated;
if (isAuthenticated) {
hasPermission = Math.random() > 0.5;
} else {
hasPermission = false;
}
}

async function loadData() {
isLoading = true;
errorMessage = '';

await new Promise(resolve => setTimeout(resolve, 1000));

if (Math.random() > 0.3) {
isLoading = false;
} else {
isLoading = false;
errorMessage = '데이터 로드 실패';
}
}
</script>

<div>
<h2>데이터 대시보드</h2>

<button onclick="{toggleAuth}">
{isAuthenticated ? '로그아웃' : '로그인'}
</button>

{#if !isAuthenticated}
<p>로그인이 필요한 서비스입니다.</p>
{/if} {#if isAuthenticated && !hasPermission}
<p>접근 권한이 없습니다.</p>
{/if} {#if isAuthenticated && hasPermission}
<button onclick="{loadData}" disabled="{isLoading}">
데이터 로드
</button>

{#if isLoading}
<p>로딩 중...</p>
{/if} {#if !isLoading && errorMessage}
<p>오류: {errorMessage}</p>
{/if} {#if !isLoading && !errorMessage && isAuthenticated}
<p>데이터가 성공적으로 로드되었습니다!</p>
{/if} {/if}
</div>

6.2 리스트 렌더링

\{#each} 블록

{#each} 블록은 배열이나 반복 가능한 객체를 순회하며 각 항목에 대해 마크업을 렌더링합니다. React의 map 함수나 Vue의 v-for와 유사하지만, 더 선언적이고 읽기 쉬운 문법을 제공합니다. 자동으로 반응성을 처리하여 배열이 변경되면 효율적으로 DOM을 업데이트합니다.

기본 배열 렌더링

<script>
let fruits = $state(['사과', '바나나', '오렌지', '포도']);
let newFruit = $state('');

function addFruit() {
if (newFruit.trim()) {
fruits = [...fruits, newFruit];
newFruit = '';
}
}

function removeFruit(index) {
fruits = fruits.filter((_, i) => i !== index);
}
</script>

<div>
<h2>과일 목록</h2>

<div>
<input
bind:value="{newFruit}"
placeholder="과일 이름"
onkeypress="{(e)"
=""
/>
e.key === 'Enter' && addFruit()} >
<button onclick="{addFruit}">추가</button>
</div>

<ul>
{#each fruits as fruit, index}
<li>
{index + 1}. {fruit}
<button onclick="{()" ="">
removeFruit(index)}>삭제
</button>
</li>
{/each}
</ul>

{#if fruits.length === 0}
<p>과일이 없습니다.</p>
{/if}
</div>

객체 배열 렌더링

<script>
let tasks = $state([
{ id: 1, text: 'Svelte 학습', completed: false },
{ id: 2, text: '프로젝트 구현', completed: false },
{ id: 3, text: '코드 리뷰', completed: true },
]);

function toggleTask(id) {
const task = tasks.find(t => t.id === id);
if (task) {
task.completed = !task.completed;
}
}

let completedCount = $derived(
tasks.filter(t => t.completed).length
);
</script>

<div>
<h2>할 일 목록</h2>
<p>완료: {completedCount} / {tasks.length}</p>

<ul>
{#each tasks as task}
<li>
<label>
<input
type="checkbox"
checked="{task.completed}"
onchange="{()"
=""
/>
toggleTask(task.id)} >
<span
style="text-decoration: {task.completed ? 'line-through' : 'none'}"
>
{task.text}
</span>
</label>
</li>
{/each}
</ul>
</div>

실습해보기: Svelte REPL에서 이 코드를 직접 실행해보세요!

키(key) 사용법과 성능

키를 사용하면 Svelte가 리스트 아이템을 더 효율적으로 업데이트할 수 있습니다. 키가 없으면 리스트 끝에서만 아이템을 추가/제거하고 나머지를 업데이트하지만, 키가 있으면 정확히 변경된 아이템만 조작합니다. 이는 특히 복잡한 컴포넌트나 애니메이션이 있는 리스트에서 중요한 성능 최적화입니다.

키를 사용한 리스트

<script>
let items = $state([
{ id: 1, name: '아이템 A', color: '#ff6b6b' },
{ id: 2, name: '아이템 B', color: '#4ecdc4' },
{ id: 3, name: '아이템 C', color: '#45b7d1' },
{ id: 4, name: '아이템 D', color: '#f7dc6f' },
]);

function removeFirst() {
items = items.slice(1);
}

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

function addRandom() {
const id = Math.max(...items.map(i => i.id), 0) + 1;
const color = `#${Math.floor(Math.random() * 16777215).toString(16)}`;
items = [
...items,
{
id,
name: `아이템 ${String.fromCharCode(68 + id)}`,
color,
},
];
}
</script>

<div>
<h2>키를 사용한 리스트</h2>

<div>
<button onclick="{removeFirst}">첫 번째 제거</button>
<button onclick="{shuffle}">섞기</button>
<button onclick="{addRandom}">추가</button>
</div>

<div>
{#each items as item (item.id)}
<div
style="background: {item.color}; padding: 10px; margin: 5px;"
>
ID: {item.id} - {item.name}
</div>
{/each}
</div>
</div>

성능 비교 예제

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

let withKey = $state(true);
let items = $state(generateItems(5));

function generateItems(count) {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
value: Math.random().toFixed(2),
timestamp: Date.now() + i,
}));
}

function updateRandom() {
const index = Math.floor(Math.random() * items.length);
items[index] = {
...items[index],
value: Math.random().toFixed(2),
};
}

onMount(() => {
const interval = setInterval(updateRandom, 2000);
return () => clearInterval(interval);
});
</script>

<div>
<h2>키 성능 테스트</h2>

<label>
<input type="checkbox" bind:checked="{withKey}" />
키 사용 {withKey ? '(효율적)' : '(비효율적)'}
</label>

{#if withKey} {#each items as item (item.id)}
<div>
#{item.id}: {item.value} (생성: {new
Date(item.timestamp).toLocaleTimeString()})
</div>
{/each} {:else} {#each items as item}
<div>
#{item.id}: {item.value} (생성: {new
Date(item.timestamp).toLocaleTimeString()})
</div>
{/each} {/if}
</div>

인덱스 활용

{#each} 블록에서 두 번째 매개변수로 인덱스를 받을 수 있어 순서가 중요한 UI를 구현할 때 유용합니다. 인덱스를 활용하여 번호 매기기, 스타일 적용, 조건부 렌더링 등 다양한 패턴을 구현할 수 있습니다. 구조 분해 할당과 함께 사용하면 더 깔끔한 코드를 작성할 수 있습니다.

<script>
let players = $state([
{ name: '김철수', score: 95 },
{ name: '이영희', score: 88 },
{ name: '박민수', score: 92 },
{ name: '정수진', score: 85 },
{ name: '최동훈', score: 90 },
]);

let sortedPlayers = $derived(
[...players].sort((a, b) => b.score - a.score)
);

function getMedal(index) {
const medals = ['🥇', '🥈', '🥉'];
return medals[index] || '';
}
</script>

<div>
<h2>점수 순위표</h2>

<table>
<thead>
<tr>
<th>순위</th>
<th>이름</th>
<th>점수</th>
<th>메달</th>
</tr>
</thead>
<tbody>
{#each sortedPlayers as player, index}
<tr
style="background: {index % 2 === 0 ? '#f8f9fa' : 'white'}"
>
<td>{index + 1}</td>
<td>{player.name}</td>
<td>{player.score}점</td>
<td>{getMedal(index)}</td>
</tr>
{/each}
</tbody>
</table>
</div>

<style>
table {
width: 100%;
border-collapse: collapse;
}

th,
td {
padding: 8px;
text-align: left;
border: 1px solid #dee2e6;
}

th {
background: #e9ecef;
}
</style>

실습해보기: Svelte REPL에서 이 코드를 직접 실행해보세요!


6.3 Promise와 비동기 데이터

\{#await} 블록

{#await} 블록은 Promise의 세 가지 상태(대기, 이행, 거부)를 템플릿에서 직접 처리할 수 있게 해줍니다. 비동기 데이터를 가져올 때 로딩, 성공, 에러 상태를 선언적으로 표현하여 복잡한 상태 관리 없이 깔끔한 코드를 작성할 수 있습니다. React의 Suspense나 Vue의 async setup보다 더 간단하고 직관적인 문법을 제공합니다.

기본 Promise 처리

<script>
async function fetchUserData(userId) {
await new Promise(resolve => setTimeout(resolve, 1000));

if (Math.random() > 0.3) {
return {
id: userId,
name: '홍길동',
email: 'hong@example.com',
joinDate: '2024-01-15',
};
} else {
throw new Error(
'사용자 데이터를 불러올 수 없습니다.'
);
}
}

let userId = $state(1);
let promise = $state(fetchUserData(userId));

function refreshData() {
promise = fetchUserData(userId);
}
</script>

<div>
<h2>사용자 정보</h2>

<button onclick="{refreshData}">새로고침</button>

{#await promise}
<p>사용자 정보를 불러오는 중...</p>
{:then user}
<div>
<h3>{user.name}</h3>
<p>이메일: {user.email}</p>
<p>가입일: {user.joinDate}</p>
</div>
{:catch error}
<p style="color: red">오류: {error.message}</p>
<button onclick="{refreshData}">다시 시도</button>
{/await}
</div>

로딩, 성공, 에러 상태 처리

{#await} 블록의 각 섹션을 활용하여 사용자 친화적인 비동기 UI를 구현할 수 있습니다. 로딩 중에는 스피너나 스켈레톤을, 성공 시에는 데이터를, 에러 시에는 재시도 버튼을 표시하는 등 다양한 패턴을 적용할 수 있습니다. 여러 Promise를 동시에 처리하거나 순차적으로 처리하는 복잡한 시나리오도 깔끔하게 구현 가능합니다.

<script>
let searchTerm = $state('');
let searchPromise = null;

async function searchProducts(term) {
if (!term.trim()) return [];

await new Promise(resolve => setTimeout(resolve, 1500));

const products = [
{ id: 1, name: '노트북', price: 1500000 },
{ id: 2, name: '마우스', price: 50000 },
{ id: 3, name: '키보드', price: 150000 },
{ id: 4, name: '모니터', price: 500000 },
{ id: 5, name: '헤드폰', price: 200000 },
];

return products.filter(p =>
p.name.toLowerCase().includes(term.toLowerCase())
);
}

function handleSearch() {
if (searchTerm.trim()) {
searchPromise = searchProducts(searchTerm);
}
}
</script>

<div>
<h2>상품 검색</h2>

<div>
<input
bind:value="{searchTerm}"
placeholder="검색어 입력"
onkeypress="{(e)"
=""
/>
e.key === 'Enter' && handleSearch()} >
<button onclick="{handleSearch}">검색</button>
</div>

{#if searchPromise} {#await searchPromise}
<div>
<p>검색 중...</p>
<p>"{searchTerm}" 관련 상품을 찾고 있습니다.</p>
</div>
{:then products} {#if products.length > 0}
<h3>검색 결과 ({products.length}개)</h3>
<ul>
{#each products as product}
<li>
{product.name} - {product.price.toLocaleString()}원
</li>
{/each}
</ul>
{:else}
<p>"{searchTerm}"에 대한 검색 결과가 없습니다.</p>
{/if} {:catch error}
<div>
<p style="color: red">검색 중 오류가 발생했습니다.</p>
<button onclick="{handleSearch}">다시 검색</button>
</div>
{/await} {:else}
<p>검색어를 입력하고 검색 버튼을 클릭하세요.</p>
{/if}
</div>

여러 Promise 동시 처리

<script>
async function fetchData(type, delay) {
await new Promise(resolve =>
setTimeout(resolve, delay)
);
return `${type} 데이터 로드 완료`;
}

let userPromise = fetchData('사용자', 1000);
let postsPromise = fetchData('게시글', 1500);
let statsPromise = fetchData('통계', 2000);

async function refreshAll() {
userPromise = fetchData('사용자', 1000);
postsPromise = fetchData('게시글', 1500);
statsPromise = fetchData('통계', 2000);
}
</script>

<div>
<h2>대시보드</h2>
<button onclick="{refreshAll}">전체 새로고침</button>

<div>
<h3>사용자 정보</h3>
{#await userPromise}
<p>로딩 중...</p>
{:then data}
<p>{data}</p>
{/await}
</div>

<div>
<h3>최근 게시글</h3>
{#await postsPromise}
<p>로딩 중...</p>
{:then data}
<p>{data}</p>
{/await}
</div>

<div>
<h3>통계</h3>
{#await statsPromise}
<p>로딩 중...</p>
{:then data}
<p>{data}</p>
{/await}
</div>
</div>

실습해보기: Svelte REPL에서 이 코드를 직접 실행해보세요!


정리

Svelte의 조건부 렌더링과 리스트 처리 기능을 완전히 마스터했습니다! 이제 다음과 같은 핵심 개념들을 이해했습니다:

핵심 요약

  • 조건부 렌더링: {#if}, {:else}, {:else if} 블록으로 선언적이고 읽기 쉬운 조건부 UI 구현
  • 리스트 렌더링: {#each} 블록과 키를 활용한 효율적인 리스트 처리와 성능 최적화
  • 비동기 처리: {#await} 블록으로 Promise의 모든 상태를 템플릿에서 직접 처리

실무 활용 팁

  • 복잡한 조건은 $derived로 계산하여 템플릿을 깔끔하게 유지
  • 리스트 렌더링 시 항상 고유한 키를 사용하여 성능 최적화
  • {#await} 블록으로 로딩, 성공, 에러 상태를 사용자 친화적으로 표시

다음 단계: 7장 "컴포넌트 통신"에서는 Props, Snippets, Context API를 통한 컴포넌트 간 데이터 전달과 통신 방법을 알아보겠습니다. 복잡한 컴포넌트 구조에서도 효율적으로 데이터를 관리하는 방법을 마스터해보세요!