게시판 - 댓글 기능 만들기 1 (쓰기, 보기)

소스코드

이 게시물에는 코드작성이 포함되어 있습니다. 소스코드를 받으신 후 진행해 주세요. MEAN Stack/개발 환경 구축에서 설명된 프로그램들(git, npm, atom editor)이 있어야 아래의 명령어들을 실행할 수 있습니다.

이 게시물의 소스코드는 게시판 만들기(고급) / 게시판 - 검색 기능 만들기 2 (작성자 검색, 검색어 하일라이트)에서 이어집니다.

board.git 을 clone 한 적이 있는 경우: 터미널에서 해당 폴더로 이동 후 아래 명령어들을 붙여넣기합니다. 폴더 내 모든 코드가 이 게시물의 코드로 교체됩니다. 이를 원치 않으시면 이 방법을 선택하지 마세요.

git reset --hard
git pull
git reset --hard 606a3f3
git reset --soft 73949cb
npm install
atom .

board.git 을 clone 한 적이 없는 경우: 터미널에서 코드를 다운 받을 폴더로 이동한 후 아래 명령어들을 붙여넣기하여 board.git 을 clone 합니다.

git clone https://github.com/a-mean-blogger/board.git
cd board
git reset --hard 606a3f3
git reset --soft 73949cb
npm install
atom .

- Github에서 소스코드 보기: https://github.com/a-mean-blogger/board/tree/606a3f310eea2d33f930987625147f4d2caa0471


게시판에 '댓글 기능'을 추가해 봅시다. 지금까지의 강의를 통해 주소록정보(contact), 게시물(post), 이용자(user)들의 CRUD 기능을 만들어 보았는데요, 댓글 기능역시 이들과 같은 CRUD 기능이지만 이들과 좀 다른 부분이 있습니다.

우선 댓글을 보고, 작성하는 공간이 게시물(post)의 show 페이지라는 점. 보통 댓글을 보기 위해 다른 페이지를 이동하지 않고, 게시물을 보면 밑에 댓글들이 자동으로 달려있죠. 댓글을 작성하거나 수정하는 것도 마찬가지 입니다. 이 모든 행동들이 게시물의 show view에서 일어납니다.

두번째로 대댓글 기능. 댓글에 댓글을 다는 것은 모델의 관계를 어떻게 설정해야 할까요?

이러한 점들을 3편의 강의를 통해 알아보겠습니다. 이번강의에서는 댓글(comment) 모델을 생성하고, 댓글 작성기능과 댓글 보기 기능을 게시판에 추가합니다.

폴더 구조

코드 - js

우선 comment의 모델부터 살펴봅시다.

// models/Comment.js

var mongoose = require('mongoose');

// schema
var commentSchema = mongoose.Schema({
  post:{type:mongoose.Schema.Types.ObjectId, ref:'post', required:true},   // 1
  author:{type:mongoose.Schema.Types.ObjectId, ref:'user', required:true}, // 1
  parentComment:{type:mongoose.Schema.Types.ObjectId, ref:'comment'}, // 2
  text:{type:String, required:[true,'text is required!']},
  isDeleted:{type:Boolean}, // 3
  createdAt:{type:Date, default:Date.now},
  updatedAt:{type:Date},
},{
  toObject:{virtuals:true}
});

commentSchema.virtual('childComments') //4
  .get(function(){ return this._childComments; })
  .set(function(value){ this._childComments=value; });

// model & export
var Comment = mongoose.model('comment',commentSchema);
module.exports = Comment;

새로운 CRUD를 추가하려면 우선 모델이 있어야 겠죠. comment 모델부터 살펴봅시다.

1. 댓글에는 작성자가 있고, 댓글이 달리게 되는 게시물있습니다. 각각 userpost를 연결하여 관계를 형성해 줍니다. 댓글에 이 둘은 반드시 필요하므로 required:true를 달아줍니다.

2. 대댓글은 다른 댓글에 달리게 되므로 댓글과 댓글간의 관계 형성이 필요합니다. 이처럼 자기 자신의 모델을 자신의 항목으로 가지는 것을 self referencing relationship이라고 합니다. 또한 댓글-대댓글은 동일한 모델이 상하관계를 가지게 되는데 이때 상위에 있는 것을 부모(parent)라고 하고, 하위에 있는 것을 자식(child)이라고 부릅니다. 그래서 parentComment라는 항목을 추가하여 대댓글인 경우 어느 댓글에 달린 댓글인지를 표시하였습니다. 대댓글이 아니고 게시물에 바로 달리는 댓글은 부모 댓글이 없으므로 required는 필요하지 않습니다.

