Svelte 입문: 컴파일러 기반 반응성, 컴포넌트, 스토어, 트랜지션, SvelteKit

이 글의 핵심

Svelte는 컴포넌트를 빌드 시점에 DOM 조작 코드로 컴파일하기 때문에 가상 DOM 비교 없이 바뀐 부분만 갱신합니다. 이 글은 Svelte 4 문법으로 반응성, props, 이벤트, 스토어, 트랜지션, SvelteKit 라우팅을 예제로 익히고, 배열 push가 화면에 반영되지 않는 이유나 SvelteKit 서버에서 전역 스토어가 요청 간에 공유되는 문제처럼 처음 쓸 때 부딪히는 함정과 Svelte 5 Runes와의 대응 관계를 정리합니다.

Svelte란?

Svelte는 Rich Harris가 만든 JavaScript UI 프레임워크입니다. React나 Vue가 브라우저에서 동작하는 런타임 라이브러리가 상태 변화를 감지하고 가상 DOM을 비교해 화면을 갱신하는 것과 달리, Svelte는 빌드 시점의 컴파일러가 .svelte 파일을 분석해 “이 변수가 바뀌면 이 텍스트 노드를 갱신하라”는 구체적인 DOM 조작 코드를 미리 생성합니다. 그래서 브라우저에 보내는 런타임 코드가 작고, 갱신할 때 트리 전체를 비교하는 단계가 없습니다.

“런타임 오버헤드가 없다”는 말은 정확히는 “작다”입니다. 컴파일된 코드도 스케줄링, 트랜지션, 스토어 같은 공용 헬퍼를 런타임으로 가져오고, 컴포넌트가 많아지면 컴포넌트마다 생성된 코드가 쌓여 번들이 커집니다. 작은 앱에서는 번들 크기 이점이 뚜렷하지만 큰 앱에서는 그 차이가 줄어든다는 점이 트레이드오프입니다.

핵심 특징

  1. 컴파일러 기반

    • 빌드 시 최적화된 코드 생성
    • 런타임 라이브러리 불필요
    • 작은 번들 크기
  2. 진정한 반응성

    • 변수 할당만으로 UI 업데이트
    • 명시적 상태 관리 불필요
    • 간결한 문법
  3. 가상 DOM 없음

    • 직접 DOM 조작
    • 빠른 업데이트
    • 메모리 효율적
  4. 내장 기능

    • 애니메이션
    • 트랜지션
    • 스토어 (상태 관리)
    • 모션

React vs Vue vs Svelte 비교

항목ReactVueSvelte
타입라이브러리프레임워크컴파일러
가상 DOM✅✅❌ (컴파일된 DOM 조작 코드)
런타임 크기큼중간작음 (필요한 헬퍼만 포함)
학습 곡선중간낮음낮음
반응성HooksComposition API변수 할당
상태 관리Redux, ZustandVuex, Pinia내장 (Stores)
컴포넌트 구조JSXSFC (템플릿)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>
  );
}

두 코드의 차이는 줄 수보다 업데이트가 일어나는 방식에 있습니다. React는 setCount가 호출되면 Counter 함수 전체를 다시 실행해 새 가상 DOM을 만들고 이전 것과 비교합니다. Svelte 컴파일러는 count += 1이라는 할당을 보고 그 뒤에 “count에 의존하는 곳(doubled, 버튼 텍스트)만 갱신하라”는 코드를 끼워 넣습니다. 컴포넌트 함수를 다시 실행하지 않으므로 useMemo나 useCallback으로 재계산을 막을 일이 없습니다.

