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