티스토리 뷰

728x90
반응형

목차

     

     

    원래 이번 글에서는 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에 여러 가지 기능을 추가해야 하겠지만,

     

    비슷한 기능의 구현이니 하지 않거나 다음 글에 다른 기능과 함께 구현하거나 해야겠다.

    반응형
    댓글
    공지사항
    최근에 올라온 글
    최근에 달린 댓글
    Total
    Today
    Yesterday
    링크
    «   2024/11   »
    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
    글 보관함