본문으로 건너뛰기

7. 컴포넌트 통신

복잡한 애플리케이션에서는 여러 컴포넌트 간에 데이터를 주고받아야 합니다. Svelte 5는 Props, Snippets, Context API 등 다양한 통신 방법을 제공하여 효율적이고 유지보수 가능한 컴포넌트 구조를 만들 수 있게 합니다. 이 장에서는 각 통신 방법의 특징과 적절한 사용 시기를 완전히 마스터하여 확장 가능한 애플리케이션을 구축해보겠습니다.


7.1 Props 전달

부모에서 자식으로 데이터 전달

Props는 부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달하는 가장 기본적이고 명시적인 방법입니다. Svelte 5의 $props Rune을 사용하면 타입 안전하고 직관적인 방식으로 컴포넌트 간 데이터를 주고받을 수 있습니다. JavaScript의 구조 분해 할당을 활용하여 기본값 설정과 선택적 props도 쉽게 처리할 수 있습니다.

기본 Props 전달

UserCard.svelte
<!-- UserCard.svelte -->
<script>
let { name, email, avatar, role = 'user' } = $props();
</script>

<div class="user-card">
<img src="{avatar}" alt="{name}" class="avatar" />
<div class="user-info">
<h3>{name}</h3>
<p>{email}</p>
<span class="role {role}">{role}</span>
</div>
</div>

<style>
.user-card {
display: flex;
padding: 1rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
}

.avatar {
width: 60px;
height: 60px;
border-radius: 50%;
margin-right: 1rem;
}

.role.admin {
color: #dc2626;
font-weight: bold;
}
</style>
UserList.svelte
<!-- UserList.svelte -->
<script>
import UserCard from './UserCard.svelte';

let users = $state([
{
id: 1,
name: '김개발',
email: 'kim@example.com',
avatar: '/avatars/kim.jpg',
role: 'admin',
},
{
id: 2,
name: '박디자인',
email: 'park@example.com',
avatar: '/avatars/park.jpg',
},
]);
</script>

<div class="user-list">
{#each users as user (user.id)}
<UserCard
name="{user.name}"
email="{user.email}"
avatar="{user.avatar}"
role="{user.role}"
/>
{/each}
</div>

복잡한 데이터 구조 전달

ProductCard.svelte
<!-- ProductCard.svelte -->
<script>
let {
product,
showDescription = true,
onAddToCart,
onToggleFavorite,
} = $props();

let { name, price, image, description, inStock, rating } =
product;

function formatPrice(price) {
return new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW',
}).format(price);
}
</script>

<div class="product-card" class:out-of-stock="{!inStock}">
<div class="product-image">
<img src="{image}" alt="{name}" />
<button
class="favorite-btn"
onclick="{() => onToggleFavorite?.(product)}"
>

</button>
</div>

<div class="product-info">
<h3>{name}</h3>
<div class="price">{formatPrice(price)}</div>

