본문으로 건너뛰기

5. 이벤트 처리

Svelte 5는 이벤트 처리 방식에 중요한 변화를 도입하여 더 직관적이고 성능이 뛰어난 방식으로 사용자 상호작용을 처리할 수 있게 되었습니다. 기존의 on: 지시문에서 표준 DOM 이벤트 속성으로 전환하면서 웹 표준에 더 가깝고 TypeScript 호환성이 크게 향상되었습니다. 이 장에서는 DOM 이벤트부터 커스텀 이벤트, 폼 처리까지 Svelte 5의 모든 이벤트 처리 방법을 완전히 마스터해보겠습니다.


5.1 DOM 이벤트 핸들링

Svelte 5의 새로운 이벤트 문법

Svelte 5는 이벤트 처리 방식을 크게 개선했습니다. 기존의 on:click 문법에서 표준 DOM 속성인 onclick을 직접 사용하는 방식으로 변경되어 더 직관적이고 웹 표준에 부합합니다. 이러한 변화는 TypeScript 지원을 크게 개선하고 개발자 경험을 향상시킵니다.

Svelte 4 vs Svelte 5 비교

특징Svelte 4Svelte 5
기본 이벤트<button on:click={fn}><button onclick={fn}>
이벤트 수식어on:click | preventDefault핸들러 내부에서 직접 처리
TypeScript제한적 타입 지원완전한 DOM 이벤트 타입 지원
인라인 핸들러on:click={() => count++}onclick={() => count++}

기본 이벤트 핸들링

BasicEvents.svelte
<script>
let count = $state(0);

function handleClick() {
count += 1;
}
</script>

<div class="counter">
<p>카운트: {count}</p>

<!-- 함수 참조로 이벤트 핸들러 연결 -->
<button onclick="{handleClick}">클릭 (+1)</button>

<!-- 인라인 이벤트 핸들러 -->
<button onclick="{()" ="">
count += 5}>빠른 증가 (+5)
</button>

<!-- 더블클릭으로 리셋 -->
<button ondblclick="{()" ="">count = 0}>리셋</button>
</div>

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

