티스토리 뷰

728x90
반응형

목차

     

     

    지난 글에선 컨트롤러에 사용자 검증 로직을 추가하고, 에러 핸들링을 유틸로 분리해 재사용성을 증가시켰다.

     

    이번 글에선 'Comment'관련 모듈을 추가함과 동시에, 몽고디비의 각 컬렉션, 혹은 도큐먼트 사이의

     

    1:N, N:M 관계 설정을 구현하겠다.

     

    사실, 결론부터 말하자면 1:N 관계를 보면 N:M 관계는 따로 공부할 필요가 없다.

     

    양쪽에서 서로 1:N 관계를 구현하면 되기 때문이다. JPA에서의 중간 테이블 같은 건 없다.

     

    어쨌건 오늘 완성할 프로젝트 구조는 아래와 같다.

     

     

    Referencs vs. Embed

     

    몽고DB와 같은 NoSQL에선 컬렉션의 관계를 설정하는 방법이 크게 두 가지가 있다.

     

    바로 제목에 적어둔 참조(Reference)와 임베드(Embed)가 그것인데,

     

    각 방식은 장단점과 유리한 경우가 다르기 때문에, 어느 쪽이 반드시 뛰어나다고 할 수는 없다.

     

    이에 대해서는 따로 정리해 둔 글이 있기에 링크로 대신한다.

     

    [Database]NoSQL(mongoDB)에서 1:N, N:M 구현

     

    [Database]NoSQL(mongoDB)에서 1:N, N:M 구현

    목차 NoSQL NoSQL은 Not only SQL의 약자이며, 기존의 관계형 데이터베이스에서 벗어난 저장방식을 가리킨다. 이에 관한 더 자세한 설명 및 SQL과의 비교는 예전에 정리한 적이 있으므로, 해당 게시글로

    gnidinger.tistory.com

    해서 결론만 말하자면, 이 글에선 데이터 중복과 일관성을 보장하는 참조방식을 사용할 것이다.

     

    /src/models

     

    /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[];
    }
    
    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',
          },
        ],
      },
      {
        timestamps: true,
      }
    );
    
    feedSchema.pre('save', autoIncrement('feedSeq'));
    
    const Feed = mongoose.model<IFeed>('Feed', feedSchema);
    
    export default Feed;

    피드 모듈은 거의 같지만, 유저 모듈과의 1:N 참조관계를 위해 필드를 추가했다.

     

    가장 먼저, 기존의 'body'필드의 이름을 'content'로 교체했는데, 이는 요청 및 응답 객체에서 body를 가져올 때와

     

    헷갈리지 않기 위함이다.

     

    계속해서 추가한 부분만 보자면

    interface IFeed extends Document {
      user: Schema.Types.ObjectId;
      userSeq: number;
      feedSeq: number;
      title: string;
      content: string;
      comments: Schema.Types.ObjectId[];
    }

    먼저 인터페이스에 유저 필드를 추가했다. 위와 같이 하면 User 도큐먼트의 '_id'를 참조하게 된다.

     

    userSeq를 이미 저장하고 있는데 왜 뜬금없이 '_id'가 나오냐 하면, 몽구스에서 직접 관리하는 식별자인 '_id'를 사용할 경우

     

    데이터 안정성과 더불어 몽구스에서 지원하는 몇몇 메서드를 사용할 수 있기 때문이다.

     

    따라서 나는 참조관계는 '_id'로, 비즈니스 로직은 'userSeq'로 진행할 예정인데,

     

    이렇게 분리하는 경우 userSeq의 일관성에 대해 개발자가 조금 더 신경 써야 한다.

     

    즉, ObjectId 타입의 '_id'가 기존의 관계형 DB에서 일종의 외래키 역할을 하게 된다는 선언이다.

     

    맨 아래엔 해당 피드와 관련된 comment의 ObjectId를 저장하기 위한 배열을 추가했다.

     

    이와 같이하면 피드를 조회할 때 한 번의 쿼리로 댓글까지 불러올 수 있게 된다.

    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',
          },
        ],
      },
      {
        timestamps: true,
      }
    );

    스키마에도 가장 위에 유저 관련 부분이 추가가 되었는데,

     

    이 부분은 user라는 필드에 저장될 값의 타입이 'ObjectId'이며, 해당 타입은 'User' 모델을 참조한다는 뜻이다.

     

    여기서 User 모델을 참조하기 때문에, 차후에 데이터를 집계하거나 참조 문서를 가져올 때(populate) 사용된다.

     

    가장 아래엔 comments 배열이 추가되었으며, 이도 마찬가지 설정이지만 배열이라는 차이점이 존재한다.

     

    /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;
    }
    
    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 },
      },
      {
        timestamps: true,
      }
    );
    
    commentSchema.pre('save', autoIncrement('commentSeq'));
    
    const Comment = mongoose.model<IComment>('Comment', commentSchema);
    
    export default Comment;

    계속해서 코멘트 모델이다.

     

    피드 모델에서 봤던 것과 비슷하게, 여기선 user와 feed를 둘 다 참조하고 있다.

     

    이어서 userSeq와 feedSeq, commentSeq까지 필드에 추가했으며

     

    클라이언트가 입력할 부분은 'content'하나 정도이다.

     

    /src/controllers

     

     

    /commentController.ts

     

    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 createComment = asyncHandler(async (req: Request, res: Response) => {
      const userSeq = res.locals.user.userSeq;
      const feedSeq = Number(req.params.feedSeq);
    
      const user = await User.findOne({ userSeq: userSeq });
      const feed = await Feed.findOne({ feedSeq: feedSeq });
    
      if (!feed) {
        sendErrorResponse(res, 404, `${feedSeq} 시퀀스에 해당하는 피드가 없습니다.`);
        return;
      }
    
      const newComment = new Comment({ ...req.body, user: user!._id, feed: feed._id, userSeq: userSeq, feedSeq: feedSeq });
      const savedComment = await newComment.save();
    
      feed.comments.push(savedComment._id);
      await feed.save();
    
      res.status(201).json(savedComment);
    });
    
    const getCommentsByFeedSeq = asyncHandler(async (req: Request, res: Response) => {
      const feedSeq = Number(req.params.feedSeq);
    
      const comments = await Comment.find({ feedSeq: feedSeq });
    
      if (!comments) {
        res.status(204).json(comments);
        return;
      }
    
      res.status(200).json(comments);
    });
    
    const updateComment = asyncHandler(async (req: Request, res: Response) => {
      const userSeq = res.locals.user.userSeq;
      const commentSeq = Number(req.params.commentSeq);
    
      const comment = await Comment.findOne({ commentSeq: commentSeq });
    
      if (!comment) {
        sendErrorResponse(res, 404, `${commentSeq} 시퀀스에 해당하는 코멘트가 없습니다.`);
        return;
      }
    
      if (comment.userSeq !== userSeq) {
        sendErrorResponse(res, 401, 'Unauthorized');
        return;
      }
    
      comment.content = req.body.content;
    
      const updatedComment = await comment.save();
      res.status(200).json(updatedComment);
    });
    
    const deleteComment = asyncHandler(async (req: Request, res: Response) => {
      const userSeq = res.locals.user.userSeq;
      const commentSeq = Number(req.params.commentSeq);
    
      const comment = await Comment.findOne({ commentSeq: commentSeq });
    
      if (!comment) {
        sendErrorResponse(res, 404, `${commentSeq} 시퀀스에 해당하는 코멘트가 없습니다.`);
        return;
      }
    
      if (comment.userSeq !== userSeq) {
        sendErrorResponse(res, 401, 'Unauthorized');
        return;
      }
    
      const feed = await Feed.findById(comment.feed);
      if (feed) {
        const index = feed.comments.indexOf(comment._id);
        if (index > -1) {
          feed.comments.splice(index, 1);
          await feed.save();
        }
      }
    
      await Comment.deleteOne({ commentSeq: commentSeq });
      res.status(200).json({ message: '삭제 완료' });
    });
    
    export { createComment, getCommentsByFeedSeq, updateComment, deleteComment };

    순서를 조금 바꿔서 코멘트 컨트롤러를 먼저 보자.

    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';

    참조를 위해 각 객체를 불러와야 하기 때문에 Comment 모듈 말고도 User, Feed를 함께 가져왔다.

    const createComment = asyncHandler(async (req: Request, res: Response) => {
      const userSeq = res.locals.user.userSeq;
      const feedSeq = Number(req.params.feedSeq);
    
      const user = await User.findOne({ userSeq: userSeq });
      const feed = await Feed.findOne({ feedSeq: feedSeq });
    
      if (!feed) {
        sendErrorResponse(res, 404, `${feedSeq} 시퀀스에 해당하는 피드가 없습니다.`);
        return;
      }
    
      const newComment = new Comment({ ...req.body, user: user!._id, feed: feed._id, userSeq: userSeq, feedSeq: feedSeq });
      const savedComment = await newComment.save();
    
      feed.comments.push(savedComment._id);
      await feed.save();
    
      res.status(201).json(savedComment);
    });

    코드의 흐름을 보면, 인증 정보에서 userSeq를, Path variable에서 feedSeq를 가져온 후에

     

    각 정보를 이용해 user와 feed를 검색한다.

     

    이어서 새로운 코멘트 객체를 생성할 때 방금 가져온 user와 feed, userSeq, feedSeq를

     

    req.body(content)와 함께 넣어주고, 이렇게 생성된 객체를 디비에 저장한다.

     

    이어서 feed.comments 배열에 저장된 코멘트의 '_id'를 저장하는데, 이는 피드 조회 시 코멘트까지 가져오기 위함이다.

     

    추가로, 현재 존재하는 모든 컨트롤러의 create메서드에는 성공 코드로 201-created를 보내도록 수정했다.

     

    또한 상태코드를 명시적으로 보내지 않던 응답에도 전부 상태코드를 추가했다.

     

    어쨌거나 위와 같이 작성하고 엔드포인트를 맞춘 후 요청을 보내면 디비엔 아래와 같이 들어간다.

     

    이후의 비즈니스 로직에선 미리 말한 바와 같이 userSeq, 혹은 feedSeq를 이용해 구현했다.

     

    솔직히 이쯤 되니까 굳이 ObjectId로 참조할 필요가 있나..? 싶지만

     

    필요 없다 싶으면 나중에 걷어내도록 하겠다.

    const deleteComment = asyncHandler(async (req: Request, res: Response) => {
      const userSeq = res.locals.user.userSeq;
      const commentSeq = Number(req.params.commentSeq);
    
      const comment = await Comment.findOne({ commentSeq: commentSeq });
    
      if (!comment) {
        sendErrorResponse(res, 404, `${commentSeq} 시퀀스에 해당하는 코멘트가 없습니다.`);
        return;
      }
    
      if (comment.userSeq !== userSeq) {
        sendErrorResponse(res, 401, 'Unauthorized');
        return;
      }
    
      const feed = await Feed.findById(comment.feed);
      if (feed) {
        const index = feed.comments.indexOf(comment._id);
        if (index > -1) {
          feed.comments.splice(index, 1);
          await feed.save();
        }
      }
    
      await Comment.deleteOne({ commentSeq: commentSeq });
      res.status(200).json({ message: '삭제 완료' });
    });

    또한, 코멘트를 삭제하는 과정에도

      const feed = await Feed.findById(comment.feed);
      if (feed) {
        const index = feed.comments.indexOf(comment._id);
        if (index > -1) {
          feed.comments.splice(index, 1);
          await feed.save();
        }
      }

    이와 같은 로직이 포함되었는데, 이는 코멘트가 삭제될 때 해당 피드의 comments 배열에서 '_id'를 제거하는데 쓰인다.

     

    /feedController.ts

     

    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;
      }
    
      res.status(200).json(feed);
    });
    
    const getAllFeeds = asyncHandler(async (req: Request, res: Response) => {
      const feeds = await Feed.find({});
      res.json(feeds);
    });
    
    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 };

    다음은 피드 컨트롤러이다.

    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';

    참조관계를 만들기 위해 User 모듈이,

     

    뒤에 나오게 될 '피드 삭제 시 해당 코멘트 전부 삭제' 로직을 위해 Comment 모듈이 추가되었다.

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

    코멘트 컨트롤러에서 본 것과 같이, user를 조회해서 Feed 객체에 넣어주는 로직이 추가되었다.

    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;
      }
    
      res.status(200).json(feed);
    });

    이어서 피드를 단건으로 조회하는 과정에서 .populate('comments') 메서드가 추가되었는데,

     

    이는 피드 단건 조회 시 comments에 있는 코멘트 객체까지 함께 불러오기 위한 메서스이다.

     

    예를 들어, 코멘트 컨트롤러에서 구현한 것처럼 코멘트를 생성하면

     

    위와 같이 저장되며, 포스트맨으로 해당 피드를 조회하면

     

    위와 같이 코멘트가 한 번에 쿼리 되는 것을 확인할 수 있다.

     

    계속해서 중간의 다른 로직은 크게 변한 것이 없고, 삭제 부분을 보면

    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: '삭제 완료' });
    });

    와 같이 삭제하려는 피드에 해당되는 코멘트까지 전부 삭제하는 로직이 포함된 것을 확인할 수 있다.

     

    로직이라기엔 다소 부끄럽지만,

    await Comment.deleteMany({ feed: feed._id });

    몽구스에서 지원하는 메서드 한 줄로 간단하게 해결할 수 있다.

     

    /src/routes

     

    /commentRoutes.ts

     

    import express from 'express';
    import { createComment, getCommentsByFeedSeq, updateComment, deleteComment } from '../controllers/commentController';
    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);
    
    export default router;

    먼저, 간단하게 코멘트 라우터는 위와 같이 구성된다.

     

    하지만 여기서 의문이 드는 건, 해당 피드의 정보가 보이지 않는다는 데 있다.

     

    이를 해결하는 방법은 물론 위 라우터에서 직접 처리해도 되지만,

     

    아래처럼 feedRoutes.ts에서 라우터를 중첩시켜 조금 더 우아하게 처리할 수 있다.

     

    /feedRoutes.ts

     

    import express from 'express';
    import { createFeed, getFeedByFeedSeq, getAllFeeds, updateFeed, deleteFeed } from '../controllers/feedController';
    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);
    
    export default router;

    코드를 보면 코멘트 라우터를 가져와 '/:feedSeq/comments' 엔드포인트에 중첩시키는 것을 볼 수 있다.

     

    생각하기에 따라 가독성이 떨어질 수도 있으니 취향에 맞게 고르도록 하자.

     

    /src/server.ts

     

    import express, { Request, Response, NextFunction } from 'express';
    import dotenv from 'dotenv';
    import connectDB from './config/db';
    import userRoutes from './routes/userRoutes';
    import feedRoutes from './routes/feedRoutes';
    import commentRoutes from './routes/commentRoutes';
    import morgan from 'morgan';
    import sendErrorResponse from './utils/sendErrorResponse';
    
    dotenv.config();
    
    connectDB();
    
    const app = express();
    const port = process.env.PORT || 3000;
    
    app.use(express.json());
    app.use(morgan('dev'));
    
    app.use('/users', userRoutes);
    app.use('/feeds', feedRoutes);
    app.use('/comments', commentRoutes);
    
    app.get('/', (req, res) => {
      res.send('Hello World!');
    });
    
    // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
    app.use((err: any, req: Request, res: Response, next: NextFunction) => {
      sendErrorResponse(res, err.status || 500, err.message);
      return;
    });
    
    app.listen(port, () => {
      console.log(`Server is running at http://localhost:${port}`);
    });

    코멘트 라우트를 가져와 엔드포인트에 연결한 라인 말고는 변한 것이 없다.

     


    이렇게 해서 'Comment' 관련 모듈과 1:N 관계를 프로젝트 전체에 구현해 보았다.

     

    예전에 리액티브 몽고를 쓸 때는 위와 같은 관계설정이 자동으로 되지 않아 한 줄씩 직접 코드에 넣느라 번거로웠던 기억이 있는데,

     

    노드에 몽고를 물려 사용하니 이렇게 편할 수가 없다는 생각이 들었다.

     

    어쨌거나 이번 글까지 해서 기초적인 구현은 모두 끝난 것 같다.

     

    물론 게시글 제목이나 내용, 코멘트 내용에 대한 유효성(글자수, 금칙어 등)이 남아있지만

     

    사소한 부분이라 다음으로 넘긴다.

     

    계속해서 이어지는 글에선 GitHub Actions를 이용한 자동배포와, 아마존 SES를 이용한 이메일 인증 등을 구현해 보겠다.

     

    오늘은 끝!

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