본문으로 건너뛰기

8. 바인딩과 폼

웹 애플리케이션의 핵심 기능 중 하나는 사용자 입력을 처리하는 것입니다. Svelte의 양방향 데이터 바인딩과 폼 처리 기능을 통해 복잡한 입력 관리와 검증을 간단하고 효율적으로 구현할 수 있습니다. 이 장에서는 bind: 지시문의 다양한 활용법과 고급 폼 처리 기법을 완전히 마스터하여 사용자 친화적인 입력 경험을 구축해보겠습니다.


8.1 양방향 데이터 바인딩

bind:value로 입력 필드 연결

bind:value는 입력 필드와 컴포넌트 상태를 양방향으로 연결하는 가장 기본적인 바인딩 지시문입니다. 사용자가 입력하면 자동으로 상태가 업데이트되고, 상태를 변경하면 입력 필드도 즉시 업데이트됩니다. React의 제어 컴포넌트나 Vue의 v-model과 유사하지만, 더 간결하고 직관적인 문법을 제공합니다.

기본 텍스트 입력

<script>
let username = $state('');
let email = $state('');
let bio = $state('');

let characterCount = $derived(bio.length);
let emailValid = $derived(
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
);
</script>

<div class="form-container">
<div class="field">
<label for="username">사용자명:</label>
<input
id="username"
type="text"
bind:value="{username}"
placeholder="사용자명 입력"
/>
<span class="preview"
>입력값: {username || '(비어있음)'}</span
>
</div>

<div class="field">
<label for="email">이메일:</label>
<input
id="email"
type="email"
bind:value="{email}"
placeholder="email@example.com"
/>
<span class="status {emailValid ? 'valid' : 'invalid'}">
{email ? (emailValid ? '✓ 유효한 이메일' : '✗ 잘못된
형식') : ''}
</span>
</div>

<div class="field">
<label for="bio">자기소개:</label>
<textarea
id="bio"
bind:value="{bio}"
placeholder="자기소개를 입력하세요"
maxlength="200"
></textarea>
<span class="counter">{characterCount} / 200</span>
</div>
</div>

<style>
.form-container {
max-width: 500px;
padding: 2rem;
}

.field {
margin-bottom: 1.5rem;
}

input,
textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #e2e8f0;
border-radius: 4px;
}

.status.valid {
color: #22c55e;
}

.status.invalid {
color: #ef4444;
}
</style>

숫자 입력과 타입 변환

<script>
let age = $state(0);
let price = $state(0);
let quantity = $state(1);

let total = $derived(price * quantity);
let canPurchaseAlcohol = $derived(age >= 18);
</script>

<div class="number-inputs">
<div class="field">
<label>
나이:
<input
type="number"
bind:value="{age}"
min="0"
max="120"
/>
</label>
<p>
성인 여부: {canPurchaseAlcohol ? '성인' : '미성년자'}
</p>
</div>

<div class="field">
<label>
가격:
<input
type="number"
bind:value="{price}"
min="0"
step="1000"
/>
</label>
</div>

<div class="field">
<label>
수량:
<input
type="range"
bind:value="{quantity}"
min="1"
max="10"
/>
<span>{quantity}개</span>
</label>
</div>

<div class="result">
<strong>총액: {total.toLocaleString()}원</strong>
</div>
</div>

함수 바인딩 (Svelte 5.9.0+)

Svelte 5.9.0부터는 함수 바인딩을 사용하여 값의 변환과 검증을 수행할 수 있습니다. getter와 setter 함수를 통해 바인딩 시점에 커스텀 로직을 적용할 수 있습니다. 이는 입력값 포맷팅이나 유효성 검사에 특히 유용합니다.

<script>
let email = $state('');
let phoneNumber = $state('');

// 이메일을 소문자로 자동 변환
function getEmail() {
return email;
}

function setEmail(value) {
email = value.toLowerCase();
}

// 전화번호 포맷팅
function getPhone() {
return phoneNumber;
}

function setPhone(value) {
// 숫자만 추출하고 포맷팅
const cleaned = value.replace(/\D/g, '');
if (cleaned.length <= 3) {
phoneNumber = cleaned;
} else if (cleaned.length <= 7) {
phoneNumber = `${cleaned.slice(0, 3)}-${cleaned.slice(3)}`;
} else {
phoneNumber = `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7, 11)}`;
}
}
</script>

<div class="function-binding">
<div class="field">
<label>
이메일 (자동 소문자 변환):
<input
type="email"
bind:value="{getEmail,"
setEmail}
/>
</label>
<p>저장된 값: {email}</p>
</div>

