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

23. 프론트엔드 서버리스 프로젝트 PWA myBlog 개발 - 블로그 조회수

그랜파 개발자 2025. 2. 28. 04:06

블로그의 조회수

글의 목록에서 글을 선택하면
상세보기 페이지로 이동해서 글의 전체 내용을 보여줍니다.
이렇게 상세보기 페이지가 열릴 때 조회수를 증가시켜 글의 조회수를 집계합니다.

 

 

 

우리의 작은 블로그는 웹툰을 볼 때 화면을 스크롤하면서 보듯
우리의 작은 블로그도 마찬가지로 스크롤하면서 글을 볼 수 있도록 하여
페이지의 이동 없이도 목록의 글들을 연속해서 볼 수 있습니다.
그래서 상세보기가 필요할까 생각할 수도 있지만,
상세보기에는 댓글과 답글이 있습니다.

또한 구독 요청, 구독 취소를 할 수 있고,
글쓴이라면 자신의 글을 수정할 수도 있습니다.
이처럼 상세보기에 기능이 많습니다.

사실 블로그에 글을 쓰면 조회수가 궁금하기도 합니다.
우리의 작은 블로그는 목록에서 이미 글의 내용을 모두 볼 수 있기 때문에
상세 보기로 들어오는 경우가 줄어들 수 있습니다.
즉 조회수가 글의 내용을 본 숫자 보다는 작을 것입니다.

조회수

글의 조회수를 구현하여 봅시다.
사용자가 상세보기에 접속을 할 때 조회수를 증가시킵니다.
한 사용자는 하루에 글을 몇 번을 읽어도 조회수는 한 번이고
비회원의 경우도 조회수를 증가시킵니다.

비회원의 경우 하루에 글을 몇번을 읽을 때 같은 사용자임을 어떻게 알수 있을까요?
비회원일 경우 로컬 스토리지 또는 쿠키를 사용하여 고유 ID 생성함으로써 같은 사용자임을 알 수 있습니다.
비회원의 고유 ID를 생성하기 위해서 UUID4를 사용합니다.

UUID4란?

UUID4는 Universally Unique Identifier (UUID) 의 한 유형으로,
무작위(randomness) 를 기반으로 고유한 식별자를 생성합니다.
UUID는 데이터베이스 키, 파일 이름, 트랜잭션 ID 등에서 고유성을 보장하기 위해 사용됩니다.

UUID를 사용하기 위해 uuid 라이브러리를 사용합니다.

npm install uuid4

조회수 구현

  1. 독자가 로그인 상태인지 확인하고 로그인이 아니면 비회원 고유 ID를 얻는다.
  2. 로그인 사용자이면 userId가 독자 ID가 되고,
  3. 로그인 상태가 아니라면 비회원 고유 ID가 독자 ID가 된다.
  4. 독자 ID로 postViews 컬렉션에서 조회 내역이 있는지 확인한다.
  5. 조회 내역이 없다면 조회수 1로 새로운 조회 문서를 저장한다.
  6. 조회 내역이 있다면 오늘 조회 내역이 있는지 확인한다.
  7. 오늘 조회 내역이 없으면 조회수를 증가시킨다.

postViews 컬렉션은 users 서브컬렉션을 가지고,
users 서브컬렉션의 각 문서는 사용자가 방문한 날짜 항목이 있습니다.
이것은 블로그 글의 날짜별 조회수를 집계하기 위한 것입니다.

postViews 컬렉션 뿐 아니라
posts에도 views 항목을 두어서 조회수를 증가시킵니다.
이것은 글을 화면에 나타낼 때 조회수를 출력을 쉽게 하려는 의도입니다.

조회수 증가

store의 post 모듈

  // -- 조회수 증가 ----
  // 1. 비회원도 조회수 증가, 비회원 고유 ID 얻기
  // 2. 같은 사용자일 경우 하루 1회 조회 증가
  //    오늘 조회 내역이 있는지 확인 필요
  // 3. 사용자의 조회 정보는 views 컬렉션에 저장
  async updateViewCount({ }, postId, userId = null) {

    let viewId;
    let viewedToday;

    // 비회원일 경우 로컬 스토리지 또는 쿠키를 사용하여 고유 ID 생성
    if (!userId) {
      viewId = localStorage.getItem('anonymousUserId');
      if (!viewId) {
        viewId = uuidv4();
        localStorage.setItem('anonymousUserId', viewId);
      }
    } else {
      viewId = userId; // 회원일 경우 사용자 ID
    }    

    try {    
      const today = new Date().toISOString().split('T')[0];
      const viewDocRef = doc(db, 'postViews', postId, 'users', viewId);

      // Firestore에서 해당 사용자의 조회 기록을 가져옵니다.
      const viewDoc = await getDoc(viewDocRef);      
      if (viewDoc.exists()) {
        // 오늘 조회 기록이 있는지 확인
        const lastViewed = viewDoc.data().lastViewed;
        viewedToday = lastViewed.some(view => view === today);
      }

      // 조회한 내역이 없으면 조회수 추가
      if (!viewedToday) {
        // Firestore의 lastViewed 배열에 조회 시간을 추가
        await setDoc(viewDocRef, {
          lastViewed: arrayUnion(today) // 배열에 서버 시간을 추가
        }, { merge: true });

        // post 조회수 증가
        const postRef = doc(db, "posts", postId);    
        try {
          // 조회수 1 증가 또는 필드가 없을 때 1로 설정
          await updateDoc(postRef, {
            views: increment(1)
          });
        } catch (error) {
          alert(error.message);
        }
      }
    } catch (error) {
      alert(error.message);
    }
  },

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-btn small text v-if="!isAuthor && user" @click="toggleSubscription">
          {{ isSubscribed ? "구독 취소" : "구독 요청" }} &nbsp;<v-icon>mdi-check-circle</v-icon>
        </v-btn>
        <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',
      'isSubscribed','subscriptionId']),

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

    // 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
      }); 
    },

    // -- 구독 -------------------------------
    // Toggle subscription: subscribe if not subscribed, 
    // unsubscribe if already subscribed
    async toggleSubscription() {

      const userId = this.user.uid;
      const authorId =  this.post.userId;
      const subscriptionId =  this.subscriptionId;

      if (this.isSubscribed) {
        // Unsubscribe the user        
        await this.unsubscribeFromUser({userId, authorId});
      } else {
        // Subscribe the user
        const createdAt = new Date();
        await this.subscribeToUser({userId, authorId, createdAt});
      }

      // 변경 내용 갱신을 위하여
      // Check if the user is subscribed when the component mounts
      this.checkSubscription({userId, authorId}); 
    },
    // -- 구독 끝 ----------------------------

  },

  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));
    const authorId =  this.post.userId;
    const userId = this.user.uid;
    if(userId) {
      // Check if the user is subscribed when the component mounts
      this.checkSubscription({userId, authorId}); 
    }

    // 댓글 버튼 활성화
    if(this.user) 
      this.isDisabled = false;
    else
      this.isDisabled = true;

    // postId에 대한 comments를 로드한다.
    await this.fetchComments(postId);
    // 조회수를 증가한다.
    // 같은 회원은 몇번을 방문해도 하루 1회, 비회원도 조회수 증가시킴
    await this.updateViewCount(postId, userId);
  },

};
</script>