토이 프로젝트 - Vue, Firebase로 서버리스 PWA 개발

21. 서버리스 PWA myBlog 개발 - 게시글 댓글에 대한 답글

그랜파 개발자 2025. 2. 26. 04:39

게시글 댓글에 대한 답글

블로그 댓글과 답글 기능은 단순한 소통 수단을 넘어, 블로그의 활성화와 신뢰도를 높이는 중요한 요소입니다.
댓글을 통해 독자들은 자신의 의견을 공유하고, 블로거와 직접 소통할 수 있습니다.
답글 기능이 있으면 토론이 자연스럽게 이어지고, 블로거와 독자 간의 관계가 강화됩니다.

답글 쓰기

블로그의 게시글에 대해 댓글을 쓸 수 있습니다.
댓글은 게시글의 서브컬렉션 comments 컬렉션에 저장 됩니다.

댓글에 대해 답글을 쓸 수 있습니다.
댓글의 답글은 댓글의 서브컬렉션 replies 컬렉션에 저장이 됩니다.

글 상세보기 페이지가 열릴 때 글을 로드하면서
글의 댓글도 state의 comments 배열에 저장하였습니다.
각 댓글에 대한 답글은 comments 배열의 각 요소에 replies 배열에 로드합니다.
이렇게 해서 댓글의 목록에 답글의 목록도 함께 나타낼 수 있게 됩니다.

댓글의 목록에서 지금 로그인 한 회원이 댓글의 글쓴이라면 삭제 아이콘 버튼이 나타납니다.
답글의 목록에서 지금 로그인 한 회원이 답글의 글쓴이라면 삭제 아이콘 버튼이 나타납니다.

답글 쓰기 폼은 숨겨져 있습니다.
각 댓글의 ‘답글’ 버튼을 누르면 답글 쓰기 폼이 나타납니다.
취소를 누르면 답글 쓰기 폼을 닫습니다.

답글을 누르면 답글을 저장합니다.
답글은 사용자가 내용을 입력하면 앱은 필요한 것들을 더 추가하여 저장합니다.

답글의 문서 항목

  • postId: 댓글을 쓰고 있는 글의 id
  • commentId: 답글을 쓰고 있는 댓글의 Id
  • content: 사용자가 입력한 댓글의 내용
  • userName: 답글 쓰는 사용자의 이름,
  • userId: 답글을 쓰는 사용자의 userId
  • createdAt: 답글 작성일

댓글의 답글은 댓글의 서브컬렉션 replies 컬렉션에 저장이 됩니다.

PostView 컴포넌트의 template - 답글 리스트

PostView 컴포넌트의 script - 답글 로드

상세 보기 페이지가 열리면 게시글을 가져오면서 게시글의 댓글도 함께 로드합니다.

 

댓글을 로드할 때 댓글도 함께 로드합니다.

store의 post 모듈

댓글 가져오기

  async fetchComments({ commit, dispatch }, postId) 
  {
    try 
    {
      const q = query(
        collection(db, "posts", postId, "comments"), 
        orderBy("createdAt", "asc")
      );

      const comments = [];
      const querySnapshot = await getDocs(q);      
      querySnapshot.forEach((doc) => {
        comments.push({ id: doc.id, ...doc.data(), replies: [] });
        // 댓글에 대한 답글 로드
        dispatch('fetchReplies', { postId, commentId: doc.id });  
      });
      // 댓글을 상태에 저장
      commit('setComments', comments); 
    } 
    catch (error) 
    {
      alert("댓글 가져오기 실패 : " + error.message);
    }
  },

댓글 추가 하기

  async addComment({ dispatch }, { postId, content, userName, userId, createdAt }) 
  {
    try 
    {
      // db에 댓글 저장
      const commentRef = await addDoc(
        collection(db, "posts", postId, "comments"), 
        {content, userName, userId, createdAt}
      );
      // 댓글 저장 후 다시 로드
      dispatch('fetchComments', postId);
      // post의 댓글수 증가
      const postRef = doc(db, "posts", postId);     
      try 
      {
        // 댓글수 1 증가 또는 필드가 없을 때 1로 설정
        await updateDoc(postRef, {
          commentCount: increment(1)
        });
      } 
      catch (error) 
      {
        alert('addComment : ' + error.message);
      }
    } 
    catch (error) 
    {      
      alert("댓글 추가 실패:" + error.message);
    }
  },

