iBetter Books
수정

Ch 04. 댓글과 권한 제어

게시글에 댓글을 달고 삭제하는 기능을 만듭니다. 권한 제어를 서버와 프론트엔드 두 곳 모두에서 처리하는 방법을 집중적으로 살펴봅니다.

댓글 목록 API

게시글 상세 페이지에서 댓글 목록을 불러올 때 작성자 이름도 함께 가져옵니다.

// server/api/posts/[id]/comments.get.tsexport default defineEventHandler(async (event) => {  const postId = Number(getRouterParam(event, 'id'))  return await usePrisma().comment.findMany({    where: { postId },    include: { author: { select: { id: true, name: true } } },    orderBy: { createdAt: 'asc' }  })})

댓글 작성 API

댓글 작성은 로그인한 사용자만 할 수 있습니다. 서버 미들웨어가 설정한 event.context.userId를 확인합니다.

// server/api/posts/[id]/comments.post.tsexport default defineEventHandler(async (event) => {  const userId = event.context.userId  if (!userId) throw createError({ statusCode: 401 })  const postId = Number(getRouterParam(event, 'id'))  const { content } = await readBody(event)  return await usePrisma().comment.create({    data: { content, postId, authorId: userId }  })})

댓글 삭제 API

댓글을 삭제하기 전에 두 가지를 확인합니다. 댓글이 존재하는지, 그리고 현재 사용자가 작성자인지입니다. 둘 중 하나라도 통과하지 못하면 오류를 반환합니다.

// server/api/comments/[id].delete.tsexport default defineEventHandler(async (event) => {  const userId = event.context.userId  if (!userId) throw createError({ statusCode: 401 })  const commentId = Number(getRouterParam(event, 'id'))  const comment = await usePrisma().comment.findUnique({ where: { id: commentId } })  if (!comment) throw createError({ statusCode: 404 })  if (comment.authorId !== userId) throw createError({ statusCode: 403, statusMessage: '삭제 권한이 없습니다.' })  await usePrisma().comment.delete({ where: { id: commentId } })  return { success: true }})

프론트엔드 댓글 컴포넌트

댓글 목록과 작성 폼을 하나의 컴포넌트로 만듭니다. 댓글을 작성하거나 삭제하면 목록을 다시 불러와 화면을 최신 상태로 유지합니다.

<!-- components/CommentSection.vue -->
<script setup lang="ts">
const props = defineProps<{ postId: number }>()
const authStore = useAuthStore()
const newContent = ref('')

const { data: comments, refresh } = await useFetch(`/api/posts/${props.postId}/comments`)

async function addComment() {
  if (!newContent.value.trim()) return
  await $fetch(`/api/posts/${props.postId}/comments`, {
    method: 'POST',
    body: { content: newContent.value }
  })
  newContent.value = ''
  await refresh()
}

async function deleteComment(commentId: number) {
  await $fetch(`/api/comments/${commentId}`, { method: 'DELETE' })
  await refresh()
}
</script>

<template>
  <section>
    <ul>
      <li v-for="comment in comments" :key="comment.id">
        <strong>{{ comment.author.name }}</strong>
        <p>{{ comment.content }}</p>
        <button
          v-if="authStore.user?.id === comment.author.id"
          @click="deleteComment(comment.id)"
        >삭제</button>
      </li>
    </ul>

    <form v-if="authStore.isLoggedIn" @submit.prevent="addComment">
      <textarea v-model="newContent" placeholder="댓글을 입력하세요." required></textarea>
      <button type="submit">댓글 달기</button>
    </form>
    <p v-else>댓글을 작성하려면 <NuxtLink to="/login">로그인</NuxtLink>이 필요합니다.</p>
  </section>
</template>

권한 제어 원칙 정리

서버에서만 권한 검증을 하면 UI가 어색해지고, 프론트엔드에서만 하면 API를 직접 호출하는 공격에 무방비 상태가 됩니다. 두 곳 모두에서 검증하는 것이 올바른 방식입니다.

프론트엔드의 v-if 조건은 사용자 경험을 위한 것이고, 서버의 authorId 비교는 실제 보안을 위한 것입니다. 이 둘의 역할을 명확히 구분하면 코드를 읽기도 쉬워집니다.