Svelte 완벽 가이드: 진정한 반응형 프레임워크
이 글의 핵심
Svelte는 빌드 타임에 컴파일되어 런타임 오버헤드가 없는 진정한 반응형 프레임워크입니다. 가상 DOM 없이 직접 DOM을 업데이트하여 React/Vue보다 빠르고 번들 크기가 작습니다. 문법이 직관적이고 보일러플레이트가 적어 학습 곡선이 낮고 생산성이 높습니다.
Svelte란?
Svelte는 Rich Harris가 만든 혁신적인 JavaScript 프레임워크로, 컴파일러 기반으로 동작하여 런타임 오버헤드가 없습니다. “Write less code”라는 철학으로 간결하고 직관적인 코드를 작성할 수 있습니다.
핵심 특징
-
컴파일러 기반
- 빌드 시 최적화된 코드 생성
- 런타임 라이브러리 불필요
- 작은 번들 크기
-
진정한 반응성
- 변수 할당만으로 UI 업데이트
- 명시적 상태 관리 불필요
- 간결한 문법
-
가상 DOM 없음
- 직접 DOM 조작
- 빠른 업데이트
- 메모리 효율적
-
내장 기능
- 애니메이션
- 트랜지션
- 스토어 (상태 관리)
- 모션
React vs Vue vs Svelte 비교
| 항목 | React | Vue | Svelte |
|---|---|---|---|
| 타입 | 라이브러리 | 프레임워크 | 컴파일러 |
| 가상 DOM | ✅ | ✅ | ❌ (직접 조작) |
| 런타임 크기 | 40KB | 34KB | 2KB |
| 번들 크기 (Todo App) | 45KB | 41KB | 7KB |
| 학습 곡선 | 중간 | 낮음 | 매우 낮음 |
| 반응성 | Hooks | Composition API | 변수 할당 |
| 상태 관리 | Redux, Zustand | Vuex, Pinia | 내장 (Stores) |
| 컴포넌트 구조 | JSX | SFC (템플릿) | SFC (템플릿) |
| TypeScript | 좋음 | 좋음 | 좋음 |
코드 비교
<!-- Svelte -->
<script>
let count = 0;
$: doubled = count * 2; // 반응형 변수
function increment() {
count += 1; // 이것만으로 UI 업데이트!
}
</script>
<button on:click={increment}>
Count: {count} (Doubled: {doubled})
</button>
// React
import { useState, useMemo } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const doubled = useMemo(() => count * 2, [count]);
function increment() {
setCount(count + 1);
}
return (
<button onClick={increment}>
Count: {count} (Doubled: {doubled})
</button>
);
}
프로젝트 생성
Svelte 프로젝트를 시작하는 방법은 두 가지입니다. 간단한 SPA를 만든다면 Vite를, 풀스택 애플리케이션이나 SSR이 필요하다면 SvelteKit을 사용하세요.
Vite로 생성 (권장 - SPA)
Vite는 Svelte의 공식 빌드 도구로, 매우 빠른 개발 경험을 제공합니다. HMR(Hot Module Replacement)이 즉각적이고, 설정이 거의 필요 없습니다.
템플릿 선택 시 TypeScript를 사용하면 타입 안전성과 자동완성의 이점을 얻을 수 있습니다. Svelte는 TypeScript를 일급 지원하며, 컴파일러가 자동으로 타입을 체크합니다.
# npm
npm create vite@latest my-svelte-app -- --template svelte
# TypeScript
npm create vite@latest my-svelte-app -- --template svelte-ts
# 프로젝트 시작
cd my-svelte-app
npm install
npm run dev
개발 서버가 시작되면 http://localhost:5173에서 즉시 확인할 수 있습니다. 파일을 수정하면 브라우저가 자동으로 업데이트됩니다.
SvelteKit으로 생성 (풀스택)
SvelteKit은 Svelte의 공식 애플리케이션 프레임워크로, Next.js나 Nuxt와 유사합니다. SSR, 라우팅, API 엔드포인트, 파일 기반 라우팅을 기본 제공합니다.
대화형 CLI가 프로젝트 타입, TypeScript 사용 여부, ESLint, Prettier 설정 등을 물어봅니다. 처음 배운다면 데모 앱을 선택하여 예제 코드를 참고하세요.
npm create svelte@latest my-app
# 선택 옵션:
# - SvelteKit demo app
# - Skeleton project
# - Library project
cd my-app
npm install
npm run dev
기본 문법
반응형 변수
<script>
let name = 'Svelte';
let count = 0;
// 반응형 구문 ($:)
$: greeting = `Hello ${name}!`;
$: {
console.log(`Count is ${count}`);
if (count >= 10) {
alert('Too high!');
}
}
function increment() {
count += 1; // 변수 할당만으로 UI 업데이트
}
</script>
<h1>{greeting}</h1>
<p>Count: {count}</p>
<button on:click={increment}>+</button>
Props (Properties)
<!-- Parent.svelte -->
<script>
import Child from './Child.svelte';
</script>
<Child name="Alice" age={25} />
<!-- Child.svelte -->
<script>
export let name;
export let age = 0; // 기본값
export let optional = undefined; // 옵션 prop
</script>
<p>Name: {name}, Age: {age}</p>
이벤트 핸들링
<script>
let count = 0;
function handleClick(event) {
count += 1;
}
function handleSubmit(event) {
event.preventDefault();
console.log('Submitted');
}
</script>
<!-- 기본 -->
<button on:click={handleClick}>Click</button>
<!-- 인라인 -->
<button on:click={() => count += 1}>+</button>
<!-- 이벤트 수정자 -->
<button on:click|preventDefault|stopPropagation={handleClick}>
Click
</button>
<!-- 폼 -->
<form on:submit|preventDefault={handleSubmit}>
<input type="text" />
<button type="submit">Submit</button>
</form>
조건부 렌더링
<script>
let loggedIn = false;
let role = 'admin';
</script>
<!-- if/else -->
{#if loggedIn}
<p>Welcome back!</p>
{:else}
<p>Please log in</p>
{/if}
<!-- else if -->
{#if role === 'admin'}
<p>Admin panel</p>
{:else if role === 'moderator'}
<p>Moderator panel</p>
{:else}
<p>User panel</p>
{/if}
리스트 렌더링
<script>
let items = [
{ id: 1, text: 'Apple' },
{ id: 2, text: 'Banana' },
{ id: 3, text: 'Cherry' }
];
</script>
<!-- each -->
<ul>
{#each items as item (item.id)}
<li>{item.text}</li>
{/each}
</ul>
<!-- index 사용 -->
{#each items as item, index (item.id)}
<li>{index + 1}: {item.text}</li>
{/each}
<!-- else (리스트가 비었을 때) -->
{#each items as item (item.id)}
<li>{item.text}</li>
{:else}
<p>No items</p>
{/each}
양방향 바인딩
<script>
let name = '';
let checked = false;
let selected = 'a';
let value = 5;
</script>
<!-- 텍스트 입력 -->
<input bind:value={name} />
<p>Hello {name}!</p>
<!-- 체크박스 -->
<input type="checkbox" bind:checked />
<!-- 라디오 -->
<input type="radio" bind:group={selected} value="a" />
<input type="radio" bind:group={selected} value="b" />
<!-- 범위 -->
<input type="range" bind:value min="0" max="10" />
<!-- 셀렉트 -->
<select bind:value={selected}>
<option value="a">A</option>
<option value="b">B</option>
</select>
<!-- Contenteditable -->
<div contenteditable="true" bind:textContent={name}></div>
컴포넌트
슬롯
<!-- Card.svelte -->
<div class="card">
<header>
<slot name="header">Default Header</slot>
</header>
<main>
<slot>Default Content</slot>
</main>
<footer>
<slot name="footer" />
</footer>
</div>
<!-- App.svelte -->
<Card>
<h1 slot="header">Custom Header</h1>
<p>Custom content</p>
<small slot="footer">Footer text</small>
</Card>
컴포넌트 이벤트
<!-- Child.svelte -->
<script>
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
function handleClick() {
dispatch('message', {
text: 'Hello from child!'
});
}
</script>
<button on:click={handleClick}>Send Message</button>
<!-- Parent.svelte -->
<script>
import Child from './Child.svelte';
function handleMessage(event) {
console.log(event.detail.text);
}
</script>
<Child on:message={handleMessage} />
컴포넌트 바인딩
<!-- Child.svelte -->
<script>
export let value = '';
</script>
<input bind:value />
<!-- Parent.svelte -->
<script>
import Child from './Child.svelte';
let childValue;
</script>
<Child bind:value={childValue} />
<p>Child value: {childValue}</p>
스토어 (Store)
Writable Store
// stores.js
import { writable } from 'svelte/store';
export const count = writable(0);
export const user = writable({
name: 'Guest',
email: ''
});
<!-- App.svelte -->
<script>
import { count, user } from './stores.js';
// 스토어 구독 ($접두사)
// 자동으로 구독/구독 해제
function increment() {
count.update(n => n + 1);
}
function reset() {
count.set(0);
}
</script>
<p>Count: {$count}</p>
<p>User: {$user.name}</p>
<button on:click={increment}>+</button>
<button on:click={reset}>Reset</button>
<input bind:value={$user.name} />
Readable Store
// stores.js
import { readable } from 'svelte/store';
export const time = readable(new Date(), function start(set) {
const interval = setInterval(() => {
set(new Date());
}, 1000);
return function stop() {
clearInterval(interval);
};
});
Derived Store
// stores.js
import { writable, derived } from 'svelte/store';
export const count = writable(1);
export const doubled = derived(count, $count => $count * 2);
export const sum = derived(
[count, doubled],
([$count, $doubled]) => $count + $doubled
);
Custom Store
// stores.js
import { writable } from 'svelte/store';
function createCounter() {
const { subscribe, set, update } = writable(0);
return {
subscribe,
increment: () => update(n => n + 1),
decrement: () => update(n => n - 1),
reset: () => set(0)
};
}
export const counter = createCounter();
<script>
import { counter } from './stores.js';
</script>
<p>{$counter}</p>
<button on:click={counter.increment}>+</button>
<button on:click={counter.decrement}>-</button>
<button on:click={counter.reset}>Reset</button>
라이프사이클
<script>
import { onMount, onDestroy, beforeUpdate, afterUpdate } from 'svelte';
onMount(() => {
console.log('Component mounted');
// cleanup 함수 반환
return () => {
console.log('Cleanup');
};
});
onDestroy(() => {
console.log('Component destroyed');
});
beforeUpdate(() => {
console.log('Before update');
});
afterUpdate(() => {
console.log('After update');
});
</script>
애니메이션 & 트랜지션
Transition
<script>
import { fade, fly, slide, scale } from 'svelte/transition';
let visible = true;
</script>
<button on:click={() => visible = !visible}>Toggle</button>
{#if visible}
<div transition:fade>Fade</div>
<div transition:fly={{ y: 200 }}>Fly</div>
<div transition:slide>Slide</div>
<div transition:scale>Scale</div>
<!-- 진입/퇴장 따로 -->
<div in:fly={{ x: -200 }} out:fade>Custom</div>
{/if}
커스텀 Transition
// transitions.js
export function typewriter(node, { speed = 1 }) {
const valid = node.childNodes.length === 1 && node.childNodes[0].nodeType === Node.TEXT_NODE;
if (!valid) return {};
const text = node.textContent;
const duration = text.length / (speed * 0.01);
return {
duration,
tick: t => {
const i = Math.trunc(text.length * t);
node.textContent = text.slice(0, i);
}
};
}
<script>
import { typewriter } from './transitions.js';
</script>
<p transition:typewriter={{ speed: 1 }}>
This text will appear character by character
</p>
Animation
<script>
import { flip } from 'svelte/animate';
import { quintOut } from 'svelte/easing';
let list = [1, 2, 3, 4, 5];
function shuffle() {
list = list.sort(() => 0.5 - Math.random());
}
</script>
<button on:click={shuffle}>Shuffle</button>
<div class="list">
{#each list as item (item)}
<div animate:flip={{ duration: 300, easing: quintOut }}>
{item}
</div>
{/each}
</div>
SvelteKit
프로젝트 구조
my-app/
├── src/
│ ├── routes/
│ │ ├── +page.svelte # /
│ │ ├── +page.js # 데이터 로딩
│ │ ├── about/
│ │ │ └── +page.svelte # /about
│ │ ├── blog/
│ │ │ ├── +page.svelte # /blog
│ │ │ └── [slug]/
│ │ │ └── +page.svelte # /blog/:slug
│ │ └── api/
│ │ └── posts/
│ │ └── +server.js # API 라우트
│ ├── lib/
│ │ └── components/
│ ├── app.html
│ └── app.css
├── static/
├── svelte.config.js
└── vite.config.js
라우팅
<!-- src/routes/+page.svelte -->
<h1>Home</h1>
<!-- src/routes/about/+page.svelte -->
<h1>About</h1>
<!-- src/routes/blog/[slug]/+page.svelte -->
<script>
export let data;
</script>
<h1>{data.post.title}</h1>
<div>{@html data.post.content}</div>
데이터 로딩
// src/routes/blog/[slug]/+page.js
export async function load({ params, fetch }) {
const res = await fetch(`/api/posts/${params.slug}`);
const post = await res.json();
return {
post
};
}
API 라우트
// src/routes/api/posts/+server.js
import { json } from '@sveltejs/kit';
export async function GET({ url }) {
const posts = await db.getPosts();
return json(posts);
}
export async function POST({ request }) {
const data = await request.json();
const post = await db.createPost(data);
return json(post, { status: 201 });
}
Form Actions
// src/routes/contact/+page.server.js
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
const name = data.get('name');
const email = data.get('email');
// 데이터 처리
await sendEmail({ name, email });
return { success: true };
}
};
<!-- src/routes/contact/+page.svelte -->
<script>
export let form;
</script>
{#if form?.success}
<p>메시지가 전송되었습니다!</p>
{/if}
<form method="POST">
<input name="name" required />
<input name="email" type="email" required />
<button>Send</button>
</form>
TypeScript 지원
<script lang="ts">
interface User {
name: string;
age: number;
}
export let user: User;
let count: number = 0;
function increment(): void {
count += 1;
}
$: doubled = count * 2 as number;
</script>
<p>{user.name} ({user.age})</p>
<button on:click={increment}>{count}</button>
실전 예제
Todo 앱
<script>
let todos = [
{ id: 1, text: 'Learn Svelte', done: false },
{ id: 2, text: 'Build an app', done: false }
];
let newTodo = '';
function addTodo() {
if (!newTodo.trim()) return;
todos = [...todos, {
id: Date.now(),
text: newTodo,
done: false
}];
newTodo = '';
}
function toggleTodo(id) {
todos = todos.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
);
}
function deleteTodo(id) {
todos = todos.filter(todo => todo.id !== id);
}
$: remaining = todos.filter(t => !t.done).length;
</script>
<style>
.done {
text-decoration: line-through;
opacity: 0.6;
}
</style>
<h1>Todo App</h1>
<p>{remaining} remaining</p>
<form on:submit|preventDefault={addTodo}>
<input bind:value={newTodo} placeholder="Add todo" />
<button type="submit">Add</button>
</form>
<ul>
{#each todos as todo (todo.id)}
<li class:done={todo.done}>
<input
type="checkbox"
checked={todo.done}
on:change={() => toggleTodo(todo.id)}
/>
{todo.text}
<button on:click={() => deleteTodo(todo.id)}>Delete</button>
</li>
{/each}
</ul>
API 통신
<script>
import { onMount } from 'svelte';
let posts = [];
let loading = true;
let error = null;
onMount(async () => {
try {
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
if (!res.ok) throw new Error('Failed to fetch');
posts = await res.json();
} catch (e) {
error = e.message;
} finally {
loading = false;
}
});
</script>
{#if loading}
<p>Loading...</p>
{:else if error}
<p>Error: {error}</p>
{:else}
<ul>
{#each posts as post (post.id)}
<li>
<h3>{post.title}</h3>
<p>{post.body}</p>
</li>
{/each}
</ul>
{/if}
실전 사례: 실시간 채팅 앱
Svelte의 반응성과 스토어를 활용하면 복잡한 상태 관리 없이 실시간 애플리케이션을 쉽게 만들 수 있습니다. WebSocket과 통합한 채팅 앱을 만들어보겠습니다.
WebSocket 연결과 메시지 스토어
Svelte 스토어는 WebSocket 이벤트를 반응형 상태로 변환하는 데 완벽합니다. 커스텀 스토어를 만들어 연결, 메시지 전송, 수신을 캡슐화할 수 있습니다.
// stores/chat.js
import { writable } from 'svelte/store';
function createChatStore() {
const { subscribe, update } = writable({
messages: [],
users: [],
connected: false
});
let ws;
return {
subscribe,
connect: (username) => {
ws = new WebSocket('wss://chat-server.example.com');
ws.onopen = () => {
update(state => ({ ...state, connected: true }));
ws.send(JSON.stringify({ type: 'join', username }));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'message') {
update(state => ({
...state,
messages: [...state.messages, data]
}));
} else if (data.type === 'users') {
update(state => ({ ...state, users: data.users }));
}
};
ws.onclose = () => {
update(state => ({ ...state, connected: false }));
};
},
sendMessage: (text) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'message', text }));
}
},
disconnect: () => {
if (ws) {
ws.close();
}
}
};
}
export const chat = createChatStore();
채팅 UI 컴포넌트
Svelte의 간결한 문법으로 복잡한 UI도 읽기 쉽게 작성할 수 있습니다. $를 붙이면 스토어를 자동으로 구독하여 변경 시 UI가 업데이트됩니다.
<!-- Chat.svelte -->
<script>
import { onMount, onDestroy } from 'svelte';
import { chat } from './stores/chat';
let username = '';
let messageText = '';
let messagesContainer;
// 자동 스크롤
$: if ($chat.messages.length > 0 && messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
function handleJoin() {
if (username.trim()) {
chat.connect(username);
}
}
function handleSend() {
if (messageText.trim()) {
chat.sendMessage(messageText);
messageText = '';
}
}
onDestroy(() => {
chat.disconnect();
});
</script>
{#if !$chat.connected}
<div class="login">
<h2>채팅 참여</h2>
<input
bind:value={username}
placeholder="사용자 이름"
on:keydown={(e) => e.key === 'Enter' && handleJoin()}
/>
<button on:click={handleJoin}>입장</button>
</div>
{:else}
<div class="chat">
<aside class="users">
<h3>참여자 ({$chat.users.length})</h3>
<ul>
{#each $chat.users as user}
<li>{user}</li>
{/each}
</ul>
</aside>
<main>
<div class="messages" bind:this={messagesContainer}>
{#each $chat.messages as msg (msg.id)}
<div class="message" class:own={msg.username === username}>
<strong>{msg.username}</strong>
<p>{msg.text}</p>
<time>{new Date(msg.timestamp).toLocaleTimeString()}</time>
</div>
{/each}
</div>
<form on:submit|preventDefault={handleSend}>
<input
bind:value={messageText}
placeholder="메시지 입력..."
autocomplete="off"
/>
<button type="submit">전송</button>
</form>
</main>
</div>
{/if}
<style>
.chat {
display: grid;
grid-template-columns: 200px 1fr;
height: 100vh;
}
.messages {
overflow-y: auto;
padding: 1rem;
}
.message.own {
background: #e3f2fd;
text-align: right;
}
</style>
이 코드는 React로 작성하면 훨씬 길어지고 복잡해집니다. Svelte는 상태 관리 라이브러리 없이도 복잡한 앱을 간결하게 작성할 수 있습니다.
베스트 프랙티스
1. 컴포넌트 분리
Svelte 컴포넌트는 작고 집중된 역할을 가져야 합니다. 100줄이 넘는 컴포넌트는 여러 개로 분리하세요.
<!-- ✅ 좋음: 명확한 역할 -->
<TodoList {todos} on:toggle on:delete />
<!-- ❌ 나쁨: 모든 것을 한 컴포넌트에 -->
<div>
<!-- 100줄의 HTML... -->
</div>
2. 스토어 활용
전역 상태는 스토어로 관리하고, Props drilling을 피하세요. Context API보다 스토어가 더 간단하고 강력합니다.
// ✅ 좋음: 전역 상태는 스토어로
// stores.js
export const user = writable(null);
// ❌ 나쁨: Props drilling
<A user={user}>
<B user={user}>
<C user={user} />
</B>
</A>
3. 반응형 구문 최적화
반응형 구문은 의존하는 변수가 변경될 때만 실행되도록 하세요. 불필요한 재계산은 성능을 저하시킵니다.
<!-- ✅ 좋음: 필요한 계산만 -->
$: doubled = count * 2;
<!-- ❌ 나쁨: 불필요한 재계산 -->
$: {
doubled = count * 2;
console.log('Recalculated'); // count가 변경될 때마다 실행
}
주의사항
1. 생태계 크기
Svelte는 React보다 생태계가 작습니다. 일부 서드파티 라이브러리는 Svelte 버전이 없을 수 있으므로, 프로젝트 시작 전 필요한 라이브러리를 확인하세요.
2. 채용 시장
React 개발자가 Svelte 개발자보다 많으므로, 팀을 구성할 때 고려해야 합니다. 하지만 React 개발자는 Svelte를 빠르게 배울 수 있습니다.
3. 컴파일 타임 종속
Svelte는 컴파일러에 의존하므로, 브라우저에서 직접 실행할 수 없습니다. 빌드 과정이 반드시 필요합니다.
Svelte는 간결하고 빠르며 즐거운 개발 경험을 제공하는 혁신적인 프레임워크입니다. 컴파일러 기반 접근으로 최고의 성능과 작은 번들 크기를 달성하며, React보다 훨씬 적은 코드로 같은 기능을 구현할 수 있습니다.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. Svelte를 활용한 현대적인 웹 개발 완벽 가이드. React/Vue 대비 장점, 컴파일러 기반 아키텍처, 반응성 시스템, 스토어, 애니메이션, SvelteKit까지 실전 예제로 학습합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- Svelte 5 완벽 가이드 | Runes·SvelteKit·반응성·성능 최적화
- [Svelte 5 Runes Deep Dive | Reactivity Rebuilt from Scratch](/en/blog/svelte-5-runes-deep-dive/
- [Svelte 5 Complete Guide | Runes· Snippets](/en/blog/svelte-5-complete-guide/
이 글에서 다루는 키워드 (관련 검색어)
Svelte, SvelteKit, Frontend, Framework, Reactive, JavaScript, Web Development 등으로 검색하시면 이 글이 도움이 됩니다.