댓글 삭제 하기

  async deleteComment({ commit, dispatch }, { postId, commentId }) 
  {
    commit('setLoading', true);

    const commentRef = doc(db, "posts", postId, "comments", commentId);
    try 
    {      
      await deleteDoc(commentRef);
      // 댓글 다시 로그
      dispatch('fetchComments', postId); 
      // post의 댓글수 감소
      const postRef = doc(db, "posts", postId);     
      try 
      {
        // 댓글수 1 감소
        await updateDoc(postRef, {
          commentCount: increment(-1)
        });
      } 
      catch (error) 
      {
        alert('댓글 삭제, 댓글수 감소 : ' + error.message);
      }

      commit('setLoading', false);
      alert("댓글을 삭제하였습니다.");
    } 
    catch (error) 
    {
      alert("댓글 삭제 실패 : " + error.message);
    }
  },

src/store/modules/post.js

// src/store/modules/post.js
import { v4 as uuidv4 } from 'uuid';
import { db, collection, getDocs, getDoc, setDoc, doc, 
  addDoc, updateDoc, deleteDoc, query, where, orderBy,
  increment, arrayUnion } from "@/firebase";

const state = {
  loading: false,
  categories: [],   //  카테고리
  post: null,       // 글
  posts: [],        // 륵목록
  comments: [],     // 댓글 목록 저장
};

const mutations = {
  setLoading(state, loading) {
    state.loading = loading;
  },
  // 카테고리 관리
  setCatogories(state, categories) {  // 카테고리 
    state.categories = categories;
  },
  addCatogory(state, category) {    // 카테고리 추가
    state.categories.push(category);
  },
  removeCatogory(state, id) {    // 카테고리 제거
    let categories = [];
    categories = state.categories.filter((category) => category.id !== id);
    state.categories = categories;
  },
  // -- 글관리
  setPost(state, post) {
    state.post = post;
  },
  setPosts(state, posts) {
    state.posts = posts;
  },
  addPost(state, post) {
    state.posts.push(post);
  },
  setComments(state, comments) {  // 현재 댓글
    state.comments = comments;
  },
  addReply(state, { commentId, reply }) {   // 댓글에 대한 답글 로드
    const comment = state.comments.find(c => c.id === commentId);
    if (comment) {
      if (!comment.replies) {
        comment.replies = [];
      }
      comment.replies.push(reply);
    }
  },
};