3. 게시물-댓글-대댓글-대댓글... 이런식으로 구조가 형성될 텐데, 만약 중간 댓글이 완전히 삭제되어 버리면 하위 댓글들이 부모를 잃고 고아가 됩니다(농담이 아니라 진짜 고아(orphaned)라고 표현합니다). 이를 방지하기 위해 진짜로 DB에서 댓글 데이터를 지우는 것이아니라, isDeleted: true 로 표시해 웹사이트 상에는 표시되지 않게 합니다.

4. DB상에는 대댓글의 부모정보만 저장하지만, 웹사이트에 사용할 때는 부모로부터 자식들을 찾아 내려가는 것이 더 편리하기 때문에 자식 댓글들의 정보를 가지는 항목을 가상(virtual) 항목으로 추가하였습니다.

다음으로 comments route을 살펴봅시다.

// routes/comments.js

var express  = require('express');
var router = express.Router();
var Comment = require('../models/Comment');
var Post = require('../models/Post');
var util = require('../util');

// create
router.post('/', util.isLoggedin, checkPostId, function(req, res){ // 1
  var post = res.locals.post; // 1-1

  req.body.author = req.user._id; // 2
  req.body.post = post._id;       // 2

  Comment.create(req.body, function(err, comment){
    if(err){
      req.flash('commentForm', { _id: null, form:req.body });                 // 3
      req.flash('commentError', { _id: null, errors:util.parseError(err) });  // 3
    }
    return res.redirect('/posts/'+post._id+res.locals.getPostQueryString()); //4
  });
});

module.exports = router;

// private functions
function checkPostId(req, res, next){ // 1
  Post.findOne({_id:req.query.postId},function(err, post){
    if(err) return res.json(err);

    res.locals.post = post; // 1-1
    next();
  });
}

1. 댓글을 달때는 post id가 필요한데, 저는 이 post id를 route uri를 통해 받도록 하였습니다. 즉 [POST] /comments?postId=postId의 route으로 댓글을 생성합니다. checkPostId 함수는 postId=postId가 있는지, 전달받은 post id가 실제 DB에 존재하는지를 확인하는 middle ware입니다.

1-1. DB에서 찾은 post는 res.locals.post에 보관하여 다음 callback함수에서 계속해서 사용할 수 있도록 합니다.

3. comment의 flash들은 post의 flash들과는 다르게 _id 항목를 가지고, form, errors와 같이 하위항목에 실제 form과 errors 데이터를 저장합니다. post와는 달리 하나의 view 페이지에 여러개의 form이 생기기 때문인데요, 해당 flash 데이터들이 올바른 form을 찾을 수 있게 하기 위해서 입니다. 이에 대해서는 다음 강의에서 자세히 살펴봅니다.

4. 댓글이 생성된 후에는 해당 댓글의 게시물의 페이지로 돌아갑니다. post와 관련된 query들을 함께 옮길 수 있도록 게시판 페이지 기능 만들기2 강의에서 만들었던 res.locals.getPostQueryString 함수도 사용합니다.

새로운 route이 생성되었으니 index.js에 등록해 주어야 합니다. index.js를 살펴봅시다.

// index.js 

...

// Routes
app.use('/', require('./routes/home'));
app.use('/posts', util.getPostQueryString, require('./routes/posts'));
app.use('/users', require('./routes/users'));
app.use('/comments', util.getPostQueryString, require('./routes/comments')); // 1

...

1. comment route을 생성하였습니다.

다음은 댓글이 표시될 post의 show route입니다.

// routes/posts.js
var express  = require('express');
var router = express.Router();
var Post = require('../models/Post');
var User = require('../models/User');
var Comment = require('../models/Comment'); // 1
var util = require('../util');
...

// show
router.get('/:id', function(req, res){ // 2
  var commentForm = req.flash('commentForm')[0] || {_id: null, form: {}};
  var commentError = req.flash('commentError')[0] || { _id:null, parentComment: null, errors:{}};

  Promise.all([
      Post.findOne({_id:req.params.id}).populate({ path: 'author', select: 'username' }),
      Comment.find({post:req.params.id}).sort('createdAt').populate({ path: 'author', select: 'username' })
    ])
    .then(([post, comments]) => {
      res.render('posts/show', { post:post, comments:comments, commentForm:commentForm, commentError:commentError});
    })
    .catch((err) => {
      console.log('err: ', err);
      return res.json(err);
    });
});