<div class="field">
<label>
전화번호 (자동 포맷팅):
<input
type="tel"
bind:value="{getPhone,"
setPhone}
placeholder="01012345678"
/>
</label>
<p>포맷된 값: {phoneNumber}</p>
</div>
</div>

bind:checked와 체크박스

bind:checked는 체크박스와 라디오 버튼의 선택 상태를 바인딩합니다. 단일 체크박스는 불린 값을, 체크박스 그룹은 배열을 사용하여 여러 선택을 관리합니다. bind:group과 함께 사용하면 더욱 강력한 선택 관리가 가능합니다.

단일 체크박스

<script>
let termsAccepted = $state(false);
let marketingOptIn = $state(false);
let newsletter = $state(true);

let canSubmit = $derived(termsAccepted);
</script>

<div class="checkbox-section">
<h3>이용 약관</h3>

<label class="checkbox-label">
<input type="checkbox" bind:checked="{termsAccepted}" />
<span>이용약관에 동의합니다 (필수)</span>
</label>

<label class="checkbox-label">
<input
type="checkbox"
bind:checked="{marketingOptIn}"
/>
<span>마케팅 정보 수신 동의 (선택)</span>
</label>

<label class="checkbox-label">
<input type="checkbox" bind:checked="{newsletter}" />
<span>뉴스레터 구독</span>
</label>

<button disabled="{!canSubmit}">
{canSubmit ? '가입하기' : '약관 동의 필요'}
</button>

<div class="status">
<h4>선택 상태:</h4>
<ul>
<li>약관 동의: {termsAccepted ? '✓' : '✗'}</li>
<li>마케팅: {marketingOptIn ? '✓' : '✗'}</li>
<li>뉴스레터: {newsletter ? '✓' : '✗'}</li>
</ul>
</div>
</div>

<style>
.checkbox-label {
display: block;
margin: 0.5rem 0;
cursor: pointer;
}

.checkbox-label input {
margin-right: 0.5rem;
}
</style>

bind:group으로 다중 선택

<script>
let selectedFruits = $state([]);
let pizzaToppings = $state(['cheese']);
let paymentMethod = $state('card');

const availableFruits = [
'사과',
'바나나',
'오렌지',
'포도',
];
const availableToppings = [
'cheese',
'pepperoni',
'mushroom',
'onion',
];
</script>