대신 Svelte 4의 반응성은 할당문이라는 문법적 표시에 기대기 때문에, 할당처럼 보이지 않는 변경은 놓칩니다. 가장 흔한 사례가 배열과 객체입니다. items.push(newItem)은 배열 내용을 바꾸지만 items에 새 값을 할당하지 않으므로 화면이 갱신되지 않습니다. 처음 Svelte를 쓸 때 대부분이 한 번은 “데이터는 들어갔는데 화면이 안 바뀐다”는 상황을 겪고, 원인이 바로 이것입니다. items = [...items, newItem]처럼 새 배열을 할당하거나 items.push(x); items = items;처럼 자기 자신을 다시 할당해야 합니다. 같은 이유로 obj.nested.value = 1은 obj를 참조하는 곳을 갱신하지만, 함수 안에서 인자로 받은 객체를 수정하는 식의 간접 변경은 추적되지 않습니다.

프로젝트 생성

Svelte 프로젝트를 시작하는 방법은 두 가지입니다. 간단한 SPA를 만든다면 Vite를, 풀스택 애플리케이션이나 SSR이 필요하다면 SvelteKit을 사용하세요.

Vite로 생성 (권장 - SPA)

Vite는 Svelte의 공식 빌드 도구로, 매우 빠른 개발 경험을 제공합니다. HMR(Hot Module Replacement)이 즉각적이며, 설정이 거의 필요 없습니다.

템플릿 선택 시 TypeScript를 사용하면 타입 안전성과 자동완성의 이점을 얻을 수 있습니다. 다만 Svelte 컴파일러와 Vite는 타입 표기를 지우고 변환할 뿐 타입 검사는 하지 않습니다. 타입 에러가 있어도 개발 서버는 잘 돌아가므로, npx svelte-check를 CI나 빌드 스크립트에 넣어야 .svelte 파일의 타입 에러를 실제로 잡을 수 있습니다.

# 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 설정 등을 물어봅니다. 처음 배운다면 데모 앱을 선택하여 예제 코드를 참고하세요.

npx sv create my-app
# (예전 명령 npm create svelte@latest는 더 이상 쓰이지 않고 sv CLI로 대체됨)

# 선택 옵션:
# - SvelteKit minimal
# - SvelteKit demo
# - Svelte library

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>

$:는 원래 JavaScript의 레이블 문법인데, Svelte가 이를 “의존하는 값이 바뀌면 다시 실행하라”는 표시로 재해석한 것입니다. 컴파일러는 $: 블록 안에서 읽은 최상위 변수를 의존성으로 등록합니다. 여기서 흔한 함정이 두 가지 있습니다. 하나는 의존성이 함수 호출 뒤에 숨는 경우입니다. $: total = calcTotal();처럼 함수 안에서 items를 읽으면, $: 블록 자체에는 items가 보이지 않으므로 items가 바뀌어도 다시 계산되지 않습니다. $: total = calcTotal(items);처럼 의존성을 인자로 드러내야 합니다. 다른 하나는 실행 시점입니다. $: 블록은 컴포넌트가 갱신되기 직전, 한 번에 모아서 실행되므로 변수를 바꾼 직후의 다음 줄에서 greeting을 읽으면 아직 옛 값입니다. 또 위 예제의 alert처럼 부수 효과를 $:에 넣으면 조건을 만족하는 동안 갱신마다 반복 실행된다는 점도 기억해야 합니다.

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}

(item.id) 부분이 키입니다. 키가 없으면 Svelte는 목록을 위치 기준으로 갱신합니다. 첫 항목을 삭제하면 DOM 요소를 지우는 대신 각 <li>의 내용을 한 칸씩 당겨 채우고 마지막 요소를 지웁니다. 단순한 텍스트라면 결과가 같아 보이지만, 항목이 입력창의 포커스, 체크박스 상태, 트랜지션, 자식 컴포넌트의 내부 상태를 갖고 있다면 그 상태가 엉뚱한 항목에 붙어 남습니다. 키에 배열 인덱스를 쓰면 이 문제가 그대로 재현되므로, 항목마다 고유하고 변하지 않는 값을 써야 합니다.

양방향 바인딩

<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>