{#if rating}
<div class="rating">★ {rating.toFixed(1)}</div>
{/if} {#if showDescription && description}
<p class="description">{description}</p>
{/if}

<button
onclick="{() => onAddToCart?.(product)}"
disabled="{!inStock}"
class="add-to-cart"
>
{inStock ? '장바구니 추가' : '품절'}
</button>
</div>
</div>

<style>
.product-card {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}

.out-of-stock {
opacity: 0.6;
}

.product-image {
position: relative;
}

.favorite-btn {
position: absolute;
top: 8px;
right: 8px;
background: white;
border: none;
border-radius: 50%;
padding: 8px;
}
</style>

Props 검증과 타입

TypeScript를 사용하면 Props의 타입을 명시적으로 정의하여 컴파일 타임에 타입 안전성을 보장할 수 있습니다. 인터페이스나 타입 정의를 통해 컴포넌트의 API를 명확하게 문서화하고, 잘못된 props 전달을 사전에 방지합니다. 옵셔널 props와 기본값을 조합하여 유연하면서도 안전한 컴포넌트 인터페이스를 설계할 수 있습니다.

Button.svelte
<!-- Button.svelte -->
<script lang="ts">
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
type?: 'button' | 'submit' | 'reset';
onclick?: () => void;
children: import('svelte').Snippet;
}

let {
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
type = 'button',
onclick,
children,
}: ButtonProps = $props();

const sizeClasses = {
sm: 'px-3 py-1 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};

const variantClasses = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white',
secondary: 'bg-gray-600 hover:bg-gray-700 text-white',
danger: 'bg-red-600 hover:bg-red-700 text-white',
};

let isDisabled = $derived(disabled || loading);
</script>

<button
{type}
disabled="{isDisabled}"
onclick
class="
rounded font-medium transition-colors
{sizeClasses[size]}
{variantClasses[variant]}
{isDisabled ? 'opacity-50 cursor-not-allowed' : ''}
"
>
{#if loading}
<span>로딩 중...</span>
{:else} {@render children()} {/if}
</button>
App.svelte
<!-- App.svelte -->
<script lang="ts">
import Button from './Button.svelte';

let isSubmitting = $state(false);

async function handleSubmit() {
isSubmitting = true;
await new Promise(resolve => setTimeout(resolve, 2000));
isSubmitting = false;
alert('제출 완료!');
}
</script>

<div>
<button onclick="{() => alert('기본 버튼!')}">
기본 버튼
</button>

<button variant="secondary" size="lg">
큰 보조 버튼
</button>

<button
variant="danger"
loading="{isSubmitting}"
onclick="{handleSubmit}"
>
제출하기
</button>

<button disabled>비활성 버튼</button>
</div>

스프레드 Props

스프레드 문법을 사용하면 객체의 모든 속성을 한 번에 props로 전달할 수 있습니다. 특히 래퍼 컴포넌트를 만들 때나 동적으로 props를 전달해야 할 때 유용합니다. ...restProps 패턴을 사용하면 명시하지 않은 모든 props를 자동으로 전달하여 컴포넌트의 유연성을 높일 수 있습니다.

FormInput.svelte
<!-- FormInput.svelte -->
<script>
let {
label,
error,
helpText,
required = false,
...inputProps
} = $props();
</script>

<div class="form-group">
<label class="form-label" class:required> {label} </label>

<input
class="form-input"
class:error
{required}
{...inputProps}
/>

{#if helpText}
<div class="help-text">{helpText}</div>
{/if} {#if error}
<div class="error-message">{error}</div>
{/if}
</div>

<style>
.form-label.required::after {
content: ' *';
color: #ef4444;
}

.form-input {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
}

.form-input.error {
border-color: #ef4444;
}

.error-message {
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.25rem;
}
</style>
ContactForm.svelte
<!-- ContactForm.svelte -->
<script>
import FormInput from './FormInput.svelte';

let formData = $state({
name: '',
email: '',
message: '',
});

const inputConfigs = [
{
label: '이름',
name: 'name',
type: 'text',
required: true,
placeholder: '이름을 입력하세요',
},
{
label: '이메일',
name: 'email',
type: 'email',
required: true,
placeholder: 'email@example.com',
helpText: '회신을 위해 정확한 이메일을 입력해주세요',
},
];
</script>

<form>
{#each inputConfigs as config}
<FormInput
{...config}
bind:value="{formData[config.name]}"
/>
{/each}

<div class="form-group">
<label>메시지</label>
<textarea
bind:value="{formData.message}"
placeholder="문의 내용을 입력하세요"
></textarea>
</div>
</form>

7.2 Snippets

Slot에서 Snippet으로의 변화

Svelte 5는 기존의 Slot 시스템을 더 강력하고 유연한 Snippet으로 대체했습니다. Snippet은 함수와 같은 개념으로 매개변수를 받을 수 있고, 명시적이며 타입 안전한 방식으로 컨텐츠를 전달할 수 있습니다. 기존의 <slot> 태그와 달리 Snippet은 어디서든 호출 가능하고 재사용성이 뛰어납니다.

Svelte 4 vs Svelte 5 비교

특징Svelte 4 SlotSvelte 5 Snippet
정의 방식<slot name="header">{#snippet header()}
호출 방식자동 렌더링{@render header()}
매개변수let: 디렉티브함수 매개변수
타입 안전성제한적완전한 TypeScript 지원
재사용성컴포넌트 내부만어디서든 호출 가능

기본 Snippet 사용법

Card.svelte
<!-- Card.svelte -->
<script>
let { title, children, actions } = $props();
</script>

<div class="card">
<div class="card-header">
<h2>{title}</h2>
</div>

<div class="card-content">{@render children()}</div>

{#if actions}
<div class="card-actions">{@render actions()}</div>
{/if}
</div>

<style>
.card {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}

.card-header {
background: #f8fafc;
padding: 1rem;
border-bottom: 1px solid #e2e8f0;
}

.card-content {
padding: 1rem;
}

.card-actions {
padding: 1rem;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
}
</style>
App.svelte
<!-- App.svelte -->
<script>
import Card from './Card.svelte';

function handleEdit() {
alert('편집 클릭');
}

function handleDelete() {
alert('삭제 클릭');
}
</script>

<Card title="사용자 프로필">
{#snippet children()}
<p>이름: 홍길동</p>
<p>이메일: hong@example.com</p>
<p>가입일: 2024-01-15</p>
{/snippet} {#snippet actions()}
<button onclick="{handleEdit}">편집</button>
<button onclick="{handleDelete}">삭제</button>
{/snippet}
</Card>

재사용 가능한 마크업 블록

Snippet은 컴포넌트 내에서 반복되는 마크업을 효율적으로 재사용할 수 있게 합니다. 복잡한 템플릿 구조를 함수처럼 정의하여 코드 중복을 줄이고 가독성을 향상시킵니다. 매개변수를 통해 동적으로 다른 데이터를 전달하여 유연한 마크업 생성이 가능합니다.

ProductList.svelte
<script>
let products = $state([
{
id: 1,
name: '무선 이어폰',
price: 89000,
image: '/products/earphones.jpg',
featured: true,
discount: 10,
},
{
id: 2,
name: '스마트워치',
price: 329000,
image: '/products/watch.jpg',
featured: false,
discount: 0,
},
]);

function formatPrice(price) {
return price.toLocaleString('ko-KR');
}

function calculateDiscountedPrice(price, discount) {
return Math.floor(price * (1 - discount / 100));
}
</script>

{#snippet productBadge(product)}
<div class="badges">
{#if product.featured}
<span class="badge featured">인기</span>
{/if} {#if product.discount > 0}
<span class="badge discount"
>{product.discount}% 할인</span
>
{/if}
</div>
{/snippet} {#snippet productPrice(product)}
<div class="price-section">
{#if product.discount > 0}
<span class="original-price"
>{formatPrice(product.price)}원</span
>
<span class="discounted-price">
{formatPrice(calculateDiscountedPrice(product.price,
product.discount))}원
</span>
{:else}
<span class="current-price"
>{formatPrice(product.price)}원</span
>
{/if}
</div>
{/snippet}

<div class="product-grid">
{#each products as product (product.id)}
<div class="product-item">
<div class="product-image">
<img src="{product.image}" alt="{product.name}" />
{@render productBadge(product)}
</div>

<div class="product-info">
<h3>{product.name}</h3>
{@render productPrice(product)}
<button class="add-to-cart">장바구니 추가</button>
</div>
</div>
{/each}
</div>

<style>
.product-grid {
display: grid;
grid-template-columns: repeat(
auto-fit,
minmax(250px, 1fr)
);
gap: 1rem;
}

.product-item {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}

.product-image {
position: relative;
}

.badges {
position: absolute;
top: 8px;
left: 8px;
}

.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
}

.badge.featured {
background: #3b82f6;
color: white;
}

.badge.discount {
background: #ef4444;
color: white;
}

.original-price {
text-decoration: line-through;
color: #6b7280;
}

.discounted-price {
color: #ef4444;
font-weight: bold;
}
</style>

매개변수를 가진 Snippet

Snippet의 강력한 기능 중 하나는 함수처럼 매개변수를 받을 수 있다는 것입니다. 이를 통해 동일한 구조의 마크업에 다른 데이터를 전달하여 재사용성을 극대화할 수 있습니다. 구조 분해 할당과 기본값을 활용하여 더욱 유연한 Snippet을 만들 수 있습니다.

Table.svelte
<!-- Table.svelte -->
<script>
let {
data,
columns,
headerSnippet,
rowSnippet,
emptySnippet,
} = $props();
</script>

<div class="table-container">
<table>
<thead>
<tr>
{@render headerSnippet(columns)}
</tr>
</thead>
<tbody>
{#if data.length > 0} {#each data as item, index
(item.id || index)} {@render rowSnippet(item, index)}
{/each} {:else}
<tr>
<td colspan="{columns.length}">
{@render emptySnippet()}
</td>
</tr>
{/if}
</tbody>
</table>
</div>

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

th,
td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #e2e8f0;
}

th {
background: #f8fafc;
font-weight: 600;
}
</style>
UserTable.svelte
<!-- UserTable.svelte -->
<script>
import Table from './Table.svelte';

let users = $state([
{
id: 1,
name: '김철수',
email: 'kim@example.com',
role: 'admin',
status: 'active',
},
{
id: 2,
name: '이영희',
email: 'lee@example.com',
role: 'user',
status: 'inactive',
},
]);

const columns = [
{ key: 'name', label: '이름' },
{ key: 'email', label: '이메일' },
{ key: 'role', label: '역할' },
{ key: 'status', label: '상태' },
{ key: 'actions', label: '작업' },
];

function handleEdit(user) {
alert(`${user.name} 편집`);
}

function handleDelete(user) {
users = users.filter(u => u.id !== user.id);
}
</script>

<table {data} {columns}>
{#snippet headerSnippet(columns)} {#each columns as
column}
<th>{column.label}</th>
{/each} {/snippet} {#snippet rowSnippet(user, index)}
<tr class:inactive="{user.status === 'inactive'}">
<td>{user.name}</td>
<td>{user.email}</td>
<td>
<span class="role {user.role}">{user.role}</span>
</td>
<td>
<span class="status {user.status}"
>{user.status}</span
>
</td>
<td>
<button onclick="{() => handleEdit(user)}">
편집
</button>
<button onclick="{() => handleDelete(user)}">
삭제
</button>
</td>
</tr>
{/snippet} {#snippet emptySnippet()}
<div class="empty-state">
<p>등록된 사용자가 없습니다.</p>
<button>사용자 추가</button>
</div>
{/snippet}
</table>

<style>
.role.admin {
background: #dc2626;
color: white;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
}

.status.active {
color: #059669;
}

.status.inactive {
color: #dc2626;
}

.inactive {
opacity: 0.6;
}

.empty-state {
text-align: center;
padding: 2rem;
}
</style>

7.3 Context API

깊은 컴포넌트 트리에서의 데이터 전달

Context API는 컴포넌트 트리의 여러 레벨을 거쳐 데이터를 전달해야 할 때 사용하는 고급 기능입니다. Props를 여러 단계로 전달하는 "prop drilling"을 피하고, 관련된 컴포넌트들이 직접적으로 데이터에 접근할 수 있게 합니다. 테마, 사용자 정보, 설정 등 애플리케이션 전역에서 사용되는 데이터에 특히 유용합니다.

Context 사용 시기

상황Props 사용Context 사용
데이터 전달 깊이1-2 레벨3+ 레벨
데이터 성격컴포넌트별 특수 데이터공통/전역 데이터
사용 빈도특정 컴포넌트만여러 컴포넌트에서 공통
예시버튼 색상, 텍스트테마, 사용자 정보, 언어 설정

기본 Context 사용법

ThemeProvider.svelte
<!-- ThemeProvider.svelte -->
<script>
import { setContext } from 'svelte';

let { theme = 'light', children } = $props();

let themeState = $state({
current: theme,
colors: {
light: {
bg: '#ffffff',
text: '#1a1a1a',
primary: '#3b82f6',
secondary: '#6b7280',
},
dark: {
bg: '#1a1a1a',
text: '#ffffff',
primary: '#60a5fa',
secondary: '#9ca3af',
},
},
toggle() {
themeState.current =
themeState.current === 'light' ? 'dark' : 'light';
},
});

// Context 설정
setContext('theme', themeState);

let currentTheme = $derived(
themeState.colors[themeState.current]
);
</script>

<div
class="app"
style:background-color="{currentTheme.bg}"
style:color="{currentTheme.text}"
>
{@render children()}
</div>

<style>
.app {
min-height: 100vh;
transition:
background-color 0.3s ease,
color 0.3s ease;
}
</style>
Header.svelte
<!-- Header.svelte -->
<script>
import { getContext } from 'svelte';
import Button from './Button.svelte';

// Context에서 테마 가져오기
const theme = getContext('theme');
let currentTheme = $derived(theme.colors[theme.current]);
</script>

<header
style:background-color="{currentTheme.secondary}"
style:padding="1rem"
>
<h1>My App</h1>
<button onclick="{theme.toggle}">
{theme.current === 'light' ? '🌙' : '☀️'} 테마 변경
</button>
</header>
Button.svelte
<!-- Button.svelte -->
<script>
import { getContext } from 'svelte';

let { onclick, children } = $props();

// Context에서 테마 가져오기
const theme = getContext('theme');
let currentTheme = $derived(theme.colors[theme.current]);
</script>

<button
{onclick}
style:background-color="{currentTheme.primary}"
style:color="{currentTheme.text}"
style:border="none"
style:padding="0.5rem 1rem"
style:border-radius="4px"
style:cursor="pointer"
>
{@render children()}
</button>

setContext, getContext

setContext는 부모 컴포넌트에서 값을 설정하고, getContext는 자식 컴포넌트에서 해당 값을 가져옵니다. 컴포넌트 초기화 시점에만 호출해야 하며, 키를 통해 고유한 Context를 식별합니다. 반응형 상태를 Context에 저장하면 값이 변경될 때 모든 구독 컴포넌트가 자동으로 업데이트됩니다.

AuthProvider.svelte
<!-- AuthProvider.svelte -->
<script>
import { setContext } from 'svelte';

let { children } = $props();

// 인증 상태 관리
let authState = $state({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,

async login(email, password) {
authState.isLoading = true;
authState.error = null;

try {
// 로그인 API 호출 시뮬레이션
await new Promise(resolve =>
setTimeout(resolve, 1000)
);

if (
email === 'admin@example.com' &&
password === 'admin'
) {
authState.user = {
id: 1,
name: '관리자',
email,
role: 'admin',
};
authState.isAuthenticated = true;
} else {
throw new Error('잘못된 로그인 정보입니다.');
}
} catch (err) {
authState.error = err.message;
} finally {
authState.isLoading = false;
}
},

logout() {
authState.user = null;
authState.isAuthenticated = false;
authState.error = null;
},
});

// Context 설정
setContext('auth', authState);
</script>

{@render children()}
LoginForm.svelte
<!-- LoginForm.svelte -->
<script>
import { getContext } from 'svelte';

const auth = getContext('auth');

let formData = $state({
email: '',
password: '',
});

async function handleSubmit(event) {
event.preventDefault();
await auth.login(formData.email, formData.password);
}
</script>

{#if auth.isAuthenticated}
<div class="welcome">
<h2>안녕하세요, {auth.user.name}님!</h2>
<button onclick="{auth.logout}">로그아웃</button>
</div>
{:else}
<form onsubmit="{handleSubmit}">
<h2>로그인</h2>

{#if auth.error}
<div class="error">{auth.error}</div>
{/if}

<input
type="email"
bind:value="{formData.email}"
placeholder="이메일"
required
/>

<input
type="password"
bind:value="{formData.password}"
placeholder="비밀번호"
required
/>

<button type="submit" disabled="{auth.isLoading}">
{auth.isLoading ? '로그인 중...' : '로그인'}
</button>
</form>
{/if}

<style>
.error {
background: #fef2f2;
color: #dc2626;
padding: 0.5rem;
border-radius: 4px;
margin-bottom: 1rem;
}

.welcome {
text-align: center;
padding: 2rem;
}
</style>

타입 안전한 Context

TypeScript를 사용할 때는 Context의 타입을 명시적으로 정의하여 타입 안전성을 확보할 수 있습니다. 헬퍼 함수를 만들어 Context 설정과 조회를 캡슐화하면 재사용성과 타입 안전성을 모두 확보할 수 있습니다.

context.ts
// context.ts
import { getContext, setContext } from 'svelte';

export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
}

export interface AuthContext {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
login(email: string, password: string): Promise<void>;
logout(): void;
}

const AUTH_KEY = Symbol('auth');

export function setAuthContext(context: AuthContext) {
setContext(AUTH_KEY, context);
}

export function getAuthContext(): AuthContext {
return getContext<AuthContext>(AUTH_KEY);
}

// 테마 Context
export interface ThemeContext {
current: 'light' | 'dark';
colors: Record<string, any>;
toggle(): void;
}

const THEME_KEY = Symbol('theme');

export function setThemeContext(context: ThemeContext) {
setContext(THEME_KEY, context);
}

export function getThemeContext(): ThemeContext {
return getContext<ThemeContext>(THEME_KEY);
}
TypedAuthProvider.svelte
<!-- TypedAuthProvider.svelte -->
<script lang="ts">
import type { AuthContext, User } from './context';
import { setAuthContext } from './context';

let { children } = $props();

let authState: AuthContext = {
user: $state(null),
isAuthenticated: $state(false),
isLoading: $state(false),
error: $state(null),

async login(email: string, password: string) {
authState.isLoading = true;
authState.error = null;

try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});

if (!response.ok) {
throw new Error('로그인에 실패했습니다.');
}

const user: User = await response.json();
authState.user = user;
authState.isAuthenticated = true;
} catch (err) {
authState.error =
err instanceof Error
? err.message
: '알 수 없는 오류';
} finally {
authState.isLoading = false;
}
},

logout() {
authState.user = null;
authState.isAuthenticated = false;
authState.error = null;
},
};

setAuthContext(authState);
</script>

{@render children()}
UserProfile.svelte
<!-- UserProfile.svelte -->
<script lang="ts">
import { getAuthContext } from './context';

const auth = getAuthContext();
</script>

{#if auth.isAuthenticated && auth.user}
<div class="profile">
<h3>{auth.user.name}</h3>
<p>{auth.user.email}</p>
<span class="role">{auth.user.role}</span>
<button onclick="{auth.logout}">로그아웃</button>
</div>
{:else}
<p>로그인이 필요합니다.</p>
{/if}

정리

Svelte 5의 컴포넌트 통신 방법을 완전히 마스터했습니다! 이제 다음과 같은 핵심 개념들을 이해했습니다:

핵심 요약

  • Props 전달: $props Rune을 통한 명시적이고 타입 안전한 부모-자식 간 데이터 전달
  • Snippets: 기존 Slot을 대체하는 더 강력하고 유연한 컨텐츠 전달 방식
  • Context API: 깊은 컴포넌트 트리에서 prop drilling 없이 데이터를 공유하는 고급 패턴

적절한 선택 가이드

상황권장 방법이유
1-2레벨 데이터 전달Props명시적이고 추적 가능
컨텐츠 전달Snippets매개변수와 재사용성 지원
3+레벨 공통 데이터Context APIprop drilling 방지

실무 활용 팁

  • Props는 컴포넌트의 명시적 API를 만드는 기본 방법
  • Snippets으로 재사용 가능한 UI 패턴과 템플릿 구현
  • Context는 테마, 인증, 언어 설정 등 전역 상태에만 사용

다음 단계: 8장 "바인딩과 폼"에서는 양방향 데이터 바인딩과 고급 폼 처리 기법을 알아보겠습니다. 사용자 입력을 효과적으로 처리하고 검증하는 방법을 마스터해보세요!