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

소스코드

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

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

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

git reset --hard
git pull
git reset --hard edd77a9
git reset --soft 468999e
npm install
atom .

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

git clone https://github.com/a-mean-blogger/board.git
cd board
git reset --hard edd77a9
git reset --soft 468999e
npm install
atom .

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


게시판에 '댓글 기능'을 추가해 봅시다. 지금까지의 강의를 통해 주소록정보(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/comment.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

...

// show
router.get('/:id', function(req, res){
  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);
    });
});

...

댓글은 게시물(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 코드가 좀 더 복잡해 졌는데요, 댓글에 관련해서 기능들이 더 추가될 예정이므로 계속 더 복잡해질 예정입니다.

댓글

댓글쓰기

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

UP