시리즈 마지막 편입니다. 지금까지의 문법과 패턴을 Task Orbit이라는 작업 관리자 앱으로 결속합니다.
시작 전에 정리하기
- 무엇을 만든다:
/홈과/stats,/activity라우트를 가진 Task Orbit 미니 앱. 서버 액션, 상태 스토어, 접근성 알림, 배포 스크립트까지 통합합니다. - 왜 중요한가: 1~19편에서 배운 패턴을 한 흐름으로 연결해 두어야 실전 프로젝트에서 재사용할 때 기억이 남습니다.
- 주의할 점: 폴더 구조를 먼저 고정하고, 서버 액션/스토어/라우팅이 서로 어떤 데이터를 주고받는지 문서로 남겨야 리팩터링 때 꼬이지 않습니다.
이번 글에서 새로 나오는 용어
- 캡스톤: 배운 내용을 한 프로젝트에 응용해 보는 종합 실습으로, 설계부터 배포까지 전 과정이 담깁니다.
- 라우팅: URL에 따라 다른 페이지 컴포넌트를 보여 주는 흐름으로,
/stats,/activity처럼 경로를 나눕니다. - 접근성 보조 영역:
aria-live나 스크린 리더 전용 영역처럼 보이지 않지만 알림을 전달하는 UI입니다.
개념
캡스톤(capstone)은 지금까지 배운 내용을 하나의 프로젝트로 묶어 보는 통합 실습입니다. 상태(state)는 컴포넌트가 기억하는 데이터입니다. 파생 스토어(derived store)는 여러 상태를 조합해 새로운 값을 계산하는 도구입니다. 페이지 라우팅(routing)은 URL에 따라 페이지를 고르는 흐름입니다. 접근성(accessibility)은 보조 기술 사용자를 포함해 누구나 앱을 쓸 수 있게 만드는 기준입니다.
D2: Task Orbit 지형도
👉 미니 요약: 요청이 들어오면 레이아웃이 세션을 확인하고, 각 페이지 서버 파일이 데이터를 모읍니다. 모든 페이지는 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 어댑터로 배포합니다.
💬 댓글
이 글에 대한 의견을 남겨주세요