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 비교는 실제 보안을 위한 것입니다. 이 둘의 역할을 명확히 구분하면 코드를 읽기도 쉬워집니다.