<div class="selection-groups">
<div class="group">
<h3>좋아하는 과일 (복수 선택)</h3>
{#each availableFruits as fruit}
<label>
<input
type="checkbox"
bind:group="{selectedFruits}"
value="{fruit}"
/>
{fruit}
</label>
{/each}
<p>
선택: {selectedFruits.length > 0 ?
selectedFruits.join(', ') : '없음'}
</p>
</div>

<div class="group">
<h3>피자 토핑</h3>
{#each availableToppings as topping}
<label>
<input
type="checkbox"
bind:group="{pizzaToppings}"
value="{topping}"
/>
{topping}
</label>
{/each}
<p>선택된 토핑: {pizzaToppings.length}개</p>
</div>

<div class="group">
<h3>결제 방법 (단일 선택)</h3>
<label>
<input
type="radio"
bind:group="{paymentMethod}"
value="card"
/>
신용카드
</label>
<label>
<input
type="radio"
bind:group="{paymentMethod}"
value="cash"
/>
현금
</label>
<label>
<input
type="radio"
bind:group="{paymentMethod}"
value="transfer"
/>
계좌이체
</label>
<p>선택: {paymentMethod}</p>
</div>
</div>

select 요소 바인딩

<select> 요소의 바인딩은 단일 선택과 다중 선택을 모두 지원합니다. 옵션의 value는 문자열뿐만 아니라 객체도 가능하여 복잡한 데이터 구조도 쉽게 처리할 수 있습니다. 동적으로 옵션을 생성하고 선택된 값에 따라 UI를 업데이트하는 것도 간단합니다.

<script>
let selectedCountry = $state('kr');
let selectedLanguages = $state([]);
let selectedProduct = $state(null);

const countries = [
{ code: 'kr', name: '한국', timezone: 'UTC+9' },
{ code: 'us', name: '미국', timezone: 'UTC-5' },
{ code: 'jp', name: '일본', timezone: 'UTC+9' },
];

const languages = [
'JavaScript',
'TypeScript',
'Python',
'Rust',
];

const products = [
{ id: 1, name: '노트북', price: 1500000 },
{ id: 2, name: '마우스', price: 50000 },
{ id: 3, name: '키보드', price: 150000 },
];
</script>

<div class="select-examples">
<div class="field">
<label>
국가 선택:
<select bind:value="{selectedCountry}">
{#each countries as country}
<option value="{country.code}">
{country.name} ({country.timezone})
</option>
{/each}
</select>
</label>
<p>선택된 국가 코드: {selectedCountry}</p>
</div>

<div class="field">
<label>
프로그래밍 언어 (다중 선택):
<select multiple bind:value="{selectedLanguages}">
{#each languages as lang}
<option value="{lang}">{lang}</option>
{/each}
</select>
</label>
<p>
선택: {selectedLanguages.length}개 -
{selectedLanguages.join(', ')}
</p>
</div>

<div class="field">
<label>
상품 선택 (객체 바인딩):
<select bind:value="{selectedProduct}">
<option value="{null}">선택하세요</option>
{#each products as product}
<option value="{product}">
{product.name} -
{product.price.toLocaleString()}원
</option>
{/each}
</select>
</label>
{#if selectedProduct}
<div class="product-detail">
<h4>{selectedProduct.name}</h4>
<p>
가격: {selectedProduct.price.toLocaleString()}원
</p>
</div>
{/if}
</div>
</div>

8.2 고급 바인딩

bind:this로 DOM 요소 참조

bind:this를 사용하면 DOM 요소나 컴포넌트 인스턴스에 직접 접근할 수 있습니다. React의 useRef나 Vue의 ref와 유사한 기능으로, DOM 조작이나 서드파티 라이브러리 통합에 필수적입니다. 컴포넌트가 마운트된 후에만 참조가 유효하므로 $effect 내에서 사용해야 합니다.

DOM 요소 참조

<script>
let inputRef = $state(null);
let canvasRef = $state(null);
let videoRef = $state(null);

function focusInput() {
inputRef?.focus();
}

function clearInput() {
if (inputRef) {
inputRef.value = '';
inputRef.focus();
}
}

$effect(() => {
if (canvasRef) {
const ctx = canvasRef.getContext('2d');
ctx.fillStyle = '#3b82f6';
ctx.fillRect(10, 10, 100, 100);
}
});

function playPauseVideo() {
if (videoRef) {
if (videoRef.paused) {
videoRef.play();
} else {
videoRef.pause();
}
}
}
</script>

<div class="ref-examples">
<div class="example">
<h3>입력 필드 제어</h3>
<input
bind:this="{inputRef}"
type="text"
placeholder="포커스 테스트"
/>
<button onclick="{focusInput}">포커스</button>
<button onclick="{clearInput}">초기화</button>
</div>

<div class="example">
<h3>Canvas 그리기</h3>
<canvas
bind:this="{canvasRef}"
width="200"
height="200"
></canvas>
</div>

<div class="example">
<h3>비디오 제어</h3>
<video bind:this="{videoRef}" width="300">
<!-- 실제 비디오 URL을 추가하거나 로컬 파일 사용 -->
<source
src="https://www.w3schools.com/html/mov_bbb.mp4"
type="video/mp4"
/>
</video>
<button onclick="{playPauseVideo}">
재생/일시정지
</button>
</div>
</div>

<style>
.ref-examples {
display: grid;
gap: 2rem;
}

canvas {
border: 1px solid #e2e8f0;
}
</style>

컴포넌트 인스턴스 바인딩

컴포넌트 인스턴스에 바인딩하면 자식 컴포넌트의 메서드를 부모에서 직접 호출할 수 있습니다. 이는 모달, 대화상자, 폼 컴포넌트 등에서 유용하며, 명령형 API를 제공할 때 사용됩니다. 컴포넌트에서 export한 함수와 변수만 접근 가능합니다.

Modal.svelte
<!-- Modal.svelte -->
<script>
let isOpen = $state(false);
let title = $state('');
let content = $state('');

export function open(modalTitle, modalContent) {
title = modalTitle;
content = modalContent;
isOpen = true;
}

export function close() {
isOpen = false;
}

export function toggle() {
isOpen = !isOpen;
}
</script>

{#if isOpen}
<div class="modal-backdrop" onclick="{close}">
<div class="modal" onclick|stopPropagation>
<h2>{title}</h2>
<p>{content}</p>
<button onclick="{close}">닫기</button>
</div>
</div>
{/if}

<style>
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}

.modal {
background: white;
padding: 2rem;
border-radius: 8px;
max-width: 500px;
}
</style>
App.svelte
<!-- App.svelte -->
<script>
import Modal from './Modal.svelte';

let modalRef = $state(null);

function showSuccessModal() {
modalRef?.open(
'성공',
'작업이 성공적으로 완료되었습니다.'
);
}

function showErrorModal() {
modalRef?.open('오류', '작업 중 오류가 발생했습니다.');
}
</script>

<div>
<button onclick="{showSuccessModal}">성공 모달</button>
<button onclick="{showErrorModal}">오류 모달</button>
<button onclick="{()" ="">
modalRef?.toggle()}>토글
</button>

<Modal bind:this="{modalRef}" />
</div>

미디어 요소 바인딩

오디오와 비디오 요소는 재생 상태, 시간, 볼륨 등 다양한 속성을 바인딩할 수 있습니다. 이러한 바인딩은 읽기 전용이거나 양방향일 수 있으며, 미디어 플레이어 UI를 구현할 때 매우 유용합니다. currentTime, duration, paused, volume 등의 속성을 실시간으로 추적할 수 있습니다.

<script>
let audioRef = $state(null);
let currentTime = $state(0);
let duration = $state(0);
let paused = $state(true);
let volume = $state(1);
let playbackRate = $state(1);

function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}

let progress = $derived(
duration > 0 ? (currentTime / duration) * 100 : 0
);
</script>

<div class="audio-player">
<audio
bind:this="{audioRef}"
bind:currentTime
bind:duration
bind:paused
bind:volume
bind:playbackRate
>
<!-- 실제 오디오 URL 사용 또는 로컬 파일 추가 -->
<source
src="https://www.w3schools.com/html/horse.mp3"
type="audio/mpeg"
/>
</audio>

<div class="controls">
<button onclick="{()" ="">
paused = !paused}> {paused ? '▶️ 재생' : '⏸️
일시정지'}
</button>

<div class="time-display">
{formatTime(currentTime)} / {formatTime(duration)}
</div>

<div class="progress-bar">
<input
type="range"
min="0"
max="{duration}"
bind:value="{currentTime}"
step="0.1"
/>
<div
class="progress-fill"
style="width: {progress}%"
></div>
</div>

<div class="volume-control">
<label>
볼륨:
<input
type="range"
min="0"
max="1"
step="0.1"
bind:value="{volume}"
/>
{Math.round(volume * 100)}%
</label>
</div>

<div class="speed-control">
<label>
재생 속도:
<select bind:value="{playbackRate}">
<option value="{0.5}">0.5x</option>
<option value="{1}">1x</option>
<option value="{1.5}">1.5x</option>
<option value="{2}">2x</option>
</select>
</label>
</div>
</div>
</div>

<style>
.audio-player {
padding: 2rem;
background: #f8fafc;
border-radius: 8px;
}

.controls {
display: grid;
gap: 1rem;
}

.progress-bar {
position: relative;
height: 8px;
background: #e2e8f0;
border-radius: 4px;
}

.progress-fill {
position: absolute;
height: 100%;
background: #3b82f6;
border-radius: 4px;
}
</style>

8.3 폼 검증과 에러 처리

실시간 검증

실시간 검증은 사용자가 입력하는 동안 즉각적인 피드백을 제공하여 사용자 경험을 향상시킵니다. Svelte의 반응형 시스템을 활용하면 복잡한 검증 로직도 선언적으로 구현할 수 있습니다. $derived를 사용하여 검증 상태를 자동으로 계산하고, 조건부 렌더링으로 에러 메시지를 표시합니다.

<script>
let formData = $state({
username: '',
email: '',
password: '',
confirmPassword: '',
});

let touched = $state({
username: false,
email: false,
password: false,
confirmPassword: false,
});

// 검증 규칙
let validations = $derived({
username: {
required: formData.username.length > 0,
minLength: formData.username.length >= 3,
maxLength: formData.username.length <= 20,
pattern: /^[a-zA-Z0-9_]+$/.test(formData.username),
},
email: {
required: formData.email.length > 0,
format: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
formData.email
),
},
password: {
required: formData.password.length > 0,
minLength: formData.password.length >= 8,
hasUpperCase: /[A-Z]/.test(formData.password),
hasLowerCase: /[a-z]/.test(formData.password),
hasNumber: /\d/.test(formData.password),
},
confirmPassword: {
required: formData.confirmPassword.length > 0,
matches:
formData.password === formData.confirmPassword,
},
});

let isFormValid = $derived(
validations.username.required &&
validations.username.minLength &&
validations.username.pattern &&
validations.email.required &&
validations.email.format &&
validations.password.required &&
validations.password.minLength &&
validations.confirmPassword.matches
);

function handleBlur(field) {
touched[field] = true;
}

function getFieldErrors(field) {
if (!touched[field]) return [];

const errors = [];
const validation = validations[field];

if (field === 'username') {
if (!validation.required)
errors.push('사용자명은 필수입니다');
else if (!validation.minLength)
errors.push('최소 3자 이상');
else if (!validation.maxLength)
errors.push('최대 20자 이하');
else if (!validation.pattern)
errors.push('영문, 숫자, _ 만 사용 가능');
}

if (field === 'email') {
if (!validation.required)
errors.push('이메일은 필수입니다');
else if (!validation.format)
errors.push('올바른 이메일 형식이 아닙니다');
}

if (field === 'password') {
if (!validation.required)
errors.push('비밀번호는 필수입니다');
else {
if (!validation.minLength)
errors.push('최소 8자 이상');
if (!validation.hasUpperCase)
errors.push('대문자 포함 필요');
if (!validation.hasLowerCase)
errors.push('소문자 포함 필요');
if (!validation.hasNumber)
errors.push('숫자 포함 필요');
}
}

if (field === 'confirmPassword') {
if (!validation.required)
errors.push('비밀번호 확인은 필수입니다');
else if (!validation.matches)
errors.push('비밀번호가 일치하지 않습니다');
}

return errors;
}
</script>

<form class="validation-form">
<h2>회원가입</h2>

<div class="field">
<label for="username">사용자명</label>
<input
id="username"
type="text"
bind:value="{formData.username}"
onblur="{()"
=""
/>
handleBlur('username')} class:error={touched.username &&
getFieldErrors('username').length > 0} /> {#if
touched.username} {#each getFieldErrors('username') as
error}
<span class="error-message">{error}</span>
{/each} {/if}
</div>

<div class="field">
<label for="email">이메일</label>
<input
id="email"
type="email"
bind:value="{formData.email}"
onblur="{()"
=""
/>
handleBlur('email')} class:error={touched.email &&
getFieldErrors('email').length > 0} /> {#if
touched.email} {#each getFieldErrors('email') as error}
<span class="error-message">{error}</span>
{/each} {/if}
</div>

<div class="field">
<label for="password">비밀번호</label>
<input
id="password"
type="password"
bind:value="{formData.password}"
onblur="{()"
=""
/>
handleBlur('password')} class:error={touched.password &&
getFieldErrors('password').length > 0} /> {#if
formData.password && touched.password}
<div class="password-strength">
<span class:valid="{validations.password.minLength}"
>✓ 8자 이상</span
>
<span
class:valid="{validations.password.hasUpperCase}"
>✓ 대문자</span
>
<span
class:valid="{validations.password.hasLowerCase}"
>✓ 소문자</span
>
<span class:valid="{validations.password.hasNumber}"
>✓ 숫자</span
>
</div>
{/if}
</div>

<div class="field">
<label for="confirmPassword">비밀번호 확인</label>
<input
id="confirmPassword"
type="password"
bind:value="{formData.confirmPassword}"
onblur="{()"
=""
/>
handleBlur('confirmPassword')}
class:error={touched.confirmPassword &&
getFieldErrors('confirmPassword').length > 0} /> {#if
touched.confirmPassword} {#each
getFieldErrors('confirmPassword') as error}
<span class="error-message">{error}</span>
{/each} {/if}
</div>