...

1. Comment 모듈을 가져옵니다.

2. 댓글은 게시물(post)과 함께 post의 show 페이지에 표시됩니다. 즉 post의 show route은 하나의 게시물과 해당 게시물의 모든 댓글들을 DB에서 모두 읽어온 후 페이지를 만들어야(render)합니다. DB에서 두개 이상의 데이터를 가져와야 하는 경우 Promise.all 함수를 사용할 수 있습니다.

Promise.all 함수는 Promise 배열을 인자로 받고, 전달 받은 모든 Promise들이 resolve될 때까지 기다렸다가 resolve된 데이터들를 같은 순서의 배열로 만들어 다음 callback으로 전달합니다.

post 하나(findOne)와 해당 post에 관련된 comment들 전부(find)를 찾아서 'posts/show' view를 render합니다.

위에서 설명한 것처럼 Promise.all 함수에 전달되는 배열의 순서([ Post.findOne(...), Comment.find(...) ])와 then 함수에서 사용되는 callback함수에 전달되는 배열의 순서([ post, comments ])가 일치하도록 합시다.

코드 - ejs

<!-- views/posts/show.ejs -->

      ...

      <h4 class="mt-3">Comments</h4> <!-- 1 -->

      <% if(comments.length){ %> <!-- 1-1 -->
        <div class="mt-3 border-bottom">
          <% comments.forEach(function(comment) { %> <!-- 1-2 -->
            <div class="border-top pt-1 pb-1">
              <div class="row">
                <div class="col-3 col-md-2 col-lg-1 pl-4"><%= comment.author.username %></div> <!-- 1-3 -->
                <div class="col-9 col-md-10 col-lg-11">
                  <div class="comment-show">
                    <div class="comment-text mb-3"><%= comment.text %></div> <!-- 1-4 -->
                    <small class="d-block">
                      (Created: <span data-date-time="<%= comment.createdAt %>"></span>) <!-- 1-5 -->
                    </small>
                  </div>
                </div>
              </div>
            </div>
          <% }) %>
        </div>
      <% } %>

      <div class="mt-3"> <!-- 2 -->
        <form action="/comments?postId=<%= post._id %><%= getPostQueryString(true) %>" method="post">
          <fieldset <%= !isAuthenticated?'disabled':'' %>> <!-- 2-1 -->
            <div class="row">
              <div class="col-8">
                <textarea name="text" rows="2" class="form-control <%= (commentError.errors.text)?'is-invalid':'' %>"><%= commentForm.form.text %></textarea>
              </div>
              <div class="col-4">
                <button type="submit" class="btn btn-primary h-100 mr-2">Add Comment</button>
              </div>
            </div>
            <% if(commentError.errors.text){ %>
              <span class="invalid-feedback d-block"><%= commentError.errors.text.message %></span> <!-- 2-2 -->
            <% } %>
          </fieldset>
        </form>
      </div>

    </div>
  </body>
</html>

1. DB에서 찾아진 댓글들을 표시하는 부분입니다. 댓글이 존재하면(댓글배열의 길이가 존재하는 경우)(1-1), 각각 댓글을 하나씩 돌면서(1-2) 작성자(1-3), 댓글 텍스트(1-4), 댓글 생성시간(1-5)을 표시합니다.

2. 새로운 댓글을 작성하는 form입니다.

2-1. 비로그인이 상태에서는 <fieldset>disabled 를 붙여서 댓글을 달 수 없게 하였습니다.

2-2. invalid-feedback class는 form-control class와 is-invalid class가 동시에 있는 element와 형제(sibling)인 경우에만 화면에 표시(display: block)되고, 그렇지 않은 경우에는 화면에서 숨겨지기(display: none) 때문에, 이처럼 form-control class와 떨어진 곳에서 사용하는 경우에는 d-block class를 사용하여 강제로 화면에 표시(display: block)될 수 있도록 합니다.

코드 - css

/* public/css/master.css */

...

.post-body,
.comment-text {
  white-space: pre-line;
}

...

댓글 텍스트도 게시물 본문(post-body)처럼 줄바꿈이 제대로 표현될 수 있도록 white-space: pre-line;해줍니다.

