티스토리 뷰
[Node.js]TS, Express, MongoDB, MVC로 게시판 만들기(7) - 좋아요 추가, 코멘트 개수 쿼리 추가
Vagabund.Gni 2023. 7. 29. 02:19목차
[Node.js]TS, Express, MongoDB, MVC로 게시판 만들기(1) - 기본 설정
[Node.js]TS, Express, MongoDB, MVC로 게시판 만들기(2) - 기본 구조
[Node.js]TS, Express, MongoDB, MVC로 게시판 만들기(3) - JWT, 회원 가입과 로그인
[Node.js]TS, Express, MongoDB, MVC로 게시판 만들기(4) - 컨트롤러에 검증로직 추가
[Node.js]TS, Express, MongoDB, MVC로 게시판 만들기(5) - 1:N, N:M 관계
[Node.js]TS, Express, MongoDB, MVC로 게시판 만들기(6) - OAuth 2.0(구글/네이버/카카오)
[Node.js]TS, Express, MongoDB, MVC로 게시판 만들기(7.5) - 페이지네이션
[Node.js]TS, Express, MongoDB, MVC로 게시판 만들기(8) - AWS, Github Actions, NGINX를 이용한 자동배포(1/2)
[Node.js]TS, Express, MongoDB, MVC로 게시판 만들기(8) - AWS, Github Actions, NGINX를 이용한 자동배포(2/2)
[Node.js]TS, Express, MongoDB, MVC로 게시판 만들기(9) - 이미지 리사이징, S3 버킷에 이미지 업로드
원래 이번 글에서는 AWS와 깃허브 액션을 이용한 자동배포를 다룰 생각이었다.
하지만 그쪽으로 넘어가면 기본 기능을 나중에 추가하는 것이 조금 웃길 것 같다는 생각이 들어서,
역시 게시판 하면 있어야 하는 기능과 그간 놓친 기능을 추가하기로 마음을 먹었다.
따라서 이 글은 조금 쉬어가는 느낌이 있으며, 별다른 로직이 없으니 코드만 복붙해도 된다.
이번 글을 마치면 구성될 프로젝트의 구조는 다음과 같다.
수정 및 추가된 코드를 하나씩 살펴보자.
/src/models
/Like.ts
가장 먼저 오늘의 목표인 좋아요 모델을 구성한다. 기능이 단순한 만큼 인터페이스와 스키마도 간결하다.
import mongoose, { Schema, Document } from 'mongoose';
interface ILike extends Document {
user: Schema.Types.ObjectId;
userSeq: number;
likeId: Schema.Types.ObjectId;
likeType: string;
}
const likeSchema: Schema<ILike> = new Schema(
{
user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
userSeq: { type: Number, required: true },
likeId: { type: Schema.Types.ObjectId, required: true },
likeType: { type: String, enum: ['Feed', 'Comment'], required: true },
},
{
timestamps: true,
}
);
export default mongoose.model<ILike>('Like', likeSchema);
여기서 user는 지난 글의 피드와 코멘트처럼 '_id'를 이용해 직접 참조하기 위한 필드이고
userSeq는 쿼리 등에서 사용하기 위해 별도로 저장하는 필드이다.
이어서 likeId는 좋아요가 적용될 Feed, 혹은 Comment의 '_id'를 가리키며,
이후에 특정 피드나 코멘트를 특정하기 위해 별도로 저장한다.
likeType은 enum 타입으로, 좋아요를 누를 대상이 피드인지 코멘트인지 정해주는 필드이다.
이와 같이 구현하면 모든 좋아요를 하나의 스키마와 하나의 핸들러 메서드로 관리할 수 있게 되는데,
그간 자바로 구현했던 좋아요 기능에 비해 확장성과 가독성, 간결함이 뛰어나다고 느꼈다.
/Feed.ts
피드와 코멘트 모델은 별로 달라진 것이 없다.
import mongoose, { Schema, Document } from 'mongoose';
import { autoIncrement } from '../middleware/autoIncrement';
interface IFeed extends Document {
user: Schema.Types.ObjectId;
userSeq: number;
feedSeq: number;
title: string;
content: string;
comments: Schema.Types.ObjectId[];
likesCount: number;
}
const feedSchema: Schema<IFeed> = new Schema(
{
user: {
type: Schema.Types.ObjectId,
ref: 'User',
},
userSeq: { type: Number, required: true },
feedSeq: { type: Number, unique: true, required: true, default: 1 },
title: { type: String, required: true },
content: { type: String, required: true },
comments: [
{
type: Schema.Types.ObjectId,
ref: 'Comment',
},
],
likesCount: { type: Number, default: 0 },
},
{
timestamps: true,
}
);
feedSchema.pre('save', autoIncrement('feedSeq'));
const Feed = mongoose.model<IFeed>('Feed', feedSchema);
export default Feed;
여기서 달라진 부분만 잘라오자면 각각 아래와 같다.
interface IFeed extends Document {
user: Schema.Types.ObjectId;
userSeq: number;
feedSeq: number;
title: string;
content: string;
comments: Schema.Types.ObjectId[];
likesCount: number;
}
좋아요 개수를 저장할 likesCount 필드를 별도로 추가했다. 이는 개수 조회를 위해 매번 추가 쿼리를 날리는 것을 막는다.
],
likesCount: { type: Number, default: 0 },
},
스키마에도 추가해 주었다.
/Comment.ts
코멘트의 경우도 피드와 같은 정도의 변경사항만 존재한다.
import mongoose, { Schema, Document } from 'mongoose';
import { autoIncrement } from '../middleware/autoIncrement';
interface IComment extends Document {
user: Schema.Types.ObjectId;
feed: Schema.Types.ObjectId;
userSeq: number;
feedSeq: number;
commentSeq: number;
content: string;
likesCount: number;
}
const commentSchema: Schema<IComment> = new Schema(
{
user: {
type: Schema.Types.ObjectId,
ref: 'User',
},
feed: {
type: Schema.Types.ObjectId,
ref: 'Feed',
},
userSeq: { type: Number, required: true },
feedSeq: { type: Number, required: true },
commentSeq: { type: Number, unique: true, required: true, default: 1 },
content: { type: String, required: true },
likesCount: { type: Number, default: 0 },
},
{
timestamps: true,
}
);
commentSchema.pre('save', autoIncrement('commentSeq'));
const Comment = mongoose.model<IComment>('Comment', commentSchema);
export default Comment;
마찬가지 위치에 likesCount가 추가된 것을 볼 수 있다.
User.ts는 놀랍게도 변경 사항이 없다.
/src/controllers
/likeController.ts
좋아요 기능을 실제로 구현하기 위해 컨트롤러 모듈을 구성해 보았다.
먼저 요약하자면, 하나의 핸들러 메서드로 이루어진 이 모듈은
이미 좋아요를 누른 객체일 경우엔 좋아요를 취소시키고, 그렇지 않은 객체는 좋아요를 누르는 기능을 한다.
import { Request, Response } from 'express';
import asyncHandler from 'express-async-handler';
import User from '../models/User';
import Like from '../models/Like';
import Feed from '../models/Feed';
import Comment from '../models/Comment';
import sendErrorResponse from '../utils/sendErrorResponse';
const clickLikeContent = asyncHandler(async (req: Request, res: Response) => {
const userSeq = res.locals.user.userSeq;
const likeObjectSeq = req.params.likeObjectSeq;
const likeType = req.body.likeType;
const user = await User.findOne({ userSeq: userSeq });
let content;
if (likeType === 'Feed') {
content = await Feed.findOne({ feedSeq: likeObjectSeq });
} else if (likeType === 'Comment') {
content = await Comment.findOne({ commentSeq: likeObjectSeq });
}
if (!content) {
sendErrorResponse(res, 404, `${likeObjectSeq}를 가진 ${likeType}가 없습니다.`);
}
const existingLike = await Like.findOne({ user: user!._id, likeId: content!._id, likeType: likeType });
if (existingLike) {
await existingLike.deleteOne({ _id: existingLike._id });
content!.likesCount--;
await content!.save();
res.status(200).json({ message: '좋아요 취소가 완료되었습니다.' });
} else {
const newLike = new Like({ user: user!._id, userSeq: userSeq, likeId: content!._id, likeType: likeType });
const savedLike = await newLike.save();
content!.likesCount++;
await content!.save();
res.status(201).json(savedLike);
}
});
export default clickLikeContent;
이젠 import문과 메서드 시그니처 등은 설명하지 않아도 될 것 같으니 로직의 중심이 되는 부분만 다루겠다.
let content;
if (likeType === 'Feed') {
content = await Feed.findOne({ feedSeq: likeObjectSeq });
} else if (likeType === 'Comment') {
content = await Comment.findOne({ commentSeq: likeObjectSeq });
}
개인적으로 꽤 매력적이라 느낀 부분이다. content를 선언하고 likeType에 따라 그 안에 피드 혹은 코멘트 객체를 넣는다.
const existingLike = await Like.findOne({ user: user!._id, likeId: content!._id, likeType: likeType });
해당 객체에 좋아요가 이미 있는지 여부를 조회하는 로직이다.
content!._id를 이용해 피드 혹은 코멘트의 고유 식별자를 사용하는 것을 볼 수 있다.
물론 여기서 content의 시퀀스를 사용해도 되지만, 이쪽이 조금 더 안정적이라고 느껴져서 이렇게 구현했다.
이후로는 미리 요약한 바와 같이 좋아요/취소 여부를 결정하고 카운트를 올리거나 내려준다.
이와 같이 모델과 컨트롤러를 구성하면 피드와 코멘트 컨트롤러에 추가 로직 없이 likesCount를 바로 읽어올 수 있다.
/feedController.ts
여기까지 구현하다가 떠오른, 피드 조회 시 코멘트의 개수를 가져오는 것을 추가로 구현했다.
다만 좋아요의 경우처럼 commentsCount 필드를 새로 추가한 것이 아닌,
피드에 해당하는 코멘트를 조회할 때 그 배열의 길이를 commentsCount라는 변수에 할당해 반환한다.
이 부분은 코드가 조금 길지만, 그래도 전체를 첨부한다.
import { Request, Response } from 'express';
import asyncHandler from 'express-async-handler';
import User from '../models/User';
import Feed from '../models/Feed';
import Comment from '../models/Comment';
import sendErrorResponse from '../utils/sendErrorResponse';
const createFeed = asyncHandler(async (req: Request, res: Response) => {
const userSeq = res.locals.user.userSeq;
const user = await User.findOne({ userSeq: userSeq });
const newFeed = new Feed({ ...req.body, user: user!._id, userSeq: userSeq });
const savedFeed = await newFeed.save();
res.status(201).json(savedFeed);
});
const getFeedByFeedSeq = asyncHandler(async (req: Request, res: Response) => {
const feedSeq = req.params.feedSeq;
const feed = await Feed.findOne({ feedSeq: feedSeq }).populate('comments');
if (!feed) {
sendErrorResponse(res, 404, `${feedSeq} 시퀀스에 해당하는 피드가 없습니다.`);
return;
}
const commentsCount = feed.comments.length;
res.status(200).json({ ...feed.toJSON(), commentsCount });
});
const getAllFeeds = asyncHandler(async (req: Request, res: Response) => {
const feeds = await Feed.find({}).populate('comments');
const feedsWithCommentsCount = feeds.map((feed) => {
const commentsCount = feed.comments.length;
return { ...feed.toObject(), commentsCount };
});
res.json(feedsWithCommentsCount);
});
const updateFeed = asyncHandler(async (req: Request, res: Response) => {
const userSeq = res.locals.user.userSeq;
const feedSeq = Number(req.params.feedSeq);
const feed = await Feed.findOne({ feedSeq: feedSeq });
if (!feed) {
sendErrorResponse(res, 404, `${feedSeq} 시퀀스에 해당하는 피드가 없습니다.`);
return;
}
if (feed.userSeq !== userSeq) {
sendErrorResponse(res, 401, 'Unauthorized');
return;
}
feed.title = req.body.title;
feed.content = req.body.content;
const updatedFeed = await feed.save();
res.status(200).json(updatedFeed);
});
const deleteFeed = asyncHandler(async (req: Request, res: Response) => {
const userSeq = res.locals.user.userSeq;
const feedSeq = Number(req.params.feedSeq);
const feed = await Feed.findOne({ feedSeq: feedSeq });
if (!feed) {
sendErrorResponse(res, 404, `${feedSeq} 시퀀스에 해당하는 피드가 없습니다.`);
return;
}
if (feed.userSeq !== userSeq) {
sendErrorResponse(res, 401, 'Unauthorized');
return;
}
await Comment.deleteMany({ feed: feed._id });
await Feed.deleteOne({ feedSeq: feedSeq });
res.status(200).json({ message: '삭제 완료' });
});
export { createFeed, getFeedByFeedSeq, getAllFeeds, updateFeed, deleteFeed };
주된 차이점을 살펴보자.
const getFeedByFeedSeq = asyncHandler(async (req: Request, res: Response) => {
const feedSeq = req.params.feedSeq;
const feed = await Feed.findOne({ feedSeq: feedSeq }).populate('comments');
if (!feed) {
sendErrorResponse(res, 404, `${feedSeq} 시퀀스에 해당하는 피드가 없습니다.`);
return;
}
const commentsCount = feed.comments.length;
res.status(200).json({ ...feed.toJSON(), commentsCount });
});
const getAllFeeds = asyncHandler(async (req: Request, res: Response) => {
const feeds = await Feed.find({}).populate('comments');
const feedsWithCommentsCount = feeds.map((feed) => {
const commentsCount = feed.comments.length;
return { ...feed.toObject(), commentsCount };
});
res.json(feedsWithCommentsCount);
});
먼저 getFeedByFeedSeq 메서드에서는 매우 간단하게 commentsCount를 할당하고 있다.
또한
res.status(200).json({ ...feed.toJSON(), commentsCount });
와 같이 구현하면 반환될 JSON 객체에 commentsCount를 자연스럽게 끼워 넣는 것이 가능하다.
이어서 그 아래는 모든 피드를 가져올 때의 구현이다.
그렇다고 뭐 크게 다를 것은 없고, 람다식을 이용해서 가져온 피드의 각 코멘트 배열의 길이를 가져와 할당 후 리턴한다.
스프링과 웹플럭스를 거쳐와서 그런지 이런 식의 간결한 구성에는 아직도 적응이 어렵다.
src/routes
/feedRoutes.ts
import express from 'express';
import { createFeed, getFeedByFeedSeq, getAllFeeds, updateFeed, deleteFeed } from '../controllers/feedController';
import clickLikeContent from '../controllers/likeController';
import { authMiddleware } from '../middleware/authentication';
import commentRouter from './commentRoutes';
const router = express.Router();
router.post('/', authMiddleware, createFeed);
router.get('/:feedSeq', getFeedByFeedSeq);
router.get('/', getAllFeeds);
router.patch('/:feedSeq/edit', authMiddleware, updateFeed);
router.delete('/:feedSeq/delete', authMiddleware, deleteFeed);
router.use('/:feedSeq/comments', commentRouter);
router.patch('/:likeObjectSeq/like', authMiddleware, clickLikeContent);
export default router;
좋아요 추가/취소를 위한 엔드포인트가 추가되었다. 이는 보통 PATCH 메서드를 이용해 구현한다고 한다.
/commentRoutes.ts
import express from 'express';
import { createComment, getCommentsByFeedSeq, updateComment, deleteComment } from '../controllers/commentController';
import clickLikeContent from '../controllers/likeController';
import { authMiddleware } from '../middleware/authentication';
const router = express.Router({ mergeParams: true });
router.post('/', authMiddleware, createComment);
router.get('/', getCommentsByFeedSeq);
router.patch('/:commentSeq/edit', authMiddleware, updateComment);
router.delete('/:commentSeq/delete', authMiddleware, deleteComment);
router.patch('/:likeObjectSeq/like', authMiddleware, clickLikeContent);
export default router;
마찬가지 기능을 하는 엔드포인트를 추가했다.
여기까지 하면 이 글의 목표인 좋아요/취소와 그 개수, 피드의 코멘트 개수의 구현이 끝난다.
물론 마이페이지 같은 것을 구성한다면 userController.ts에 여러 가지 기능을 추가해야 하겠지만,
비슷한 기능의 구현이니 하지 않거나 다음 글에 다른 기능과 함께 구현하거나 해야겠다.
'JavaScript > Node.js' 카테고리의 다른 글
- Total
- Today
- Yesterday
- 칼이사
- 기술면접
- 리스트
- 여행
- 백준
- Python
- 동적계획법
- 세모
- 자바
- 지지
- 유럽여행
- Backjoon
- 알고리즘
- 면접 준비
- RX100M5
- 유럽
- java
- 스프링
- 파이썬
- 중남미
- 세계여행
- a6000
- Algorithm
- 스트림
- 세계일주
- BOJ
- spring
- 남미
- 야경
- 맛집
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |