[Svelte 시리즈 20편] 캡스톤: 상태·라우팅·접근성을 묶는 미니 앱

English version

시리즈 마지막 편입니다. 지금까지의 문법과 패턴을 Task Orbit이라는 작업 관리자 앱으로 결속합니다.

시작 전에 정리하기

  • 무엇을 만든다: / 홈과 /stats, /activity 라우트를 가진 Task Orbit 미니 앱. 서버 액션, 상태 스토어, 접근성 알림, 배포 스크립트까지 통합합니다.
  • 왜 중요한가: 1~19편에서 배운 패턴을 한 흐름으로 연결해 두어야 실전 프로젝트에서 재사용할 때 기억이 남습니다.
  • 주의할 점: 폴더 구조를 먼저 고정하고, 서버 액션/스토어/라우팅이 서로 어떤 데이터를 주고받는지 문서로 남겨야 리팩터링 때 꼬이지 않습니다.

이번 글에서 새로 나오는 용어

  1. 캡스톤: 배운 내용을 한 프로젝트에 응용해 보는 종합 실습으로, 설계부터 배포까지 전 과정이 담깁니다.
  2. 라우팅: URL에 따라 다른 페이지 컴포넌트를 보여 주는 흐름으로, /stats, /activity 처럼 경로를 나눕니다.
  3. 접근성 보조 영역: aria-live나 스크린 리더 전용 영역처럼 보이지 않지만 알림을 전달하는 UI입니다.

개념

캡스톤(capstone)은 지금까지 배운 내용을 하나의 프로젝트로 묶어 보는 통합 실습입니다. 상태(state)는 컴포넌트가 기억하는 데이터입니다. 파생 스토어(derived store)는 여러 상태를 조합해 새로운 값을 계산하는 도구입니다. 페이지 라우팅(routing)은 URL에 따라 페이지를 고르는 흐름입니다. 접근성(accessibility)은 보조 기술 사용자를 포함해 누구나 앱을 쓸 수 있게 만드는 기준입니다.

D2: Task Orbit 지형도

Client UIsrc/routes/+layout.server.tssrc/routes/+page.server.tssrc/routes/stats/+page.server.tssrc/routes/activity/+page.server.tssrc/lib/stores/tasks.tsTaskForm·TaskList·StatCard·ActivityLogsrc/routes/api/tasks/+server.tsPlanetScale/Postgressrc/hooks.server.ts request + session cookielocals.user 준비레이아웃 데이터action POST create/togglefetch RESTCRUD tasksJSON초기 데이터구독파생 통계SSE 스트림

👉 미니 요약: 요청이 들어오면 레이아웃이 세션을 확인하고, 각 페이지 서버 파일이 데이터를 모읍니다. 모든 페이지는 tasks 스토어를 바라보며, /stats는 파생 통계를, /activity는 SSE를 추가로 구독합니다.

코드

폴더 구조와 핵심 모듈을 간단히 정리합니다.

src/
  lib/
    stores/tasks.ts
    router.ts
    accessibility.ts
    server/
      notifications.ts
  routes/
    +layout.svelte
    +layout.server.ts
    +page.svelte
    +page.server.ts
    stats/+page.svelte
    stats/+page.server.ts
    activity/+page.svelte
    api/tasks/+server.ts
  components/
    TaskForm.svelte
    TaskList.svelte
    StatCard.svelte
    ActivityLog.svelte
  app.d.ts
// src/lib/stores/tasks.ts

type Task = {
  id: string;
  title: string;
  priority: 'low' | 'medium' | 'high';
  done: boolean;
  createdAt: number;
};

export const tasks = writable<Task[]>([]);

export const pendingTasks = derived(tasks, ($tasks) =>
  $tasks.filter((task) => !task.done)
);

export const stats = derived(tasks, ($tasks) => ({
  total: $tasks.length,
  done: $tasks.filter((task) => task.done).length,
  ratio: $tasks.length
    ? Math.round(($tasks.filter((task) => task.done).length / $tasks.length) * 100)
    : 0
}));
<!-- src/components/TaskForm.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import { invalidate } from '$app/navigation';
  export let action = '?/create';
</script>

