Learning
레슨 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만 관리합니다. 이런 분리는 테스트와 유지보수를 크게 개선합니다.