button {
margin: 0.5rem;
padding: 0.75rem 1.5rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>

다양한 DOM 이벤트

EventTypes.svelte
<script>
let mousePos = $state({ x: 0, y: 0 });
let inputValue = $state('');
let keyPressed = $state('');

function handleMouseMove(event) {
mousePos.x = event.clientX;
mousePos.y = event.clientY;
}

function handleKeyDown(event) {
keyPressed = event.key;
}
</script>

<div>
<!-- 마우스 이벤트 -->
<div class="mouse-area" onmousemove="{handleMouseMove}">
마우스 위치: ({mousePos.x}, {mousePos.y})
</div>

<!-- 키보드 이벤트 -->
<input
type="text"
placeholder="여기에 타이핑해보세요"
bind:value="{inputValue}"
onkeydown="{handleKeyDown}"
/>
<p>입력: "{inputValue}" | 마지막 키: "{keyPressed}"</p>
</div>

<style>
.mouse-area {
height: 100px;
background: #667eea;
color: white;
display: flex;
align-items: center;
justify-content: center;
margin: 1rem 0;
}
</style>

preventDefault와 stopPropagation

Svelte 5에서는 이벤트 수식어가 제거되고 표준 JavaScript 메서드를 직접 사용합니다. 이는 더 명시적이고 예측 가능한 동작을 제공하며, 모든 로직이 한 곳에 모이게 됩니다. 특히 폼 처리나 링크 클릭 방지 등에서 중요한 역할을 합니다.

EventControl.svelte
<script>
let logs = $state([]);

function log(message) {
logs = [message, ...logs.slice(0, 2)]; // 최근 3개만 유지
}

function handleFormSubmit(event) {
event.preventDefault();
log('폼 제출됨 (새로고침 방지)');
}

function handleLinkClick(event) {
event.preventDefault();
log('링크 클릭됨 (네비게이션 방지)');
}

function handleParentClick() {
log('부모 클릭');
}

function handleChildClick(event) {
event.stopPropagation();
log('자식 클릭 (전파 중단)');
}
</script>

<div>
<!-- 폼 제출 방지 -->
<form onsubmit="{handleFormSubmit}">
<input type="text" placeholder="텍스트 입력" />
<button type="submit">제출</button>
</form>

<!-- 링크 클릭 방지 -->
<a href="https://example.com" onclick="{handleLinkClick}">
테스트 링크 (클릭해도 이동 안됨)
</a>

<!-- 이벤트 전파 제어 -->
<div class="parent" onclick="{handleParentClick}">
부모 요소
<div class="child" onclick="{handleChildClick}">
자식 요소 (전파 차단)
</div>
</div>

<!-- 로그 출력 -->
<div class="logs">
{#each logs as logEntry}
<div>{logEntry}</div>
{/each}
</div>
</div>

<style>
.parent {
background: #dbeafe;
padding: 1rem;
margin: 1rem 0;
cursor: pointer;
}

.child {
background: #fef2f2;
padding: 0.5rem;
margin: 0.5rem;
}

.logs {
background: #f9fafb;
padding: 1rem;
margin-top: 1rem;
}
</style>

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


5.2 커스텀 이벤트

createEventDispatcher에서 콜백 Props로의 변화

Svelte 5는 컴포넌트 간 이벤트 통신 방식을 크게 개선했습니다. 기존의 createEventDispatcheron: 문법에서 콜백 props 방식으로 변경되어 더 직관적이고 타입 안전한 컴포넌트 통신이 가능해졌습니다. 이는 React의 콜백 props 패턴과 유사하여 다른 프레임워크 경험이 있는 개발자들에게도 친숙합니다.

Svelte 4 vs Svelte 5 비교

특징Svelte 4Svelte 5
이벤트 발송dispatch('eventName', data)callback(data) 직접 호출
이벤트 수신<Component on:eventName={handler}><Component callback={handler}>
타입 안전성제한적 (문자열 기반)완전한 TypeScript 지원

새로운 콜백 Props 패턴

Counter.svelte
<!-- Counter.svelte - 자식 컴포넌트 -->
<script>
let { initialValue = 0, onIncrement, onReset } = $props();

let count = $state(initialValue);

function increment() {
count += 1;
onIncrement?.(count); // 옵셔널 체이닝으로 안전하게 호출
}

function reset() {
count = initialValue;
onReset?.(count);
}
</script>

<div class="counter">
<div class="display">{count}</div>
<button onclick="{increment}">+1</button>
<button onclick="{reset}">리셋</button>
</div>

<style>
.counter {
border: 2px solid #e2e8f0;
padding: 1rem;
text-align: center;
}

.display {
font-size: 2rem;
font-weight: bold;
margin: 1rem 0;
}

button {
margin: 0 0.25rem;
padding: 0.5rem 1rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
}
</style>
CounterApp.svelte
<!-- CounterApp.svelte - 부모 컴포넌트 -->
<script>
import Counter from './Counter.svelte';

let events = $state([]);

function handleIncrement(value) {
events = [`증가: ${value}`, ...events.slice(0, 2)];
}

function handleReset(value) {
events = [`리셋: ${value}`, ...events.slice(0, 2)];
}
</script>

<div>
<h2>콜백 Props 예제</h2>

<Counter
initialValue="{10}"
onIncrement="{handleIncrement}"
onReset="{handleReset}"
/>

<div class="events">
<h3>이벤트 로그:</h3>
{#each events as event}
<div>{event}</div>
{/each}
</div>
</div>

<style>
.events {
margin-top: 1rem;
padding: 1rem;
background: #f8fafc;
}
</style>

복잡한 데이터 전달

콜백 props를 통해 복잡한 데이터 구조도 안전하게 전달할 수 있습니다. 객체나 배열 형태의 데이터를 전달하거나, 여러 개의 콜백을 통해 다양한 이벤트를 처리할 수 있습니다.

TaskItem.svelte
<!-- TaskItem.svelte -->
<script>
let { task, onToggle, onDelete } = $props();

function handleToggle() {
onToggle?.({
id: task.id,
completed: !task.completed,
});
}

function handleDelete() {
onDelete?.({
id: task.id,
title: task.title,
});
}
</script>

<div class="task-item">
<span class:completed="{task.completed}"
>{task.title}</span
>
<button onclick="{handleToggle}">
{task.completed ? '완료취소' : '완료'}
</button>
<button onclick="{handleDelete}">삭제</button>
</div>

<style>
.task-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem;
border: 1px solid #e2e8f0;
margin: 0.5rem 0;
}

.completed {
text-decoration: line-through;
opacity: 0.6;
}

button {
padding: 0.25rem 0.5rem;
background: #6b7280;
color: white;
border: none;
border-radius: 4px;
}
</style>
TaskApp.svelte
<!-- TaskApp.svelte -->
<script>
import TaskItem from './TaskItem.svelte';

let tasks = $state([
{ id: 1, title: 'Svelte 5 학습', completed: false },
{ id: 2, title: '이벤트 처리 실습', completed: true },
]);

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

function handleDelete(data) {
tasks = tasks.filter(t => t.id !== data.id);
}
</script>

<div>
<h2>할 일 목록</h2>
{#each tasks as task (task.id)}
<TaskItem
{task}
onToggle="{handleToggle}"
onDelete="{handleDelete}"
/>
{/each}
</div>

실습해보기: Svelte REPL에서 이 콜백 시스템을 직접 실험해보세요!


5.3 폼 처리

양방향 데이터 바인딩

Svelte의 bind: 지시문은 폼 처리를 매우 간단하게 만듭니다. 입력 필드와 컴포넌트 상태 간의 양방향 연결을 자동으로 처리하여 별도의 이벤트 핸들러 없이도 실시간으로 데이터가 동기화됩니다. 다양한 입력 요소 타입에 대해 적절한 바인딩 방식을 제공합니다.

기본 폼 바인딩

BasicForm.svelte
<script>
let formData = $state({
name: '',
email: '',
age: '',
gender: '',
interests: [],
newsletter: false,
});

function handleSubmit(event) {
event.preventDefault();
console.log('제출 데이터:', formData);
alert('폼 제출됨 (콘솔 확인)');
}
</script>

<div class="form-container">
<form onsubmit="{handleSubmit}">
<h2>회원가입 폼</h2>

<!-- 텍스트 입력 -->
<div class="field">
<label>이름:</label>
<input type="text" bind:value="{formData.name}" />
</div>

<div class="field">
<label>이메일:</label>
<input type="email" bind:value="{formData.email}" />
</div>

<!-- 숫자 입력 -->
<div class="field">
<label>나이:</label>
<input type="number" bind:value="{formData.age}" />
</div>

<!-- 셀렉트 -->
<div class="field">
<label>성별:</label>
<select bind:value="{formData.gender}">
<option value="">선택하세요</option>
<option value="male">남성</option>
<option value="female">여성</option>
</select>
</div>

<!-- 체크박스 그룹 -->
<div class="field">
<label>관심사:</label>
<div>
<label
><input
type="checkbox"
bind:group="{formData.interests}"
value="sports"
/>
운동</label
>
<label
><input
type="checkbox"
bind:group="{formData.interests}"
value="music"
/>
음악</label
>
</div>
</div>

<!-- 단일 체크박스 -->
<div class="field">
<label>
<input
type="checkbox"
bind:checked="{formData.newsletter}"
/>
뉴스레터 구독
</label>
</div>

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

<!-- 실시간 미리보기 -->
<div class="preview">
<h3>입력 데이터:</h3>
<pre>{JSON.stringify(formData, null, 2)}</pre>
</div>
</div>

<style>
.form-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
max-width: 800px;
}

.field {
margin-bottom: 1rem;
}

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

.preview {
background: #f8fafc;
padding: 1rem;
}
</style>

폼 검증

폼 검증은 사용자 경험의 핵심 요소입니다. Svelte에서는 반응형 상태와 계산된 값을 활용하여 실시간 검증과 사용자 친화적인 오류 메시지를 쉽게 구현할 수 있습니다.

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

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

// 검증 규칙
let validation = $derived({
username: {
isValid: formData.username.length >= 3,
message: '사용자명은 3글자 이상',
},
email: {
isValid: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
formData.email
),
message: '올바른 이메일 형식 아님',
},
password: {
isValid: formData.password.length >= 6,
message: '비밀번호는 6글자 이상',
},
});

let isFormValid = $derived(
validation.username.isValid &&
validation.email.isValid &&
validation.password.isValid
);

function handleSubmit(event) {
event.preventDefault();

// 모든 필드를 터치 상태로 설정
Object.keys(touched).forEach(
key => (touched[key] = true)
);

if (isFormValid) {
alert('회원가입 완료!');
}
}
</script>

<form onsubmit="{handleSubmit}" class="validation-form">
<h2>검증 폼</h2>

<div class="field">
<label>사용자명:</label>
<input
type="text"
bind:value="{formData.username}"
onblur="{()"
=""
/>
touched.username = true} class:error={touched.username
&& !validation.username.isValid} /> {#if
touched.username && !validation.username.isValid}
<span class="error-message"
>{validation.username.message}</span
>
{/if}
</div>