컴포넌트 바인딩은 편리하지만 데이터 흐름을 양방향으로 만듭니다. 부모와 자식이 모두 같은 값을 바꿀 수 있으므로, 여러 단계로 바인딩이 이어지면 “이 값을 누가 바꿨는지” 추적하기 어려워집니다. 폼 입력처럼 값 하나를 감싸는 작은 컴포넌트에는 잘 맞고, 복잡한 상태는 이벤트(dispatch)로 변경을 알리고 부모가 직접 값을 바꾸는 단방향 흐름이 유지보수에 유리합니다. createEventDispatcher로 보낸 이벤트는 DOM 이벤트처럼 버블링되지 않으므로, 손자 컴포넌트의 이벤트를 조부모가 받으려면 중간 컴포넌트에서 on:message처럼 값 없이 적어 전달해야 한다는 점도 자주 헷갈리는 부분입니다.

스토어 (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} />

$count처럼 $를 붙인 이름은 컴파일러가 “컴포넌트가 만들어질 때 subscribe하고 파괴될 때 해제하는” 코드로 바꿔 줍니다. .svelte 파일 안에서만 동작하는 문법이라, 일반 .js 파일에서는 get(count)나 직접 subscribe를 써야 합니다. 컴포넌트 밖에서 subscribe를 직접 호출했다면 반환된 해제 함수를 반드시 호출해야 합니다. 빠뜨리면 컴포넌트가 사라진 뒤에도 콜백이 계속 호출되는 메모리 누수가 생깁니다. bind:value={$user.name}은 입력할 때마다 user.set(...)을 호출하는 코드로 변환되므로, writable 스토어에서만 쓸 수 있습니다.

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>

onMount는 브라우저에서만 실행됩니다. SvelteKit에서 서버 사이드 렌더링을 할 때 컴포넌트의 <script> 최상위 코드는 서버에서도 실행되지만 onMount는 건너뛰므로, window, document, localStorage에 접근하는 코드는 onMount 안에 두어야 합니다. 최상위에서 localStorage.getItem(...)을 호출하면 서버 렌더링 단계에서 localStorage is not defined 에러로 페이지가 실패합니다. 반대로 onDestroy는 서버에서도 호출되므로 여기서 브라우저 API를 쓰면 같은 에러가 납니다. 정리 코드는 onMount에서 반환하는 함수에 두는 편이 안전합니다. 참고로 beforeUpdate와 afterUpdate는 Svelte 5에서 폐기 예정이며 $effect.pre와 $effect로 대체됩니다.

애니메이션 & 트랜지션

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}

트랜지션은 요소가 {#if}나 {#each} 블록 때문에 DOM에 추가되거나 제거될 때만 실행됩니다. CSS의 display: none으로 숨기거나 클래스만 바꿔서는 동작하지 않습니다. Svelte 4부터는 트랜지션이 기본적으로 로컬이라, 바로 감싸는 블록이 바뀔 때만 재생되고 상위 블록이 통째로 사라질 때는 재생되지 않습니다. 페이지 전환처럼 상위 블록 변화에도 재생하고 싶다면 transition:fade|global로 지정합니다. Svelte 내장 트랜지션은 대부분 CSS 애니메이션을 생성해 메인 스레드와 별개로 재생되므로 부드럽지만, 아래의 typewriter처럼 tick 함수를 쓰는 트랜지션은 매 프레임 JavaScript를 실행한다는 차이가 있습니다.

커스텀 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>

{@html}은 문자열을 이스케이프하지 않고 그대로 HTML로 넣습니다. 게시글 본문이 사용자 입력이라면 <script>나 onerror 속성이 든 HTML이 그대로 실행되는 XSS 취약점이 되므로, 서버에서 DOMPurify 같은 라이브러리로 정화한 콘텐츠만 넣어야 합니다. 일반 {data.post.title} 표현식은 자동으로 이스케이프되므로 안전합니다.

데이터 로딩

// 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
  };
}

