Skip to main content
Summary

문법을 앱으로 연결하는 연습

지금까지 우리는 Svelte의 핵심 문법을 하나씩 배웠다. $state로 상태를 만들고, $derived로 계산값을 만들고, props로 컴포넌트를 나누고, SvelteKit의 라우팅과 load 함수로 페이지 데이터를 준비했다.

하지만 문법을 아는 것과 앱 하나를 끝까지 만드는 것은 다르다. 실제 개발에서는 이런 질문이 계속 따라온다.

  • 이 상태는 어느 컴포넌트에 두어야 할까?

  • 이 코드는 컴포넌트로 분리해야 할까?

  • 페이지가 바뀌어도 유지되어야 하는 데이터인가?

  • 서버에서 미리 가져와야 할 데이터인가, 브라우저에서 가져와도 되는가?

  • 지금 고치는 코드가 다른 화면에 영향을 주지는 않을까?

마지막 장의 목표는 새 문법을 더 많이 배우는 것이 아니다. 이미 배운 조각을 하나의 흐름으로 묶는 것이다. 여기서는 세 가지 프로젝트를 통해 기본기, 전역 상태, SvelteKit 라우팅을 차례대로 연결해 보자.

완성 프로젝트로 Svelte 실력 연결하기AI 생성 이미지

프로젝트 1: 할 일 관리 앱으로 기본기 복습하기

가장 좋은 첫 완성 프로젝트는 할 일 관리 앱이다. 단순해 보이지만 Svelte의 핵심을 거의 모두 사용한다.

필요한 기능은 다음과 같다.

  • 할 일 추가

  • 할 일 목록 표시

  • 완료 상태 변경

  • 할 일 삭제

  • 전체, 진행 중, 완료 필터

  • 새로고침 후에도 데이터 유지

이 앱의 중심 상태는 todos다. 한 화면 안에서만 사용한다면 페이지 컴포넌트에 $state로 두어도 충분하다.

<script lang="ts">
  import { onMount } from 'svelte';

  type Todo = {
    id: number;
    title: string;
    done: boolean;
  };

  let todos = $state<Todo[]>([]);
  let title = $state('');
  let filter = $state<'all' | 'active' | 'done'>('all');
  let ready = $state(false);

  const visibleTodos = $derived(
    todos.filter((todo) => {
      if (filter === 'active') return !todo.done;
      if (filter === 'done') return todo.done;
      return true;
    })
  );

  function addTodo() {
    const value = title.trim();
    if (!value) return;

    todos.push({
      id: Date.now(),
      title: value,
      done: false
    });

    title = '';
  }

  function removeTodo(id: number) {
    todos = todos.filter((todo) => todo.id !== id);
  }

  function handleSubmit(event: SubmitEvent) {
    event.preventDefault();
    addTodo();
  }

  onMount(() => {
    const saved = localStorage.getItem('todos');

    if (saved) {
      try {
        todos = JSON.parse(saved);
      } catch {
        todos = [];
      }
    }

    ready = true;
  });

  $effect(() => {
    if (!ready) return;
    localStorage.setItem('todos', JSON.stringify(todos));
  });
</script>

여기서 중요한 점은 역할 구분이다. todos, title, filter는 사용자가 바꾸는 원본 상태이므로 $state가 어울린다. 반면 visibleTodos는 원본 상태에서 계산되는 결과이므로 $derived가 어울린다. 필터링된 목록을 별도 상태로 저장하면 todos와 어긋날 수 있다.

localStorage는 브라우저에만 있는 기능이다. 그래서 처음 데이터를 읽을 때는 onMount를 사용한다. 저장은 $effect로 처리하되, 초기 로딩이 끝나기 전에는 실행하지 않도록 ready 값을 둔다. 저장된 값이 깨져 있어도 화면이 멈추지 않도록 JSON.parse는 try...catch로 감싸 두었다. 이렇게 하면 서버 렌더링 환경에서도 브라우저 전용 API를 안전하게 다룰 수 있다.

이제 화면을 붙이면 CRUD 흐름이 완성된다.

<form onsubmit={handleSubmit}>
  <input bind:value={title} placeholder="할 일을 입력하세요" />
  <button type="submit">추가</button>
</form>

<div>
  <button type="button" onclick={() => (filter = 'all')}>전체</button>
  <button type="button" onclick={() => (filter = 'active')}>진행 중</button>
  <button type="button" onclick={() => (filter = 'done')}>완료</button>