const actions = {
  // -- 카테고리 관리 ----
  async fetchCategories({ commit }, { userId }) { 
    if (!userId) return;

    try {
      // 사용자 ID로 필터링된 카테고리 가져오기
      const q = query(
        collection(db, "categories"),
        where("userId", "==", userId)
      );
      const querySnapshot = await getDocs(q);

      let categories = [];
      categories = querySnapshot.docs.map((doc) => ({
        id: doc.id, ...doc.data(),
      })); 
      // 로드한 카테고리를 상태에 저장한다.
      commit("setCatogories", categories); 
    } catch (error) {
      alert("Error fetching categories : " + error.message);
    }
  },

  async addCategory({ commit }, { newCategory, userId } ) {
    if (!newCategory || !userId) return;

    try {
      commit('setLoading', true);
      const docRef = await addDoc(collection(db, "categories"), {
        name: newCategory, userId: userId, 
      });
      commit("addCatogory", { id: docRef.id, name: newCategory });
      commit('setLoading', false);
    } catch (error) { 
      alert("Error adding category : " + error.message);
    }
  },
  async removeCategory({ commit }, id) {
    try {
      commit('setLoading', true);
      await deleteDoc(doc(db, "categories", id)); // db에 저장된 카테고리 에서 제거
      commit("removeCatogory", id);     // state에 있는 카테고리에서 제거
      commit('setLoading', false);
    } catch (error) {
      console.error("Error deleting category:", error);
    }
  },

  // -- 글쓰기 -------
  async addPost({ dispatch }, post) {
    const docRef = await addDoc(collection(db, "posts"), post);
    dispatch('fetchPosts');
  },

  async fetchPost({ commit }, postId) {
    commit('setLoading', true);
    try {
      const docRef = doc(db, "posts", postId); // Reference to the specific article document
      const docSnap = await getDoc(docRef); // Fetch the document snapshot
      if (docSnap.exists()) {
        const post = docSnap.data(); // Set article data
        commit('setPost', post);
      } else {
        alert("post가 없습니다.");
      }
    } catch (error) {
      alert('fetchPost: ' + error.message);
    } finally {
      commit('setLoading', false);
    }
  },

  // 전체 글 가져오기 - 최신순
  async fetchPosts({ commit }) {
    const postsRef = query(collection(db, "posts"), orderBy("createdAt", "desc"));
    const querySnapshot = await getDocs(postsRef);
    const posts = querySnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
    commit("setPosts", posts);
  },
  // 전체 글 가져오기 - 오래된 순
  async fetchPostsOrderByAsc({ commit }) {
    const postsRef = query(collection(db, "posts"), orderBy('createdAt', 'asc'));
    const querySnapshot = await getDocs(postsRef);
    const posts = querySnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
    commit("setPosts", posts);
  },

  // 글수정
  async updatePost({ commit }, { id, post }) {
    const docRef = doc(db, "posts", id);
    await updateDoc(docRef, post);
  },

  // -- 댓글 --
  // 댓글 가져오기
  async fetchComments({ commit, dispatch }, postId) 
  {
    try 
    {
      const q = query(
        collection(db, "posts", postId, "comments"), 
        orderBy("createdAt", "asc")
      );

      const comments = [];
      const querySnapshot = await getDocs(q);      
      querySnapshot.forEach((doc) => {
        comments.push({ id: doc.id, ...doc.data(), replies: [] });
        // 댓글에 대한 답글 로드
        dispatch('fetchReplies', { postId, commentId: doc.id });  
      });
      // 댓글을 상태에 저장
      commit('setComments', comments); 
    } 
    catch (error) 
    {
      alert("댓글 가져오기 실패 : " + error.message);
    }
  },

  // 댓글 추가 하기
  async addComment({ dispatch }, { postId, content, userName, userId, createdAt }) 
  {
    try 
    {
      // db에 댓글 저장
      const commentRef = await addDoc(
        collection(db, "posts", postId, "comments"), 
        {content, userName, userId, createdAt}
      );
      // 댓글 저장 후 다시 로드
      dispatch('fetchComments', postId);
      // post의 댓글수 증가
      const postRef = doc(db, "posts", postId);     
      try 
      {
        // 댓글수 1 증가 또는 필드가 없을 때 1로 설정
        await updateDoc(postRef, {
          commentCount: increment(1)
        });
      } 
      catch (error) 
      {
        alert('addComment : ' + error.message);
      }
    } 
    catch (error) 
    {      
      alert("댓글 추가 실패:" + error.message);
    }
  },

  // 댓글 삭제 하기
  async deleteComment({ commit, dispatch }, { postId, commentId }) 
  {
    commit('setLoading', true);

    const commentRef = doc(db, "posts", postId, "comments", commentId);
    try 
    {      
      await deleteDoc(commentRef);
      // 댓글 다시 로그
      dispatch('fetchComments', postId); 
      // post의 댓글수 감소
      const postRef = doc(db, "posts", postId);     
      try 
      {
        // 댓글수 1 감소
        await updateDoc(postRef, {
          commentCount: increment(-1)
        });
      } 
      catch (error) 
      {
        alert('댓글 삭제, 댓글수 감소 : ' + error.message);
      }

      commit('setLoading', false);
      alert("댓글을 삭제하였습니다.");
    } 
    catch (error) 
    {
      alert("댓글 삭제 실패 : " + error.message);
    }
  },
  // -- 댓글 끝 ---

    // -- 답글 -----
  // 특정 댓글의 답글 불러오기
  async fetchReplies({ commit }, { postId, commentId }) {
    try {
      const q = query(
        collection(db, "posts", postId, "comments", commentId, "replies"), 
        orderBy("createdAt", "asc"));
      const querySnapshot = await getDocs(q);

      const replies = [];
      querySnapshot.forEach((doc) => {
        replies.push({ id: doc.id, ...doc.data() });
      });
      replies.forEach(reply => {
        commit('addReply', { commentId, reply });
      });
    } catch (error) {
      alert("답글 불러오기 실패 : " + error.message);
    }
  },
  // 댓글에 답글 추가
  async addReply({ dispatch }, { postId, commentId, content, userName, userId, createdAt }) {
    try {
      // db에 답글 저장
      const replyRef = await addDoc(
        collection(db, "posts", postId, "comments", commentId, "replies"), {
        content, userName, userId, createdAt    
      });
      dispatch('fetchReplies', { postId, commentId });  // 답글 로드
      alert("답글을 저장하였습니다.");
    } catch (error) {
      alert("답글 추가 실패 : " + error.message);
    }
  },

  // 답글 삭제
  async deleteReply({ commit, dispatch }, { postId, commentId, replyId }) {
    const replyRef = doc(db, "posts", postId, "comments", commentId, "replies", replyId);
    try {
      await deleteDoc(replyRef); 
      dispatch('fetchComments', postId); // 댓글 다시 로드     
      alert("답글이 삭제되었습니다.");
    } catch (error) {
      alert("답글 삭제 실패 : " + error.message);
    }
  }, 

};