+page.js의 load는 첫 방문 시 서버에서, 이후 페이지 내 이동 시에는 브라우저에서 실행됩니다. 인자로 받는 fetch는 이 차이를 흡수해 주는 SvelteKit 전용 버전이라, 서버에서는 같은 앱의 API 라우트를 네트워크를 거치지 않고 직접 호출하고, 서버에서 받은 응답을 페이지에 심어 두어 브라우저가 같은 요청을 다시 보내지 않게 합니다. 전역 fetch를 쓰면 서버 렌더링 중 상대 경로 /api/...를 해석하지 못하는 문제가 생깁니다. DB 비밀번호나 비공개 API 키처럼 브라우저에 노출되면 안 되는 코드가 필요하다면 +page.server.js의 load에 둬야 합니다. 이 파일은 항상 서버에서만 실행됩니다. 또 이 예제는 res.ok를 확인하지 않아 404 응답도 그대로 json()으로 파싱하려 하므로, 실제 코드에서는 @sveltejs/kit의 error(404, 'Not found')를 던져 에러 페이지로 넘기는 것이 맞습니다.

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>

Form Actions는 JavaScript 없이도 동작하는 일반 HTML 폼 제출 위에 만들어져 있습니다. 브라우저가 JavaScript를 실행하지 못해도 POST 요청이 서버의 actions.default로 가고, 결과가 form prop으로 돌아옵니다. <form method="POST" use:enhance>처럼 $app/forms의 enhance를 붙이면 JavaScript가 있을 때는 페이지 새로고침 없이 같은 동작을 합니다. 검증에 실패했을 때는 return { success: true } 대신 fail(400, { email, missing: true })를 반환해야 상태 코드가 올바르게 설정되고, 입력값을 돌려받아 폼에 다시 채워 줄 수 있습니다.

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, tick } from 'svelte';
  import { chat } from './stores/chat';
  
  let username = '';
  let messageText = '';
  let messagesContainer;
  

  // 자동 스크롤: 새 메시지가 DOM에 그려진 뒤 스크롤해야 함
  $: if ($chat.messages.length > 0 && messagesContainer) {
    tick().then(() => {
      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>

자동 스크롤에 tick()을 넣은 이유는 $: 블록의 실행 시점 때문입니다. $:는 DOM이 갱신되기 전에 실행되므로, 원래 코드처럼 바로 scrollHeight를 읽으면 새 메시지가 아직 그려지지 않은 높이로 스크롤해 항상 한 메시지씩 모자라게 내려갑니다. tick()은 대기 중인 DOM 갱신이 끝나면 풀리는 Promise라서, 그 뒤에 스크롤하면 새 메시지까지 보입니다. 사용자가 위로 스크롤해 옛 메시지를 읽는 중에도 강제로 맨 아래로 끌어내리지 않으려면, 갱신 전에 스크롤이 이미 맨 아래 근처였는지를 확인하는 조건을 추가하는 것이 좋습니다.

이 스토어는 모듈 최상위에서 createChatStore()를 한 번 호출해 만든 싱글턴입니다. 순수 SPA에서는 문제가 없지만, SvelteKit의 서버 사이드 렌더링과 함께 쓰면 서버 프로세스 하나에 스토어도 하나라서 모든 사용자의 요청이 같은 객체를 공유합니다. 서버에서 한 사용자의 정보를 이 스토어에 set하면 동시에 렌더링되는 다른 사용자의 페이지에 그 정보가 섞여 나갈 수 있습니다. WebSocket처럼 브라우저 전용인 기능은 onMount 안에서만 연결하고, 사용자별 상태가 필요한 스토어는 setContext로 컴포넌트 트리마다 새로 만들어 넘기는 것이 안전합니다. 또 이 구현에는 재연결 로직이 없어서 네트워크가 잠깐 끊기면 connected가 false가 된 채 사용자가 다시 입장해야 합니다. 실제 서비스라면 onclose에서 지수 백오프로 재연결을 시도하고, 끊긴 동안 보내려던 메시지를 큐에 모아 두는 처리가 필요합니다.

Svelte 5에서 달라진 문법: Runes

이 글의 예제는 Svelte 4 문법(let이 자동으로 반응형, $: 반응형 구문, export let props)으로 작성되어 있습니다. Svelte 5는 이 “컴파일러가 알아서 추론하는” 방식을 Runes라는 명시적인 API로 바꿨습니다. 컴포넌트 안의 모든 let이 반응형이던 방식은 편했지만, 어떤 값이 반응형인지 코드만 봐서는 알기 어렵고 .js 파일로 로직을 옮기면 반응성이 사라지는 문제가 있었습니다. Runes는 반응형 상태를 $state로 표시하게 해서 이 경계를 분명히 하고, 같은 문법을 .svelte.ts 모듈에서도 쓸 수 있게 했습니다. 대응 관계는 다음과 같습니다.

Svelte 4Svelte 5
let count = 0 (자동 반응형)let count = $state(0)
$: double = count * 2let double = $derived(count * 2)
$: { console.log(count) }$effect(() => { console.log(count) })
export let namelet { name } = $props()
on:click={handler}onclick={handler}
<slot />{@render children()} (Snippet)
<script>
  let { initial = 0 } = $props();
  let count = $state(initial);
  let double = $derived(count * 2);
</script>

<button onclick={() => count++}>{count} × 2 = {double}</button>

Svelte 5는 기존 Svelte 4 문법 컴포넌트도 그대로 실행하므로 한 번에 옮길 필요는 없고, npx sv migrate svelte-5로 컴포넌트 단위 자동 변환을 시작할 수 있습니다. 위의 Store도 계속 동작하지만, 새 코드에서는 .svelte.ts 파일의 $state 클래스로 대체하는 경우가 많습니다. Runes 각각의 동작과 마이그레이션 함정은 Svelte 5 Runes 심화에서 자세히 다룹니다.

주의사항

생태계 크기

Svelte는 React보다 생태계가 작습니다. 일부 서드파티 라이브러리는 Svelte 버전이 없을 수 있으므로, 프로젝트 시작 전 필요한 라이브러리를 확인하세요.

채용 시장

React 개발자가 Svelte 개발자보다 많으므로, 팀을 구성할 때 고려해야 합니다. 하지만 React 개발자는 Svelte를 빠르게 배울 수 있습니다.

컴파일 타임 종속

Svelte는 컴파일러에 의존하므로, 브라우저에서 직접 실행할 수 없습니다. 빌드 과정이 반드시 필요합니다. 컴파일러가 코드를 변환한다는 것은 디버깅 시 브라우저에서 보는 코드가 작성한 코드와 다르다는 뜻이기도 합니다. 소스맵이 대부분을 연결해 주지만, $: 블록의 실행 순서나 의존성 추적처럼 컴파일러가 결정하는 동작은 생성된 코드를 봐야 이해되는 경우가 있습니다. 공식 REPL의 “JS output” 탭에서 컴포넌트가 어떤 코드로 바뀌는지 직접 확인해 보면 반응성 규칙을 훨씬 빨리 익힐 수 있습니다.

정리하면, Svelte의 장점은 적은 코드로 반응형 UI를 만들 수 있다는 것과 작은 런타임이고, 대가는 컴파일러의 규칙(할당 기반 반응성, $:의 의존성 추적)을 정확히 이해해야 한다는 점과 React보다 좁은 생태계입니다. Svelte 5의 Runes는 이 규칙을 명시적으로 만들어 첫 번째 대가를 줄이는 방향의 변화입니다.


자주 묻는 질문 (FAQ)

Q. 이 글의 Svelte 4 문법으로 작성한 코드는 Svelte 5에서도 동작하나요?

A. 동작합니다. Svelte 5는 let 자동 반응형, $: 반응형 구문, export let props 같은 Svelte 4 문법 컴포넌트도 그대로 실행하므로 한 번에 옮길 필요는 없습니다. 다만 새 코드는 $state, $derived, $props 같은 Runes로 작성하는 것이 권장되며, 기존 컴포넌트는 npx sv migrate svelte-5로 컴포넌트 단위 자동 변환을 시작할 수 있습니다. Runes를 쓰면 .svelte.ts 모듈로 로직을 옮겨도 반응성이 유지된다는 장점이 있습니다.


같이 보면 좋은 글