</div>

<ul>
  {#each visibleTodos as todo (todo.id)}
    <li>
      <label>
        <input type="checkbox" bind:checked={todo.done} />
        <span class:done={todo.done}>{todo.title}</span>
      </label>
      <button type="button" onclick={() => removeTodo(todo.id)}>삭제</button>
    </li>
  {:else}
    <li>표시할 할 일이 없습니다.</li>
  {/each}
</ul>

이 작은 앱 안에는 입력 바인딩, 이벤트, 반복문, {:else}를 이용한 빈 상태 처리, keyed each, $derived, $effect, onMount가 모두 들어 있다. Svelte 기본기를 점검하기에 가장 좋은 프로젝트다.

프로젝트 2: 상품 목록과 장바구니로 전역 상태 익히기

두 번째 프로젝트는 상품 목록과 장바구니 앱이다. 이 프로젝트에서는 컴포넌트 분리와 전역 상태가 중요해진다.

필요한 기능은 다음과 같다.

  • 상품 목록 표시

  • 상품 카드 컴포넌트 분리

  • 장바구니에 상품 추가

  • 수량 변경

  • 장바구니 총액 계산

  • 배송 정보 입력 폼

상품 카드는 재사용 가능한 UI다. 따라서 props로 상품 데이터와 버튼 동작을 받게 만들 수 있다.

<script lang="ts">
  type Product = {
    id: number;
    name: string;
    price: number;
    image: string;
  };

  let { product, onAdd } = $props<{
    product: Product;
    onAdd: (product: Product) => void;
  }>();
</script>

<article class="card">
  <img src={product.image} alt={product.name} />
  <h2>{product.name}</h2>
  <p>{product.price.toLocaleString()}원</p>
  <button type="button" onclick={() => onAdd(product)}>장바구니 담기</button>
</article>

장바구니는 여러 컴포넌트에서 함께 사용할 가능성이 높다. 상품 목록에서는 추가하고, 헤더에서는 개수를 보여 주고, 장바구니 페이지에서는 수량을 바꾼다. 이런 상태는 store로 분리하면 좋다.

// src/lib/stores/cart.ts
import { derived, writable } from 'svelte/store';

export type CartItem = {
  id: number;
  name: string;
  price: number;
  quantity: number;
};

export const cart = writable<CartItem[]>([]);

export const cartCount = derived(cart, ($cart) =>
  $cart.reduce((sum, item) => sum + item.quantity, 0)
);

export const cartTotal = derived(cart, ($cart) =>
  $cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
);

export function addToCart(product: Omit<CartItem, 'quantity'>) {
  cart.update((items) => {
    const found = items.find((item) => item.id === product.id);

    if (found) {
      return items.map((item) =>
        item.id === product.id
          ? { ...item, quantity: item.quantity + 1 }
          : item
      );
    }

    return [...items, { ...product, quantity: 1 }];
  });
}

export function updateQuantity(id: number, quantity: number) {
  const nextQuantity = Math.max(1, quantity);

  cart.update((items) =>
    items.map((item) =>
      item.id === id ? { ...item, quantity: nextQuantity } : item
    )
  );
}

export function removeFromCart(id: number) {
  cart.update((items) => items.filter((item) => item.id !== id));
}

여기서도 원칙은 같다. 장바구니 항목 배열은 원본 상태다. 상품 개수와 총액은 계산값이다. 그래서 cartCount, cartTotal은 derived로 만든다. 수량 변경이나 삭제처럼 장바구니를 바꾸는 동작은 store 파일에 함수로 모아 두면, 여러 컴포넌트가 같은 규칙으로 상태를 수정할 수 있다.

배송 정보 입력은 지역 상태로 충분하다. 이름, 주소, 요청사항은 장바구니 전체에서 공유할 필요가 없다면 결제 폼 컴포넌트 안에 둔다.

<script lang="ts">
  let name = $state('');
  let address = $state('');
  let memo = $state('');

  const canSubmit = $derived(
    name.trim().length > 0 && address.trim().length > 0
  );
</script>

<input bind:value={name} placeholder="이름" />
<input bind:value={address} placeholder="주소" />
<textarea bind:value={memo} placeholder="배송 요청사항"></textarea>

<button type="button" disabled={!canSubmit}>주문하기</button>

이 프로젝트에서 익혀야 할 감각은 상태의 위치다. 상품 카드 안에 장바구니 상태를 두면 다른 컴포넌트가 알 수 없다. 반대로 모든 입력값을 store에 넣으면 앱이 불필요하게 복잡해진다. 가까운 곳에서만 쓰는 값은 지역 상태, 여러 곳에서 함께 쓰는 값은 부모 상태나 store로 올린다.

프로젝트 3: API 검색 앱으로 SvelteKit 흐름 연결하기

세 번째 프로젝트는 API 검색 앱이다. 이번에는 SvelteKit의 라우팅, load 함수, 오류 처리, 동적 상세 페이지를 연결한다.

예를 들어 책 검색 앱을 만든다고 해 보자. 필요한 화면은 다음과 같다.

  • /search: 검색어 입력과 결과 목록

  • /books/[id]: 책 상세 페이지

  • 검색 결과가 없을 때의 빈 상태

  • API 오류가 발생했을 때의 오류 메시지

  • 존재하지 않는 책 상세 페이지의 404 처리

검색 페이지는 URL 쿼리와 잘 어울린다. /search?q=svelte처럼 주소에 검색어가 남으면 새로고침하거나 공유해도 같은 결과를 볼 수 있다.

// src/routes/search/+page.ts
export async function load({ fetch, url }) {
  const q = url.searchParams.get('q')?.trim() ?? '';

  if (!q) {
    return { q, results: [] };
  }

  const res = await fetch(`/api/books?q=${encodeURIComponent(q)}`);

  if (!res.ok) {
    return {
      q,
      results: [],
      error: '검색 결과를 불러오지 못했습니다.'
    };
  }

  return {
    q,
    results: await res.json()
  };
}

페이지 컴포넌트는 data props를 받아 화면 상태를 나눈다. 검색어가 없을 때, 검색 결과가 없을 때, 오류가 있을 때를 따로 보여 주면 사용자가 지금 어떤 상황인지 쉽게 이해할 수 있다.

<script lang="ts">
  let { data } = $props();
</script>

<form method="GET" action="/search">
  <input name="q" value={data.q} placeholder="검색어" />
  <button type="submit">검색</button>
</form>

{#if data.error}
  <p>{data.error}</p>
{:else if !data.q}
  <p>검색어를 입력해 보세요.</p>
{:else if data.results.length === 0}
  <p>검색 결과가 없습니다.</p>
{:else}
  <ul>
    {#each data.results as book (book.id)}
      <li>
        <a href={`/books/${book.id}`}>{book.title}</a>
      </li>
    {/each}
  </ul>
{/if}

상세 페이지는 동적 라우트로 만든다.

// src/routes/books/[id]/+page.server.ts
import { error } from '@sveltejs/kit';

export async function load({ params, fetch }) {
  const res = await fetch(`https://api.example.com/books/${params.id}`);

  if (res.status === 404) {
    error(404, '책을 찾을 수 없습니다.');
  }

  if (!res.ok) {
    error(500, '책 정보를 불러오지 못했습니다.');
  }

  return {
    book: await res.json()
  };
}

이 흐름에서 컴포넌트는 데이터를 직접 가져오는 책임을 갖지 않는다. 페이지 진입 전에 load가 데이터를 준비하고, 컴포넌트는 받은 데이터를 화면으로 표현한다. 물론 자동완성 검색처럼 사용자가 입력하는 동안 계속 바뀌는 데이터는 컴포넌트에서 추가로 다룰 수도 있다. 하지만 페이지에 들어오자마자 필요한 데이터라면 먼저 load의 책임으로 생각해 보는 것이 좋다.

오류도 상태의 일부로 다룬다. 실패 가능성을 숨기지 않고, 실패했을 때 사용자가 이해할 수 있는 화면을 준비하는 것이 실전 앱의 기본이다.

리팩터링 체크리스트

기능이 동작한다고 바로 끝내지 말자. 완성 프로젝트의 마지막 단계는 리팩터링이다. 리팩터링은 동작을 바꾸지 않고 구조를 더 읽기 쉽게 만드는 작업이다.

다음 질문을 기준으로 코드를 정리해 보자.

  • 한 파일이 너무 많은 일을 하고 있지 않은가?

  • 같은 마크업이 두 번 이상 반복되고 있지 않은가?

  • props 이름만 보고 컴포넌트의 역할을 이해할 수 있는가?

  • 원본 상태와 계산값이 구분되어 있는가?

  • $effect를 계산용으로 잘못 쓰고 있지 않은가?

  • store에 넣지 않아도 되는 지역 상태까지 전역으로 올리지 않았는가?

  • src/lib/components, src/lib/stores, src/lib/utils처럼 파일 위치가 역할을 설명하는가?

  • 라우트 파일은 URL 구조와 자연스럽게 대응하는가?

  • 오류, 빈 상태, 로딩 상태가 빠져 있지 않은가?

  • 배포 전 check, test, build, preview를 실행했는가?

파일명도 일관성이 중요하다. 컴포넌트는 ProductCard.svelte, CartSummary.svelte처럼 역할이 드러나게 짓는다. store는 cart.ts, theme.ts처럼 상태의 주제를 이름으로 삼는다. 유틸 함수는 formatPrice.ts, validateOrder.ts처럼 동작이 보이게 만든다.

좋은 구조는 처음부터 완벽하게 나오지 않는다. 먼저 작게 만들고, 중복이 보이면 분리하고, 상태가 여러 곳에서 필요해지면 위치를 조정한다. Svelte는 컴포넌트 분리가 가볍기 때문에 이런 반복에 잘 맞는다.

요구사항을 구조로 바꾸는 연습

새 프로젝트를 시작할 때는 바로 코드를 쓰기보다 요구사항을 컴포넌트와 라우트로 바꾸는 연습을 먼저 해 보자.

예를 들어 "간단한 독서 기록 앱"을 만든다면 요구사항은 이렇게 나눌 수 있다.

  • 책 목록을 볼 수 있다.

  • 책을 추가할 수 있다.

  • 읽음 상태를 바꿀 수 있다.

  • 책 상세 메모를 볼 수 있다.

  • 새로고침해도 기록이 남는다.

이 요구사항은 다음 구조로 바꿀 수 있다.

src/routes/
  +page.svelte              # 책 목록
  books/[id]/+page.svelte   # 책 상세

src/lib/components/
  BookForm.svelte
  BookList.svelte
  BookCard.svelte
  EmptyState.svelte

src/lib/stores/
  books.ts

그다음 상태 위치를 정한다.

  • 책 목록이 여러 페이지에서 필요하면 books.ts store로 분리한다.

  • 책 추가 폼의 입력값은 BookForm.svelte 안의 지역 상태로 둔다.

  • 전체 권수, 완료 권수는 원본 상태가 아니라 계산값으로 만든다.

  • 상세 페이지의 id는 라우트 파라미터에서 읽는다.

이렇게 요구사항을 구조로 번역하면 코드를 쓰기 전부터 앱의 윤곽이 보인다. 화면은 컴포넌트가 되고, 주소는 라우트가 되고, 사용자의 행동은 이벤트와 함수가 되고, 데이터의 위치는 상태 관리 방식이 된다. 서버에 제출해야 하는 폼이라면 SvelteKit의 form action으로 이어질 수도 있다.

완성의 기준

완성 프로젝트는 코드 양이 많은 프로젝트가 아니다. 사용자가 한 가지 목적을 끝까지 달성할 수 있는 프로젝트다.

할 일 앱이라면 할 일을 추가하고, 완료하고, 다시 돌아와도 목록이 남아 있어야 한다. 장바구니 앱이라면 상품을 담고, 수량을 바꾸고, 총액을 확인할 수 있어야 한다. 검색 앱이라면 검색하고, 결과를 보고, 상세 페이지로 이동하고, 오류 상황도 이해할 수 있어야 한다.

Svelte 개발의 핵심은 복잡한 규칙을 외우는 데 있지 않다. 상태가 어디에 있어야 하는지, 그 상태가 어떤 화면을 만들고, 어떤 사용자 행동으로 바뀌는지를 차분히 연결하는 데 있다.

이제 작은 앱을 하나 정해 처음부터 끝까지 만들어 보자. 처음에는 투박해도 괜찮다. 기능을 완성하고, 컴포넌트를 나누고, 상태 위치를 조정하고, 빌드해 보면서 다시 다듬으면 된다. 그 반복 속에서 Svelte 문법은 조각난 지식이 아니라 실제 앱을 만드는 도구가 된다.

책 목록

쉽게 배우는 Svelte 개발: Svelte 5와 SvelteKit으로 시작하는 프론트엔드

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기