실행 결과

게시물에 들어가면 아랫쪽에 댓글을 작성할 수 있는 form이 보입니다. 댓글을 하나 작성해 봅시다.

댓글과 작성시간이 표시됩니다. 이번엔 아무것도 입력하지 않고 'Add Comment' 버튼을 눌러 봅시다.

에러가 제대로 표시되는지 확인합시다.

마치며..

댓글의 가장 기본적인 기능인 댓글 생성과 보기 기능을 추가해 봤습니다. 이전까지는 하나의 view가 CRUD의 하나의 기능만을 수행했는데, post의 show 페이지가 댓글을 보여주고 생성하는 기능까지 담당하게 되었습니다.

이 때문에 post의 show 페이지의 view 코드가 좀 더 복잡해 졌는데요, 댓글에 관련해서 기능들이 더 추가될 예정이므로 계속 더 복잡해질 예정입니다.

댓글

J
Jake Lyu 2020.04.28
var Comment = require("../models/Comment");
clone하지 않고 타이핑으로 따라가고 있는데, module을 변수에 담는 걸 내용에 추가하지 않으신 것 같습니다. Comment is not defined 가 나오더라구요.  
I
Ian H 2020.04.28
@Jake Lyu,
와 타이핑만으로 하신다니 대단해요. 알려주신 부분 수정하였습니다. 도움을 주셔서 감사합니다^^
M
Moonkyu Kim 2020.06.21
플젝 만드는데에 큰 방향을 제시해주고 계셔요 ! 핵심적인 흐름들은 이 강의를 바탕으로 작성하고 있습니다 ㅎㅎ 복받으실거에요 !
I
Ian H 2020.06.23
@Moonkyu Kim,
감사합니다^^ 뭔가 보람차네요 ㅋ
특대갈비 2020.06.29
댓글기능이 더 급해서 댓글기능을 제 프로젝트에 구현하려고 노력했는데 view파일들에서 에러가 납니다. 페이지 기능 만들기 2를 안해서 그러한가요? view (ejs) 파일들에서 getPostQueryString을 찾을수 없다고 나오는데 ejs 파일들에서 불러오는게 왜 안되는지 알려주실수 있나요 ? util.js 나 post.js 등에 파일에는 작동을 할수 있도록  "util.getPostQueryString = function(req, res, next){   res.locals.getPostQueryString = function(isAppended=false, overwrites={}){" 나  "var Post = require('../models/Post'); var util = require('../util'); var Comment = require('../models/Comment');" 이렇게했습니다  너무 감사합니다. 
특대갈비 2020.06.29
 78|      79|                  <div class="mt-3"> <!-- 2 -->  >> 80|                    <form action="/comments?postId=<%= post._id %><%= getPostQueryString(true) %>" method="post">     81|                      <fieldset <%= !isAuthenticated?'disabled':'' %>> <!-- 2-1 -->     82|                        <div class="row">     83|                          <div class="col-8">