<form method="POST" {action}
  use:enhance={({ result }) => {
    if (result.type === 'success') invalidate('data:tasks');
  }}>
  <label>
    제목
    <input name="title" placeholder="새 작업" required />
  </label>
  <label>
    우선순위
    <select name="priority">
      <option value="low">낮음</option>
      <option value="medium" selected>보통</option>
      <option value="high">높음</option>
    </select>
  </label>
  <button type="submit">추가</button>
</form>
<!-- src/routes/+page.svelte -->
<script lang="ts">
  import TaskForm from '$components/TaskForm.svelte';
  import TaskList from '$components/TaskList.svelte';
  export let data;
</script>

<TaskForm />
<TaskList tasks={data.tasks} />

<section aria-live="polite" class="sr-only">
  완료율 {data.stats.ratio}%
</section>
// src/routes/+page.server.ts

export const load = async ({ fetch, depends }) => {
  depends('data:tasks');
  const [tasks, stats] = await Promise.all([
    fetch('/api/tasks').then((r) => r.json()),
    fetch('/api/tasks/stats').then((r) => r.json())
  ]);
  return { tasks, stats };
};

export const actions = {
  create: async ({ request, fetch }) => {
    const form = Object.fromEntries(await request.formData());
    if (!form.title) {
      return fail(400, { errors: { title: '제목은 필수입니다.' } });
    }
    const res = await fetch('/api/tasks', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(form)
    });
    if (!res.ok) {
      return fail(500, { errors: { title: '작업을 저장하지 못했습니다.' } });
    }
    return { success: true };
  }
};
// src/lib/router.ts

export const currentSection = derived(page, ($page) => $page.url.pathname.replace('/', '') || 'home');

export function navigate(section: string) {
  goto(section === 'home' ? '/' : `/${section}`);
}
pnpm run lint
pnpm run test
pnpm run check
pnpm run build

테스트와 타입 검사를 통과하면 Vercel이나 Netlify 어댑터로 배포합니다.

https://task-orbit-demo.local
DEV

UI Preview

CRUD·통계·실시간 로그 한 번에

작업을 추가하면 홈과 통계가 동시에 갱신되고, 활동 탭은 SSE로 새 이벤트를 스트리밍합니다.

SvelteKit actionsSSE

Tasks

홈 화면

TaskForm + TaskList + aria-live.

Stats

파생 스토어

완료율/총합 계산.

Activity

실시간

EventSource로 로그 업데이트.

👉 미니 요약: 홈은 CRUD와 접근성 경고를 담당하고, Stats는 파생 스토어만 갱신하면 되며, Activity는 SSE 연결만 추가하면 됩니다. 세 화면이 같은 데이터 뿌리를 공유한다는 그림을 잊지 마세요.

왜 중요한가

개념을 분리해 두면 확장 요구가 생겼을 때 어디를 수정할지 바로 떠오릅니다. 라우팅과 상태를 통합해 보면 성능 점검과 접근성 점검도 함께 챙기기 쉬워집니다. 캡스톤 결과물은 포트폴리오나 발표에서 설명할 근거가 됩니다.

실습

  • 따라 하기: 폴더 구조를 그대로 만들고 Task Orbit 앱에 CRUD, 통계, 활동 로그를 구현한다.
  • 확장하기: currentSection 스토어를 이용해 통합 탐색 메뉴와 키보드 단축키 내비게이션을 추가한다.
  • 디버깅: 접근성 경고가 나오면 aria-live, aria-invalid, 포커스 이동 흐름을 우선 점검한다.
  • 완료 기준: 작업 추가, 통계 계산, 섹션 이동, 접근성 알림까지 한 흐름으로 동작하고 pnpm run check, pnpm run test, pnpm run build가 통과한다.

마무리

이번 캡스톤은 시리즈 전체에서 배운 상태 관리, 라우팅, 접근성, 배포 흐름을 하나의 앱으로 묶는 과정입니다. 12편이 중간 결산이었다면, 20편은 진짜 완주 인증입니다. 여기까지 완성했다면 Svelte로 작은 제품을 처음부터 끝까지 조립하는 감각을 충분히 익혔습니다. 이제 백엔드 API나 디자인 시스템을 더해 자신만의 SaaS 프로토타입으로 확장할 차례입니다.

💬 댓글

이 글에 대한 의견을 남겨주세요