<div class="field">
<label>이메일:</label>
<input
type="email"
bind:value="{formData.email}"
onblur="{()"
=""
/>
touched.email = true} class:error={touched.email &&
!validation.email.isValid} /> {#if touched.email &&
!validation.email.isValid}
<span class="error-message"
>{validation.email.message}</span
>
{/if}
</div>

<div class="field">
<label>비밀번호:</label>
<input
type="password"
bind:value="{formData.password}"
onblur="{()"
=""
/>
touched.password = true} class:error={touched.password
&& !validation.password.isValid} /> {#if
touched.password && !validation.password.isValid}
<span class="error-message"
>{validation.password.message}</span
>
{/if}
</div>

<button type="submit" disabled="{!isFormValid}">
{isFormValid ? '가입하기' : '입력 정보 확인'}
</button>
</form>

<style>
.validation-form {
max-width: 400px;
padding: 2rem;
border: 1px solid #e2e8f0;
}

.field {
margin-bottom: 1rem;
}

input {
width: 100%;
padding: 0.5rem;
border: 2px solid #e5e7eb;
border-radius: 4px;
}

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

.error-message {
color: #ef4444;
font-size: 0.875rem;
}

button:disabled {
background: #e5e7eb;
cursor: not-allowed;
}
</style>

제출 처리

