iBetter Books
수정

Ch 03. 게시글 CRUD와 Pinia 상태 동기화

블로그의 핵심은 게시글입니다. 목록을 불러오고, 상세 페이지를 보여주고, 글을 작성하고, 수정하고, 삭제하는 기능을 차례로 만들어봅니다.

게시글 목록 API

모든 게시글을 최신순으로 가져옵니다. 작성자 이름도 함께 포함시킵니다.

// server/api/posts/index.get.tsexport default defineEventHandler(async () => {  return await usePrisma().post.findMany({    include: { author: { select: { name: true } } },    orderBy: { createdAt: 'desc' }  })})

게시글 목록 페이지

useFetch로 API를 호출합니다. datanull일 수 있으므로 v-for 앞에 안전하게 처리합니다.

<!-- pages/index.vue -->
<script setup lang="ts">
const { data: posts } = await useFetch('/api/posts')
</script>

<template>
  <div v-for="post in posts" :key="post.id">
    <NuxtLink :to="`/posts/${post.id}`">{{ post.title }}</NuxtLink>
    <span>{{ post.author.name }}</span>
    <time>{{ new Date(post.createdAt).toLocaleDateString('ko-KR') }}</time>
  </div>
</template>

게시글 작성 API와 페이지

글 작성은 로그인된 사용자만 할 수 있습니다. 서버 미들웨어가 event.context.userId를 설정해두었다면, API에서 그 값을 바로 사용합니다.

// server/api/posts/index.post.tsexport default defineEventHandler(async (event) => {  const userId = event.context.userId  if (!userId) throw createError({ statusCode: 401 })  const { title, content, imageUrl } = await readBody(event)  return await usePrisma().post.create({    data: { title, content, imageUrl, authorId: userId }  })})

작성 페이지에서는 definePageMeta로 인증 미들웨어를 선언합니다.

<!-- pages/posts/write.vue -->
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
const title = ref('')
const content = ref('')

async function submit() {
  await $fetch('/api/posts', {
    method: 'POST',
    body: { title: title.value, content: content.value }
  })
  await navigateTo('/')
}
</script>

<template>
  <form @submit.prevent="submit">
    <input v-model="title" placeholder="제목" required />
    <textarea v-model="content" placeholder="내용을 입력하세요." required></textarea>
    <button type="submit">게시하기</button>
  </form>
</template>

게시글 수정과 삭제

수정과 삭제 API는 먼저 게시글을 조회한 뒤 authorId가 현재 사용자와 일치하는지 확인합니다. 서버에서 이 검증을 반드시 해야 합니다. 프론트엔드에서만 버튼을 숨기는 것은 보안 조치가 아닙니다.

// server/api/posts/[id].delete.tsexport default defineEventHandler(async (event) => {  const userId = event.context.userId  if (!userId) throw createError({ statusCode: 401 })  const postId = Number(getRouterParam(event, 'id'))  const post = await usePrisma().post.findUnique({ where: { id: postId } })  if (!post) throw createError({ statusCode: 404 })  if (post.authorId !== userId) throw createError({ statusCode: 403 })  await usePrisma().post.delete({ where: { id: postId } })  return { success: true }})

수정 API도 같은 패턴으로 권한을 확인한 뒤 update를 호출합니다.

게시글 상세 페이지의 조건부 렌더링

프론트엔드에서는 현재 로그인한 사용자가 작성자인 경우에만 수정·삭제 버튼을 표시합니다.

<!-- pages/posts/[id].vue -->
<script setup lang="ts">
const route = useRoute()
const authStore = useAuthStore()
const { data: post } = await useFetch(`/api/posts/${route.params.id}`)

const isAuthor = computed(() => authStore.user?.id === post.value?.authorId)

async function deletePost() {
  if (!confirm('정말 삭제하시겠습니까?')) return
  await $fetch(`/api/posts/${route.params.id}`, { method: 'DELETE' })
  await navigateTo('/')
}
</script>

<template>
  <article v-if="post">
    <h2>{{ post.title }}</h2>
    <p>{{ post.content }}</p>
    <div v-if="isAuthor">
      <NuxtLink :to="`/posts/${post.id}/edit`">수정</NuxtLink>
      <button @click="deletePost">삭제</button>
    </div>
  </article>
</template>

삭제 후 navigateTo('/')로 목록 페이지로 이동합니다. 삭제된 게시글 페이지에 머무르면 API 오류가 발생하기 때문입니다.