<button type="submit" disabled="{!isFormValid}">
가입하기
</button>
</form>

<style>
.validation-form {
max-width: 400px;
padding: 2rem;
}

.field {
margin-bottom: 1.5rem;
}

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

.error-message {
color: #ef4444;
font-size: 0.875rem;
display: block;
margin-top: 0.25rem;
}

.password-strength {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.875rem;
}

.password-strength span {
color: #6b7280;
}

.password-strength span.valid {
color: #22c55e;
}
</style>

에러 메시지 표시

에러 메시지는 사용자가 이해하기 쉽고 구체적이어야 합니다. 필드별로 적절한 타이밍에 표시하여 사용자를 압도하지 않으면서도 필요한 정보를 제공해야 합니다. 에러 상태에 따른 시각적 피드백과 함께 명확한 해결 방법을 안내합니다.

<script>
let form = $state({
name: '',
phone: '',
birthdate: ''
});

let errors = $state({});
let submitted = $state(false);

function validateForm() {
const newErrors = {};

// 이름 검증
if (!form.name.trim()) {
newErrors.name = '이름을 입력해주세요';
} else if (form.name.length < 2) {
newErrors.name = '이름은 2자 이상이어야 합니다';
}

// 전화번호 검증
const phoneRegex = /^01[0-9]-?\d{3,4}-?\d{4}$/;
if (!form.phone) {
newErrors.phone = '전화번호를 입력해주세요';
} else if (!phoneRegex.test(form.phone.replace(/-/g, ''))) {
newErrors.phone = '올바른 전화번호 형식이 아닙니다 (예: 010-1234-5678)';
}

// 생년월일 검증
if (!form.birthdate) {
newErrors.birthdate = '생년월일을 입력해주세요';
} else {
const birthDate = new Date(form.birthdate);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();

if (birthDate > today) {
newErrors.birthdate = '미래 날짜는 선택할 수 없습니다';
} else if (age > 120) {
newErrors.birthdate = '올바른 생년월일을 입력해주세요';
} else if (age < 14) {
newErrors.birthdate = '14세 이상만 가입 가능합니다';
}
}

errors = newErrors;
return Object.keys(newErrors).length === 0;
}