const getters = { 

};

export default {
  namespaced: true,
  state,
  mutations,
  actions,
  getters
};

PostView.vue

<!-- src/views/PostView.vue -->
<template>
  <v-container>
    <v-card v-if="post">

      <!--글쓴이의 블로그 이름-->
      <v-card-title v-if=author style="cursor: pointer; font-size:1em" @click="goToBlogs(author.userId)">
        {{ getBlogName }}<v-icon>mdi-chevron-right</v-icon>
      </v-card-title>
      <!--카테고리-->
      <v-card-text class="mb-n5" style="font-size:1em">
        {{ post.category }}
      </v-card-text>
      <!-- 제목 -->
       <v-card-title style="cursor: pointer; font-size:1.1em">
        {{ post.title }}
       </v-card-title>
       <!-- 내용 -->
      <!-- eslint-disable -->
      <v-card-text style="font-size:1em" v-html="content"></v-card-text>
      <!-- eslint-enable -->
      <!-- 글쓴이 이름, 글쓴 날, 조회수 -->
      <v-card-subtitle style="text-align:right">
        {{ post.userName }} . {{ post.createdAt.toDate().toISOString().split('T')[0] }}
        <span v-if="post.views > 0" > . ({{ post.views }})</span>
      </v-card-subtitle>      

      <v-card-actions >
        <v-spacer></v-spacer>
        <v-btn small text v-if="isAuthor" @click="goToEditPost()">
          수정 &nbsp; <v-icon>mdi-pencil</v-icon>
        </v-btn>
      </v-card-actions>

      <!-- 댓글 작성 폼 -->
      <v-form @submit.prevent="doSubmitComment" class="ml-2 mr-2">
        <v-textarea outlined style="border-color: #fffeee;" v-model="newComment" label="댓글 쓰기 ..." rows="3" required></v-textarea> 
        <v-card-actions >
          <v-spacer></v-spacer>
          <v-btn :disabled="isDisabled" class="mt-n6" type="submit" color="primary">댓글</v-btn>  
        </v-card-actions >
      </v-form>

      <v-list>
        <!-- 댓글 리스트 -->
        <v-list-item v-for="comment in comments" :key="comment.id"> 
          <v-list-item-content>
            <!-- eslint-disable -->
            <v-list-item-subtitle style="white-space: normal;" v-html="sanitizeContent(comment.content)"></v-list-item-subtitle>
            <!-- eslint-enable -->

            <v-list-item-subtitle class="mt-1"> 
              {{ comment.userName }} . {{ formatDate(comment.createdAt) }}
              <!--댓글 삭제 버튼 -->             
              <v-btn v-if="isOwner(comment.userId)" icon @click="doDeleteComment(comment.id)">
                <v-icon>mdi-delete</v-icon>
              </v-btn>
              <!-- reply 버튼 -->  
              <v-btn v-if="user" small text @click="showReplyForm(comment.id)">답글</v-btn>             
            </v-list-item-subtitle>

            <!-- 답글 리스트 -->
            <v-list class="ml-5" v-if="comment.replies && comment.replies.length">
              <!-- 답글 -->
              <v-list-item v-for="reply in comment.replies" :key="reply.id">
                <v-list-item-content>
                  <!-- eslint-disable -->
                  <v-list-item-subtitle style="white-space: normal;" v-html="sanitizeContent(reply.content)"></v-list-item-subtitle>
                  <!-- eslint-enable -->
                  <v-list-item-subtitle class="ml-2">
                    {{ reply.userName }} : {{ formatDate(reply.createdAt) }}
                    <v-btn v-if="isOwner(reply.userId)" icon @click="doDeleteReply(comment.id, reply.id)">
                      <v-icon>mdi-delete</v-icon>
                    </v-btn>
                  </v-list-item-subtitle>
                </v-list-item-content>
              </v-list-item>
            </v-list>

            <!-- 답글 쓰기-->
            <v-form class="ml-5 mt-1 mr-2" v-if="replyingTo === comment.id" @submit.prevent="doSubmitReply(comment.id)">
              <v-textarea  outlined style="border-color: #fffeee; width: 90%" v-model="newReply" label="답글 쓰기 ..." rows="3" required></v-textarea>
              <v-btn class="ma-0 mt-n12" small text @click="showReplyForm(null)">취소</v-btn>&nbsp;
              <v-btn class="ma-0 mt-n12" small text type="submit" color="primary">답글</v-btn>
            </v-form>
            <!-- reply end --->

          </v-list-item-content>
        </v-list-item>
      </v-list> 

    </v-card>

    <div class="text-center">
      <v-progress-circular v-if="loading" indeterminate></v-progress-circular>
    </div>
  </v-container>