getPostQueryString is not defined 이런식으로 나오더라고요
I
Ian H 2020.06.29
@특대갈비,
제 강의의 index.js파일처럼
app.use('/comments', util.getPostQueryString, require('./routes/comments'));
이렇게 util.getPostQueryString을 해당 route의 middle-ware로 사용해 주셔야 해당코드를 사용할 수 있습니다^^
특대갈비 2020.07.01
@Ian H,
감사합니다. app.use('/comments', util.getPostQueryString, require('./routes/comments'));  이렇게 했었는데 자꾸 error 가나서  app.use('/', util.getPostQueryString, require('./routes/home')) 전체로 바꿔버렸더니 되더라구요.  뭔가 저에게 보드 종류가 더 많아서 그런것 같습니다. 감사합니다!
I
Ian H 2020.07.01
@특대갈비,
아마 './routes/comments' 외의 route에서 사용되는 view 파일에서 getPostQueryString호출이 있기 때문일거예요. getPostQueryString이 호출되는 모든 route에 util.getPostQueryString를 넣어주어야 합니다.
특대갈비 2020.07.02
@Ian H,
감사합니다 잘 해결 했습니다.  제 프로젝트가 보드를 여러게 만드는 거 여서 여기서 부터 많은 문제점이 시작 되더라고요.  혹시 괜찮으시다면 몇개 질문을 하고싶습니다.  1. router.get('/', async function(req, res){  여기서 '/' 같이 url  주소를 적는 부분에서 혹시 디비에서 가저온 데이터르 쓸수가 있나요? 예를들면  router. get('/'+ board...   이런식으로  2. 만약 다중 게시판을 개설한다고 하였을때  저는 post 를 (model view route) 를 만들고 이안에서 디비 모델에 boardType 과같이 넣어주어서 하고싶은데 이방법이 맞는건가요...? 죄송합니다 강의와 전혀 관련없는 질문을 하게 되어서
I
Ian H 2020.07.02
@특대갈비,
아닙니다 민스택에 관한 질문은 언제든지 환영입니다^^ route에 db에 있는 정보를 입력해서 각각 다른 board를 보여주고 싶으신거죠?
router. get('/:boardName', ...
이런식으로  :를 이용해서 dynamic한 값을 route으로부터 받을 수 있고, 이 값을 이용해서 DB를 검색한 후 값에 따라 할일을 정해주시면 됩니다.
https://a-mean-blog.com/ko/blog/Node-JS-첫걸음/Hello-World/EJS로-Dynamic-Website-만들기 강의 에서 :nameParam 과 req.params.nameParam를 사용해서 dynamic한 route을 만드는 부분을 봐주세요.
이상민 2020.08.03
h님 궁금한게 있습니다. 게시글 내에 댓글을 추가하게 되면 나중에 게시글을 삭제했을 때 해당 게시글과 관련된 댓글들이 삭제되지 않는 점을 발견했습니다. 그러면 이러한 경우에는 게시글을 삭제하기 전에 삭제하고자 하는 게시글의 댓글을 먼저 삭제하고 나서 게시글을 삭제하는 것이 맞는 거겠죠??
이상민 2020.08.03
코드 한 줄 추가 했더니 관련된 데이터도 잘 삭제 되네요
I
Ian H 2020.08.03
@이상민,
사실 실무에서는 게시글을 삭제했다고 DB에서 해당 데이터를 지우는 일은 거의 없습니다. 대부분 사용자들이 볼 수 없게 처리하고 DB에는 정보가 남아있죠. 관련된 데이터들도 마찬가지로 DB상에서 지우지 않죠. 정답이 있는 것은 아니고 상민님께서 수정하신 내용처럼, 게시물을 지울 때 댓글을 다 찾아서 지울 수도 있고, 댓글이 달려있으면 게시글을 못지우게 강제할 수도 있습니다. 제 강의에서는 글만 지워지고 댓글은 지워지지 않는데, 이건 잘못된것이 맞죠^^;; 하지만 제 코드는 '강의용' 코드로 복잡한 댓글 강의를 조금이라도 간단히 하기 위해 해당부분을 일부러 누락하였습니다.
이상민 2020.08.04
@Ian H,
그렇군요 답변 감사합니다. 그러면 h님이 말씀하신대로 게시글을 삭제할 때 댓글을 db상에서 지우지 않는 이유는 추후에 그 데이터를 이용해서 뭐.. 사이버범죄 예방에 도움이 되도록 사용한다던지?? 이런식으로의 사용을 위해서인가요?? 아니면 또 다른 이유가 있다던지 진짜로 아무 이유없이 단지 데이터 수집을 위한 하나의 대책?이라고 할까요?? 아직 실무를 한 번도 겪어보지를 못해서 잘 모르겠네요. 한 번 궁금해서 물어봅니다.
I
Ian H 2020.08.04
@이상민,
그렇죠. 혹시나 분쟁이 있는 경우를 위해, 통계를 낸다든지, 혹은 빅데이터에 활용하기 위해서 수집하기도 하죠. DB가 차고 시간이 흐르면 일정기간 이전의 데이터는 따로 삭제하기도 합니다.
이상민 2020.08.07
@Ian H,
감사합니다.
I
Ian H 2020.08.10
@이상민,
👍
가정교사 2020.10.16
// routes/comment.js    =>   comments.js 라고 바꾸셔야 할거 같아요 별거 아닌거 같지만...
따라가면서 타이핑 하다가 디렉토리 구조 안보고 주석만 보고 파일 만들었는데 index.js에서 route 등록 할때 깨달았네요
I
Ian H 2020.10.16
@가정교사,
수정하였습니다. 제보주셔서 감사합니다👍
댓글쓰기

이 글에 댓글을 다시려면 SNS 계정으로 로그인하세요. 자세히 알아보기

UP