블로그 구독
블로그의 구독은 특정 블로그의 새로운 글을 사용자가 지속적으로 받을 수 있도록 설정하는 기능을 의미합니다.
구독은 블로그를 즐겨 찾는 독자와 블로그글쓴이 간의 연결을 강화하는 주요 도구입니다.
구독 요청을 하면 글쓴이가 새로운 글을 등록할 때 푸시 알림을 받을 수 있고,
독자는 자신이 구독하는 글쓴이들의 글을 한 곳에 모아볼 수 있습니다.


구독 요구 사항
글 상세보기에서 구독을 신청할 수 있습니다.
구독을 신청할 수 있으니, 구독 취소도 할 수 있습니다.
구독 신청을 하면 내가 구독하는 글들을 구독 페이지에서 모아 볼 수 있고
구독 중인 글쓴이가 새로운 글을 저장하면 푸시 알림도 받을 수 있습니다.
본인의 글은 구독 요청이 필요하지 않으므로
구독 관련 버튼이 보이지 않습니다..
구독 기능 정의
구독 신청을 할 수 있습니다.
구독 취소를 할 수 있습니다.
글쓴이는 자신의 글에 대한 구독 버튼이 나타나지 않습니다.
구독을 하면 구독 중인 모든 글을 모아 볼 수 있습니다.
구독을 하면 구독 중인 글쓴이가 새로운 글을 저장하면 푸시 알림을 받을 수 있습니다.
구독 기능 분석
구독 신청과 구독 취소는 toggle 버튼입니다.
구독 요청은 subscriptions 컬렉션에 저장되며,
사용자의 userId에 대해 글쓴이를 authorId를 저장하여 연결합니다.
구독 여부는 subscriptions에서 userId로 authorId가 있는지 확인해서
있으면 구독중, 없으면 아직 구독하지 않은 상태입니다.
이를 이용하여 구독 버튼에 ‘구독 요청’ 또는 ‘구독 취소’를 나타냅니다.
구독 취소는 subscriptions 컬렉션에 저장된 authorId를 삭제하는 것입니다.
글쓴이는 자신의 글을 구독 하지 않으므로 구독 버튼이 나타나지 않습니다.
구독 중인 글들을 모아 보는 컴포넌트는 후에 개발합니다.
구독 푸시 알림을 받는 것도 fcm을 구현할 때 기능을 구현하도록 합니다.
구독 정보 저장
구독 정보는 subscriptions 컬렉션에 저장됩니다.
subscriptions 컬렉션의 각 문서 항목은 다음과 같습니다.
- userId : 로그인 한 회원의 아이디 입니다.
- authorId: 현재 상세보기 중인 글의 글쓴이 Id입니다,
- createdAt: 구독을 요청한 날입니다. 구독 요청을 저장할 때의 시간을 가져와서 저장합니다.
PostView 컴포넌트의 template - 구독 신청 버튼

PostView 컴포넌트의 script- 구독 신청

구독 여부 확인
async checkSubscription({ commit }, { userId, authorId }) {
const q = query(
collection(db, "subscriptions"),
where("userId", "==", userId),
where("authorId", "==", authorId)
);
// 구독 정보가 있으면 구독 중
const querySnapshot = await getDocs(q);
if (!querySnapshot.empty) {
commit("setSubscribed", true);
// 구독 취소를 위하여 구독문서 Id를 구해둔다.
commit("setSubscriptionId", querySnapshot.docs[0].id);
} else {
commit("setSubscribed", false);
commit("setSubscriptionId", '');
}
},
구독
async subscribeToUser({ }, {userId, authorId, createdAt}) {
try {
const subscriptionRef = collection(db, "subscriptions");
await addDoc(subscriptionRef, {
userId, authorId, createdAt,
});
} catch (error) {
alert("Error subscribing : " + error.message);
}
},
구독 취소
async unsubscribeFromUser({ }, { subscriptionId }) {
try {
const subscriptionDocRef = doc(db, "subscriptions", subscriptionId);
await deleteDoc(subscriptionDocRef);
} catch (error) {
alert("Error unsubscribing : " + error.message);
}
},
store의 post 모듈 - 구독
// 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 = {
. . .
isSubscribed: false, // 구독 여부 - 상세 보기에서 사용
subscriptionId: '', // 구독 등록 Id - 구독정보 삭제에 사용
};
const mutations = {
. . .
setSubscribed(state, subscribed) { // 구독 여부
state.isSubscribed = subscribed;
},
setSubscriptionId(state, subscriptionId) { // 구독 Id
state.subscriptionId = subscriptionId;
},
};
const actions = {
. . .
// --- 구독 요청, 취소 ----------------
// 구독 여부 확인
async checkSubscription({ commit }, { userId, authorId }) {
const q = query(
collection(db, "subscriptions"),
where("userId", "==", userId),
where("authorId", "==", authorId)
);
// 구독 정보가 있으면 구독 중
const querySnapshot = await getDocs(q);
if (!querySnapshot.empty) {
commit("setSubscribed", true);
// 구독 취소를 위하여 구독문서 Id를 구해둔다.
commit("setSubscriptionId", querySnapshot.docs[0].id);
} else {
commit("setSubscribed", false);
commit("setSubscriptionId", '');
}
},
// 구독
async subscribeToUser({ }, {userId, authorId, createdAt}) {
try {
const subscriptionRef = collection(db, "subscriptions");
await addDoc(subscriptionRef, {
userId, authorId, createdAt,
});
} catch (error) {
alert("Error subscribing : " + error.message);
}
},
// 구독 해지
async unsubscribeFromUser({ }, { subscriptionId }) {
try {
const subscriptionDocRef = doc(db, "subscriptions", subscriptionId);
await deleteDoc(subscriptionDocRef);
} catch (error) {
alert("Error unsubscribing : " + 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 ? "구독 취소" : "구독 요청" }} <v-icon>mdi-check-circle</v-icon>
</v-btn>
<v-spacer></v-spacer>
<v-btn small text v-if="isAuthor" @click="goToEditPost()">
수정 <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>
<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']),
// 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() {
// 글 가져오기
// Get the article ID from the route parameters
const postId = this.$route.params.id;
await this.fetchPost(postId); // 글 id로 글 가져오기
// 내용에 포한된 html을 안전한 html로 변경한다.
this.content = this.sanitizeContent(this.post.content);
// 글 저자 설정정보 가져오기 - 상태에 로드되어 있는 전체 계정 설정 정보를 이용한다.
this.author = this.profiles.find(profile => profile.uids.includes(this.post.userId));
// postId에 대한 comments를 로드한다.
await this.fetchComments(postId);
},
};
</script>
'토이 프로젝트 - Vue, Firebase로 서버리스 PWA 개발' 카테고리의 다른 글
24. Vue와 Firebase 서버리스 PWA myBlog 개발 - 블로그 보기 (0) | 2025.03.01 |
---|---|
23. 프론트엔드 서버리스 프로젝트 PWA myBlog 개발 - 블로그 조회수 (0) | 2025.02.28 |
21. 서버리스 PWA myBlog 개발 - 게시글 댓글에 대한 답글 (0) | 2025.02.26 |
20. Firestore로 PWA myBlog 개발 - 댓글 쓰기 (0) | 2025.02.25 |
19. Firestore로 PWA myBlog 개발 - 글수정 (0) | 2025.02.24 |