레슨 8 / 9·25분
실전: 북마크 앱 만들기
프로젝트 개요
Vue의 핵심 개념(Composition API, Pinia, Vue Router, Composables)을 모두 활용하여 북마크 관리 앱을 만듭니다.
- •북마크 추가, 수정, 삭제
- •카테고리별 분류와 검색
- •Pinia로 상태 관리
- •localStorage 영속화
- •Vue Router로 페이지 구성
Store 정의
typescript
// stores/bookmark.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
type Bookmark = {
id: string;
title: string;
url: string;
category: string;
createdAt: number;
};
export const useBookmarkStore = defineStore('bookmark', () => {
const bookmarks = ref<Bookmark[]>(
JSON.parse(localStorage.getItem('bookmarks') || '[]')
);
const searchQuery = ref('');
const selectedCategory = ref('all');
// Getters
const categories = computed(() => {
const cats = new Set(bookmarks.value.map(b => b.category));
return ['all', ...Array.from(cats)];
});
const filtered = computed(() => {
return bookmarks.value
.filter(b => {
const matchCategory = selectedCategory.value === 'all'
|| b.category === selectedCategory.value;
const matchQuery = b.title.toLowerCase()
.includes(searchQuery.value.toLowerCase())
|| b.url.includes(searchQuery.value);
return matchCategory && matchQuery;
})
.sort((a, b) => b.createdAt - a.createdAt);
});
// Actions
function addBookmark(title: string, url: string, category: string) {
bookmarks.value.push({
id: crypto.randomUUID(),
title,
url,
category,
createdAt: Date.now(),
});
save();
}
function removeBookmark(id: string) {
bookmarks.value = bookmarks.value.filter(b => b.id !== id);
save();
}
function save() {
localStorage.setItem('bookmarks', JSON.stringify(bookmarks.value));
}
return {
bookmarks, searchQuery, selectedCategory,
categories, filtered,
addBookmark, removeBookmark,
};
});메인 뷰 컴포넌트
vue
<!-- views/BookmarkList.vue -->
<script setup lang="ts">
import { useBookmarkStore } from '@/stores/bookmark';
import { storeToRefs } from 'pinia';
import BookmarkCard from '@/components/BookmarkCard.vue';
import AddBookmarkForm from '@/components/AddBookmarkForm.vue';
const store = useBookmarkStore();
const {
filtered, searchQuery, selectedCategory, categories
} = storeToRefs(store);
const { removeBookmark } = store;
</script>
<template>
<div class="bookmark-app">
<h1>북마크 관리</h1>
<AddBookmarkForm />
<div class="filters">
<input
v-model="searchQuery"
placeholder="검색..."
class="search-input"
/>
<select v-model="selectedCategory">
<option v-for="cat in categories" :key="cat" :value="cat">
{{ cat === 'all' ? '전체' : cat }}
</option>
</select>
</div>
<p>{{ filtered.length }}개의 북마크</p>
<div class="bookmark-grid">
<BookmarkCard
v-for="bookmark in filtered"
:key="bookmark.id"
:bookmark="bookmark"
@delete="removeBookmark(bookmark.id)"
/>
</div>
</div>
</template>하위 컴포넌트
vue
<!-- components/BookmarkCard.vue -->
<script setup lang="ts">
defineProps<{
bookmark: {
id: string;
title: string;
url: string;
category: string;
createdAt: number;
};
}>();
const emit = defineEmits<{
delete: [];
}>();
function formatDate(ts: number) {
return new Date(ts).toLocaleDateString('ko-KR');
}
</script>
<template>
<div class="bookmark-card">
<div class="card-header">
<span class="category-badge">{{ bookmark.category }}</span>
<button @click="emit('delete')" class="delete-btn">삭제</button>
</div>
<h3>
<a :href="bookmark.url" target="_blank">{{ bookmark.title }}</a>
</h3>
<p class="url">{{ bookmark.url }}</p>
<small>{{ formatDate(bookmark.createdAt) }}</small>
</div>
</template>vue
<!-- components/AddBookmarkForm.vue -->
<script setup lang="ts">
import { ref } from 'vue';
import { useBookmarkStore } from '@/stores/bookmark';
const store = useBookmarkStore();
const title = ref('');
const url = ref('');
const category = ref('일반');
const showForm = ref(false);
function handleSubmit() {
if (!title.value.trim() || !url.value.trim()) return;
store.addBookmark(title.value, url.value, category.value);
title.value = '';
url.value = '';
showForm.value = false;
}
</script>
<template>
<button @click="showForm = !showForm">
{{ showForm ? '취소' : '+ 북마크 추가' }}
</button>
<form v-if="showForm" @submit.prevent="handleSubmit">
<input v-model="title" placeholder="제목" required />
<input v-model="url" placeholder="URL" type="url" required />
<input v-model="category" placeholder="카테고리" />
<button type="submit">저장</button>
</form>
</template>💡
이 프로젝트에서는 Pinia Store가 비즈니스 로직(필터링, 정렬, 저장)을 담당하고, 컴포넌트는 순수하게 UI만 관리합니다. 이런 분리는 테스트와 유지보수를 크게 개선합니다.