function handleSubmit(event) {
event.preventDefault();
submitted = true;

if (validateForm()) {
alert('폼이 성공적으로 제출되었습니다!');
// 폼 초기화
form = { name: '', phone: '', birthdate: '' };
errors = {};
submitted = false;
}
}

// 실시간 검증 (제출 후에만)
$effect(() => {
if (submitted) {
validateForm();
}
});
</script>

<form onsubmit={handleSubmit} class="error-handling-form">
<h2>회원 정보</h2>

{#if submitted && Object.keys(errors).length > 0}
<div class="error-summary">
<h3>다음 항목을 확인해주세요:</h3>
<ul>
{#each Object.values(errors) as error}
<li>{error}</li>
{/each}
</ul>
</div>
{/if}

<div class="field">
<label for="name">
이름 <span class="required">*</span>
</label>
<input
id="name"
type="text"
bind:value={form.name}
class:error={errors.name}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{#if errors.name}
<span id="name-error" class="field-error">
{errors.name}
</span>
{/if}
</div>

<div class="field">
<label for="phone">
전화번호 <span class="required">*</span>
</label>
<input
id="phone"
type="tel"
bind:value={form.phone}
placeholder="010-1234-5678"
class:error={errors.phone}
aria-invalid={!!errors.phone}
aria-describedby={errors.phone ? 'phone-error' : undefined}
/>
{#if errors.phone}
<span id="phone-error" class="field-error">
{errors.phone}
</span>
{/if}
</div>

<div class="field">
<label for="birthdate">
생년월일 <span class="required">*</span>
</label>
<input
id="birthdate"
type="date"
bind:value={form.birthdate}
class:error={errors.birthdate}
aria-invalid={!!errors.birthdate}
aria-describedby={errors.birthdate ? 'birthdate-error' : undefined}
/>
{#if errors.birthdate}
<span id="birthdate-error" class="field-error">
{errors.birthdate}
</span>
{/if}
</div>

<button type="submit">제출</button>
</form>

<style>
.error-handling-form {
max-width: 400px;
padding: 2rem;
}

.error-summary {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 4px;
padding: 1rem;
margin-bottom: 1.5rem;
}

.error-summary h3 {
color: #dc2626;
margin: 0 0 0.5rem 0;
font-size: 0.875rem;
}

.required {
color: #ef4444;
}

.field-error {
color: #dc2626;
font-size: 0.875rem;
margin-top: 0.25rem;
display: block;
}
</style>

사용자 경험 개선

좋은 폼 UX는 명확한 라벨, 적절한 입력 타입, 도움말 텍스트, 진행 상황 표시 등을 포함합니다. 접근성을 고려하여 ARIA 속성을 적절히 사용하고, 키보드 네비게이션을 지원해야 합니다. 폼 제출 중 로딩 상태를 표시하고 성공/실패 피드백을 제공하여 사용자가 현재 상황을 인지할 수 있도록 합니다.

<script>
let currentStep = $state(1);
let formData = $state({
// Step 1
accountType: '',
username: '',
// Step 2
fullName: '',
company: '',
// Step 3
notifications: true,
theme: 'light',
});

let isLoading = $state(false);
let completedSteps = $state(new Set());

const totalSteps = 3;

function nextStep() {
if (currentStep < totalSteps) {
completedSteps.add(currentStep);
currentStep++;
}
}

function prevStep() {
if (currentStep > 1) {
currentStep--;
}
}

function canProceed(step) {
if (step === 1) {
return formData.accountType && formData.username;
}
if (step === 2) {
return formData.fullName;
}
return true;
}

async function submitForm() {
isLoading = true;

// 제출 시뮬레이션
await new Promise(resolve => setTimeout(resolve, 2000));

isLoading = false;
alert('설정이 완료되었습니다!');
}

let progressPercentage = $derived(
((currentStep - 1) / (totalSteps - 1)) * 100
);
</script>

<div class="multi-step-form">
<div class="progress-bar">
<div
class="progress-fill"
style="width: {progressPercentage}%"
></div>
</div>

<div class="step-indicators">
{#each Array(totalSteps) as _, i}
<div
class="step-indicator"
class:active="{currentStep"
=""
=""
="i"
+
1}
class:completed="{completedSteps.has(i"
+
1)}
>
{i + 1}
</div>
{/each}
</div>

<form onsubmit|preventDefault="{submitForm}">
{#if currentStep === 1}
<div class="step-content">
<h2>계정 설정</h2>
<p class="step-description">
기본 계정 정보를 입력해주세요
</p>

<div class="field">
<label>계정 유형</label>
<div class="radio-group">
<label>
<input
type="radio"
bind:group="{formData.accountType}"
value="personal"
/>
개인
</label>
<label>
<input
type="radio"
bind:group="{formData.accountType}"
value="business"
/>
비즈니스
</label>
</div>
</div>

<div class="field">
<label for="username">
사용자명
<span class="help-text"
>영문, 숫자, 언더스코어만 사용 가능</span
>
</label>
<input
id="username"
type="text"
bind:value="{formData.username}"
pattern="[a-zA-Z0-9_]+"
required
/>
</div>
</div>
{/if} {#if currentStep === 2}
<div class="step-content">
<h2>프로필 정보</h2>
<p class="step-description">
추가 정보를 입력해주세요
</p>

<div class="field">
<label for="fullName"> 이름 </label>
<input
id="fullName"
type="text"
bind:value="{formData.fullName}"
required
/>
</div>

{#if formData.accountType === 'business'}
<div class="field">
<label for="company"> 회사명 </label>
<input
id="company"
type="text"
bind:value="{formData.company}"
/>
</div>
{/if}
</div>
{/if} {#if currentStep === 3}
<div class="step-content">
<h2>환경 설정</h2>
<p class="step-description">
선호하는 설정을 선택해주세요
</p>

<div class="field">
<label class="checkbox-label">
<input
type="checkbox"
bind:checked="{formData.notifications}"
/>
이메일 알림 받기
</label>
</div>

<div class="field">
<label for="theme">테마</label>
<select id="theme" bind:value="{formData.theme}">
<option value="light">라이트</option>
<option value="dark">다크</option>
<option value="auto">자동</option>
</select>
</div>
</div>
{/if}

<div class="form-actions">
{#if currentStep > 1}
<button type="button" onclick="{prevStep}">
이전
</button>
{/if} {#if currentStep < totalSteps}
<button
type="button"
onclick="{nextStep}"
disabled="{!canProceed(currentStep)}"
>
다음
</button>
{:else}
<button type="submit" disabled="{isLoading}">
{isLoading ? '처리 중...' : '완료'}
</button>
{/if}
</div>
</form>
</div>

<style>
.multi-step-form {
max-width: 500px;
padding: 2rem;
}

.progress-bar {
height: 4px;
background: #e2e8f0;
border-radius: 2px;
overflow: hidden;
margin-bottom: 2rem;
}

.progress-fill {
height: 100%;
background: #3b82f6;
transition: width 0.3s ease;
}

.step-indicators {
display: flex;
justify-content: space-between;
margin-bottom: 2rem;
}

.step-indicator {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: #e2e8f0;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}

.step-indicator.active {
background: #3b82f6;
color: white;
}

.step-indicator.completed {
background: #22c55e;
color: white;
}

.step-description {
color: #6b7280;
margin-bottom: 1.5rem;
}

.help-text {
display: block;
font-size: 0.875rem;
color: #6b7280;
margin-top: 0.25rem;
}

.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
}
</style>

정리

Svelte의 바인딩과 폼 처리 시스템을 완전히 마스터했습니다! 이제 다음과 같은 핵심 개념들을 이해했습니다:

핵심 요약

  • 양방향 데이터 바인딩: bind:value, bind:checked, bind:group을 통한 직관적인 입력 관리
  • 고급 바인딩: bind:this로 DOM 요소와 컴포넌트 인스턴스 직접 제어, 미디어 요소 속성 바인딩
  • 폼 검증과 UX: 실시간 검증, 에러 메시지 표시, 멀티스텝 폼 등 사용자 친화적인 폼 구현

실무 활용 팁

  • 복잡한 검증 로직은 $derived를 활용하여 선언적으로 구현
  • bind:this는 컴포넌트 마운트 후 $effect 내에서 사용
  • 폼 UX 개선을 위해 적절한 피드백과 접근성 고려

다음 단계: 9장 "애니메이션과 전환"에서는 Svelte의 강력한 애니메이션 시스템을 알아보겠습니다. 트랜지션, 애니메이션, 모션 효과를 통해 생동감 있는 UI를 만들어보세요!