폼 제출 시 데이터 검증, 로딩 상태 관리, 에러 처리 등을 포함한 완전한 폼 처리 예제입니다.

FormSubmission.svelte
<script>
let formData = $state({
email: '',
message: '',
});

let isSubmitting = $state(false);
let submitStatus = $state(''); // 'success', 'error', ''

async function handleSubmit(event) {
event.preventDefault();

if (!formData.email || !formData.message) {
alert('모든 필드를 입력해주세요.');
return;
}

isSubmitting = true;
submitStatus = '';

try {
// API 호출 시뮬레이션 (2초 대기)
await new Promise(resolve =>
setTimeout(resolve, 2000)
);

submitStatus = 'success';

// 성공 시 폼 리셋 (3초 후)
setTimeout(() => {
formData.email = '';
formData.message = '';
submitStatus = '';
}, 3000);
} catch (error) {
submitStatus = 'error';
} finally {
isSubmitting = false;
}
}
</script>

<form onsubmit="{handleSubmit}" class="contact-form">
<h2>문의하기</h2>

{#if submitStatus === 'success'}
<div class="success">메시지가 전송되었습니다!</div>
{:else if submitStatus === 'error'}
<div class="error">
전송에 실패했습니다. 다시 시도해주세요.
</div>
{/if}

<div class="field">
<label>이메일:</label>
<input
type="email"
bind:value="{formData.email}"
disabled="{isSubmitting}"
required
/>
</div>

<div class="field">
<label>메시지:</label>
<textarea
bind:value="{formData.message}"
rows="4"
disabled="{isSubmitting}"
required
></textarea>
</div>

<button type="submit" disabled="{isSubmitting}">
{isSubmitting ? '전송 중...' : '전송하기'}
</button>
</form>

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

.success {
background: #f0fdf4;
color: #166534;
padding: 1rem;
margin-bottom: 1rem;
border-radius: 4px;
}

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

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

input:disabled,
textarea:disabled {
background: #f9fafb;
}

button {
background: #3b82f6;
color: white;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
}

button:disabled {
background: #9ca3af;
}
</style>

실습해보기: Svelte REPL에서 이 폼 처리 예제들을 직접 실험해보세요!


정리

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

핵심 요약

  • DOM 이벤트: on: 지시문에서 표준 DOM 속성으로 전환하여 더 직관적이고 타입 안전한 이벤트 처리
  • 커스텀 이벤트: createEventDispatcher에서 콜백 props로 변경하여 명시적이고 타입 안전한 컴포넌트 통신
  • 폼 처리: bind: 지시문을 통한 양방향 데이터 바인딩과 실시간 검증으로 사용자 친화적인 폼 구현

실무 활용 팁

  • 이벤트 수식어 대신 핸들러 내부에서 preventDefault(), stopPropagation() 직접 사용
  • 콜백 props를 활용하여 컴포넌트 간 명확하고 타입 안전한 통신 구현
  • 반응형 상태와 계산된 값을 활용한 실시간 폼 검증으로 UX 향상

다음 단계: 6장 "조건부 렌더링과 리스트"에서는 Svelte의 템플릿 제어 구조를 알아보겠습니다. {#if}, {#each}, {#await} 블록을 통한 동적 UI 렌더링을 마스터해보세요!