</template>

<script>
import { mapActions, mapState } from "vuex";
import sanitizeHtml from 'sanitize-html';

export default {
  data() {
    return {
      content: '',
      newComment: '',  // 댓글
      replyingTo: null,
      newReply: '',
      author: null, // 저자의 이름을 가져오기 위해
      isDisabled: false, // 버튼 비활성화 여부를 결정하는 상태
    };
  },

  computed: {
    ...mapState('auth',['user', 'profile', 'profiles']),
    ...mapState('post', ['loading', 'post', 'comments']),

    getBlogName() {
      return this.author.blogName;
    },
    // 저자인가? - 작성자와 로그인한 사용자 비교
    isAuthor() {
      return this.user && this.user.uid === this.post.userId;
    },
  },
  methods: {  
    ...mapActions('post', ['fetchPost','fetchComments',
      'addComment', 'deleteComment', 'addReply', 'deleteReply']),

    // content를 안전한 html로 바꿔준다.
    sanitizeContent(content) {
      return sanitizeHtml(content.replace(/\n/g, '<br>'), {
        allowedTags: ['b', 'i', 'em', 'strong', 'p', 'br'],
        allowedAttributes: {}
      });
    },

    formatDate(date) {
      if (date && date.toDate) {
        return date.toDate().toISOString().replace('T', ' ').substring(0, 16); 
      }
      return '';
    },

    // 로그인한 회원이 글쓴이 인가?
    isOwner(userId) {
      if(!this.user)
        return false;
      return userId === this.user.uid;
    },

    // 글 수정하러 가기
    goToEditPost() {
      this.post.updatedAt = new Date(); // 수정한 날짜를 넣자.
      this.$router.push({ name: 'Edit', params: { postId: this.$route.params.id, post: this.post } });
    },

    // 댓글 쓰기
    async doSubmitComment() {
      //postId, content, autherName, author, createdAt
      if (this.newComment.trim()) {
        await this.addComment({
          postId: this.$route.params.id,
          content: this.newComment,
          userName: this.author.name,
          userId: this.user.uid,
          createdAt: new Date()
        });
        this.newComment = ''; // 댓글 입력 필드 초기화
      }
    },

    // 댓글 삭제
    async doDeleteComment(commentId) {
      await this.deleteComment({
        postId: this.$route.params.id, 
        commentId: commentId
      });      
    },

    // 답글 작성 폼 보이기
    showReplyForm(commentId) {
      this.replyingTo = commentId;
    },

    // 답글 작성
    async doSubmitReply(commentId) {
      if (this.newReply && this.newReply.trim()) {
        //console.log(this.author.name);
        await this.addReply({
          postId: this.$route.params.id,
          commentId: commentId,
          content: this.newReply,
          userName: this.author.name,
          userId: this.user.uid,
          createdAt: new Date()
        });
        this.newReply = ''; // 입력 필드 초기화
        this.replyingTo = null;
      }
    },
    // 답글 삭제
    async doDeleteReply(commentId, replyId) {
      await this.deleteReply({
        postId: this.$route.params.id,
        commentId: commentId,
        replyId: replyId
      }); 
    },
  },

  async created() {
    // 글 가져오기
    const postId = this.$route.params.id; // Get the article ID from the route parameters
    await this.fetchPost(postId);       // 글 id로 글 가져오기
    this.content = this.sanitizeContent(this.post.content);  // 내용에 포한된 html을 안전한 html로 변경한다.

    // 글 저자 설정정보 가져오기 - 상태에 로드되어 있는 전체 계정 설정 정보를 이용한다.
    this.author = this.profiles.find(profile => profile.uids.includes(this.post.userId));

    // postId에 대한 comments를 로드한다.
    await this.fetchComments(postId);
  },

};
</script>