게시판 - 댓글 기능 만들기 3 (대댓글 기능)

소스코드

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

이 게시물의 소스코드는 게시판 만들기(고급) / 게시판 - 댓글 기능 만들기 2 (수정, 삭제)에서 이어집니다.

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

git reset --hard
git pull
git reset --hard 1212b2e
git reset --soft ed7dfd8
npm install
atom .

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

git clone https://github.com/a-mean-blogger/board.git
cd board
git reset --hard 1212b2e
git reset --soft ed7dfd8
npm install
atom .

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


대댓글은 댓글에 댓글을 다는 것입니다. 즉 댓글과 대댓글이 연결되어 있다는 정보를 어딘가 저장해야 하는데, 이를 위해 comment 모델에 parentComment항목을 만들었죠.

예를들어 DB에 postId가 1234인 글에 달린 댓글 id 1을 생각해봅시다.

{ id:1, post:1234, author: ... , text: ... }

댓글 id 1에 대댓글이 달린다면 아래와 같이 댓글 id 2가 생성될겁니다.

{ id:2, parentComment: 1, post:1234, author: ... , text: ... }

댓글 id 1 대댓글을 하나 더 달아봅시다. 댓글 id 3이 생성됩니다.

{ id:3, parentComment: 1, post:1234, author: ... , text: ... }

이번엔 댓글 id 3에 다시 댓글을 달아봅시다.

{ id:4, parentComment: 3, post:1234, author: ... , text: ... }
전체 postId가 1234인 글에 지금까지 달린 댓글들을 검색하면 아래와 같은 검색결과가 나옵니다.
[
  { id:1, post:1234, author: ... , text: ... },
  { id:2, parentComment: 1, post:1234, author: ... , text: ... },
  { id:3, parentComment: 1, post:1234, author: ... , text: ... },
  { id:4, parentComment: 3, post:1234, author: ... , text: ... }
]

이런식으로 부모, 자식 관계가 있는 데이터가 배열에 한줄로 나열된 것을 flat(평평한) 배열이라고 합니다.

위 데이터 처럼 배열의 요소들이 부모-자식 관계에 있는 경우, 아래와 같이 부모-자식을 엮어서 표현할 수 있습니다.

[
  { 
    id:1, post:1234, author: ... , text: ... , 
    childComments: [
      { 
        id:2, parentComment: 1, post:1234, author: ... , text: ... , 
        childComments:[] 
      },
      { 
        id:3, parentComment: 1, post:1234, author: ... , text: ... , 
        childComments:[
          { 
            id:4, parentComment: 1, post:1234, author: ... , text: ... , 
            childComments:[]
          },
        ] 
      },      
    ]
  },
]

댓글 id 1은 자식으로 댓글 id 2와 3을 가지고 있습니다. 댓글 id 2는 자식이 없으며, 댓글 id 3은 댓글 id 4를 자식으로 가집니다. 댓글 id 4는 자식이 없습니다. 자식은 여러명이 될 수도 있으므로 배열로 표시합니다. 이러한 구조를 tree 구조라고 합니다.

DB에는 댓글들이 flat한 형태로 저장되어 있기 때문에 댓글들을 구조에 맞게 웹페이지에 표시하려면 먼저 tree 구조로 변환하는 코드가 필요하고, 두번째로 tree 구조를 render할 수 있는 view도 필요합니다.

댓글이 동일한 구조의 댓글을 하위 항목으로 가지므로 댓글 view 역시 댓글 view안에 다시 댓글 view가 들어가게 만들어야 합니다. 즉 댓글 view를 partial로 만들고, 댓글에 대댓글이 있는 경우 자기 자신인 댓글 view를 원래 댓글 view안에 가지고 있어야 하는 거죠. 이러한 개념을 recursive라고 하는데요, 우선 이해를 돕기 위해 recursive 함수를 예로 들어 보겠습니다.

// recursive 함수의 예

function r(p) {
  
  //할일 ...
  
  for(let child of p.children){
    r(child);
  }
}

r(p);

r함수는 함수 내 코드 안에서 본인인 r함수를 다시 호출하고 있습니다. tree 구조의 전체 아이템을 모두 돌며 뭔가를 하려면 위와 같은 함수가 필요합니다.

우리는 함수가 아닌 recursive view를 만들 텐데, 댓글의 자식이 있는 경우에만 다시 view를 호출하게 합니다. 아래와 같은 구조가 됩니다.

<!-- comment-show.ejs -->

...

<% if(comment.childComments){ %>
  <% comment.childComments.forEach(function(child) { %>
    <%- include('./comment-show', { ... }); %>
  <% }) %>
<% } %>

...

이전 강의를 통해 하나의 post show 페이지에 여러개의 comment edit view가 들어가게 되었습니다. 이제 대댓글, 즉 새로운 댓글도 댓글 하나마다 달 수 있게 해야 하기 때문에 comment new view 역시 여러개가 들어가게 되었습니다. 더욱 복잡해졌죠. 대댓글을 다는 form도 역시 댓글 생성, 댓글 수정과 같은 form을 공유합니다.

대댓글을 다는 form도 edit과 같은 방식으로 화면에서 숨겨지고 보여지게 됩니다.

<!-- 참고용 html 코드-->

<div id="comment-111" class="">
  <div class="comment-show">
    <!-- comment-show 코드 ... -->
    <a onclick="$('#comment-111').addClass('comment-reply-enabled')">Reply</a>    <a onclick="$('#comment-111').addClass('comment-edit-enabled')">Edit</a>
  </div>
  <div class="comment-edit">
    <!-- comment-edit 코드 ... -->
    <a onclick="$('#comment-111').removeClass('comment-edit-enabled')">Cancel</a>
  </div>
  <div class="comment-reply">
    <!-- comment-reply 코드 ... -->
    <a onclick="$('#comment-111').removeClass('comment-reply-enabled')">Cancel</a>
  </div>
</div> 
/* 참고용 css 코드 */

.comment-edit,
.comment-reply,
.comment-edit-enabled .comment-show {
  display:none;
}

.comment-edit-enabled .comment-edit,
.comment-reply-enabled .comment-reply {
  display:inherit;
}

위 내용을 충분히 이해한 후에 이번 강의의 코드를 봅시다.

폴더 구조

코드 - js

comment route부터 살펴봅시다.

// routes/comments.js

// create

      ...
      req.flash('commentError', { _id:null, parentComment:req.body.parentComment, errors:util.parseError(err) });
    ...

// update

      ...
      req.flash('commentError', { _id:req.params.id, parentComment:req.body.parentComment, errors:util.parseError(err) });
    ...
이전 강의에서 commentError flash에서 _id항목에 값이 있으면 댓글 수정과정에서 생긴 error, _id항목의 값이 없으면 댓글 생성 과정에서 생긴 error라고 설명했었습니다. 또한 정확히 어떤 댓글을 수정중이였는지는 _id항목의 값으로 알 수 있구요. 이제 대댓글 생성의 과정 판별을 위해 commentError flash에 parentComment항목이 추가됩니다.
  • 댓글 생성과정에서 발생한 에러: _id항목의 값이 없고, parentComment항목에도 값이 없음
  • 대댓글 생성과정에서 발생한 에러: _id항목의 값이 없지만, parentComment항목에는 값이 있음
  • 댓글 수정과정에서 생성된 에러: _id항목의 값이 있고, parentComment항목에도 값이 있음

parentComment의 값은 req.body 즉 comment form으로 부터 오는데 이전강의까지에서는 comment form에parentComment의 항목이 없었죠. 이번 강의에서 해당 항목이 추가됩니다.

다음으로 flat한 배열을 trees로 바꿔줄 convertToTrees함수를 살펴봅시다.

// util.js

...

util.convertToTrees = function(array, idFieldName, parentIdFieldName, childrenFieldName){
  var cloned = array.slice();

  for(var i=cloned.length-1; i>-1; i--){
    var parentId = cloned[i][parentIdFieldName];

    if(parentId){
      var filtered = array.filter(function(elem){
        return elem[idFieldName].toString() == parentId.toString();
      });

      if(filtered.length){
        var parent = filtered[0];

        if(parent[childrenFieldName]){
          parent[childrenFieldName].push(cloned[i]);
        }
        else {
          parent[childrenFieldName] = [cloned[i]];
        }

      }
      cloned.splice(i,1);
    }
  }

  return cloned;
}

module.exports = util;

이 함수는 array, idFieldName, parentIdFieldName, childrenFieldName 네개의 parameter를 가집니다. 실제 함수가 어떻게 flat한 배열을 tree 구조로 바꾸는지는 설명하지 않겠습니다.

  • array: tree구조로 변경할 array를 받습니다.
  • idFieldName: array의 member에서 id를 가지는 field의 이름을 받습니다.
  • parentIdFieldName: array의 member에서 부모id를 가지는 field의 이름을 받습니다.
  • childrenFieldName: 생성된 자식들을 넣을 field의 이름을 정하여 넣습니다.

** 이 함수는 게시판 예제 뿐만아니라 어떠한 flat 배열이라도 tree 구조로 변경할 수 있게 만들었습니다. 필요하신 분들은 제 허락없이 그냥 막 가져다 쓰셔도 됩니다.

// routes/post.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:{}}; //1

  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]) => {
      var commentTrees = util.convertToTrees(comments, '_id','parentComment','childComments');                               //2
      res.render('posts/show', { post:post, commentTrees:commentTrees, commentForm:commentForm, commentError:commentError}); //2
    })
    .catch((err) => {
      return res.json(err);
    });
});

1. commentError flash의 구조가 수정되었으므로 맞게 수정해 줍니다.

2. comment 모델에 맞는 값들을 converToTrees함수로 전달하여 comment 모델을 tree 구조로 변경하고 post show view에 전달합니다. flat comments와 구별하기 위해 commentTrees로 전달하는 이름을 변경하였습니다.

코드 - ejs

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

      ...

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

      <% if(commentTrees.length){ %> 
        <div class="mt-3 border-bottom">
          <% commentTrees.forEach(function(comment) { %>
            <%- include('partials/comment-show', { <!-- 1 -->
              post: post,
              comment: comment,
              commentForm: commentForm,
              commentError: commentError,
            }); %>
          <% }) %>
        </div>
      <% } %>

      <div class="mt-3">
        <%- include('partials/comment-form', {
          actionUrl:'/comments?postId=' + post._id,
          comment: {},
          commentForm: commentForm,
          commentError: commentError,
          parentComment: null, <!-- 2 -->
        }); %>
      </div>

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

1. 각각의 댓글은 이제 partial로 생성됩니다.

2. comment-form에 parentComment 파라메터가 추가되었습니다. 생성시에는 값이 없으므로 null을 전달합니다.

<!-- views/posts/partials/comment-form.ejs -->

<%/*
 actionUrl
 comment
 commentForm
 commentError
 parentComment <!-- 1 -->
*/%>

<form action="<%= actionUrl %><%= getPostQueryString(true) %>" method="post">
  <fieldset <%= !isAuthenticated?'disabled':'' %>>
    <% if(parentComment){ %> <!-- 2 -->
      <input type="hidden" name="parentComment" value="<%= parentComment %>">
    <% } %>
    <div class="row">
      <div class="col-8"> <!-- 3 -->
        <textarea name="text" rows="2" class="form-control <%= (commentError._id == comment._id && commentError.parentComment == parentComment && commentError.errors.text)?'is-invalid':'' %>"><%= (commentForm._id == comment._id)?commentForm.form.text:comment.text %></textarea>
      </div>
      <div class="col-4">
        <% if(comment._id){ %>
          <button type="submit" class="btn btn-primary h-100 mr-2 pl-4 pr-4">Edit</button>
          <a href="javascript:void(0)" onclick="$('#comment-<%= comment._id %>').removeClass('comment-edit-enabled')">Cancel</a>
        <% } else if(parentComment) {%> <!-- 4 -->
          <button type="submit" class="btn btn-primary h-100 mr-2">Add Reply</button>
          <a href="javascript:void(0)" onclick="$('#comment-<%= parentComment %>').removeClass('comment-reply-enabled')">Cancel</a>
        <% } else { %>
          <button type="submit" class="btn btn-primary h-100 mr-2">Add Comment</button>
        <% } %>
      </div>
    </div>
    <% if(commentError._id == comment._id && commentError.parentComment == parentComment && commentError.errors.text){ %> <!-- 2 -->
      <span class="invalid-feedback d-block"><%= commentError.errors.text.message %></span>
    <% } %>
  </fieldset>
</form>

1. parentComment이 parameter로 추가되었습니다.

2. parentComment가 전달되면 form에 hidden 항목으로 form에 추가합니다. hidden type의 input은 화면에 표시되지는 않지만 form submit시 데이터가 함께 전달됩니다.

3. commentError._id == comment._id && commentError.parentComment == parentComment를 통해 에러메세지가 현재 form에 적용되는지를 알 수 있습니다.

4. comment._id가 없고 parentComment가 있는 경우 대댓글 버튼과 취소 문구를 넣어줍니다.

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

<%/*
 post
 comment
 commentForm
 commentError
*/%>

<div class="border-top">
  <% if(comment.isDeleted){ %>
    <div class="text-muted p-2">(Deleted Commnet)</div>
  <% } else { %>
    <div class="row pt-1 pb-1">
      <div class="col-3 col-md-2 col-lg-1 pl-4"><%= comment.author.username %></div>
      <div id="comment-<%= comment._id %>" class="col-9 col-md-10 col-lg-11 <%= (commentError._id == comment._id)?'comment-edit-enabled':'' %> <%= (commentError.parentComment == comment._id)?'comment-reply-enabled':'' %>"> <!-- 1 -->
        <div class="comment-show">
          <div class="comment-text mb-3"><%= comment.text %></div>
          <small class="d-block">
            (Created: <span data-date-time="<%= comment.createdAt %>"></span>
            <% if(comment.updatedAt){ %>
              | Updated: <span data-date-time="<%= comment.updatedAt %>"></span>
            <% } %>
            )
          </small>
          <% if(isAuthenticated){ %> <!-- 4-1 -->
            <small class="d-block">
  <!-- 2 --> <a href="javascript:void(0)" onclick="$('.comment-reply-enabled').removeClass('comment-reply-enabled'); $('.comment-edit-enabled').removeClass('comment-edit-enabled'); $('#comment-<%= comment._id %>').addClass('comment-reply-enabled')">reply</a>
  <!-- 4-2 --> <% if(comment.author && currentUser.id == comment.author.id){ %>
                |
                <a href="javascript:void(0)" onclick="$('.comment-reply-enabled').removeClass('comment-reply-enabled'); $('.comment-edit-enabled').removeClass('comment-edit-enabled'); $('#comment-<%= comment._id %>').addClass('comment-edit-enabled')">Edit</a>
                |
                <form action="/comments/<%= comment._id %>?postId=<%= post._id %>&_method=delete<%= getPostQueryString(true) %>" method="post" class="d-inline">
                  <a href="javascript:void(0)" onclick="confirm('Do you want to delete this?')?this.parentElement.submit():null;">Delete</a>
                </form>
              <% } %>
            </small>
          <% } %>
        </div>
        <div class="comment-edit">
          <%- include('comment-form', {
            actionUrl:'/comments/' + comment._id + '?postId=' + post._id + '&_method=put',
            comment: comment,
            commentForm: commentForm,
            commentError: commentError,
            parentComment: null,
          }); %>
        </div>
        <div class="comment-reply"> <!-- 3 -->
          <%- include('comment-form', {
            actionUrl:'/comments?postId=' + post._id,
            comment: {},
            commentForm: commentForm,
            commentError: commentError,
            parentComment: comment._id,
          }); %>
        </div>
      </div>
    </div>
  <% } %>

  <div class="ml-3"> <!-- 5 -->
    <% if(comment.childComments){ %>
      <% comment.childComments.forEach(function(childComment) { %>
        <%- include('comment-show', {
          post: post,
          comment: childComment,
          commentForm: commentForm,
          commentError: commentError,
        }); %>
      <% }) %>
    <% } %>
  </div>

</div>

1, 2, 3. 댓글 수정기능과 마찬가지로 대댓글 추가를 위한 코드입니다. 이전 강의와 비교해 보면서 다시한번 복습해 봅시다.

4-1, 4-2. Reply버튼은 로그인한(isAuthenticated == true) 사용자 모두에게 보이도록, edit, delete 버튼은 글작성자 본인에게만 보이도록 합니다.

5. 댓글의 대댓글들을 다시 comment-show ejs파일로 보냅니다.

show.ejs, comment-form.ejs, comment-show.ejs는 서로 긴밀하게 연결되어 있습니다. 각각의 코드를 번갈아 보면서 서로가 어떻게 연결되어 있는지를 확실하게 이해하도록 합시다.

코드 - css

/* public/css/master.css */

.comment-edit,
.comment-reply,
.comment-edit-enabled .comment-show {
  display:none;
}

.comment-edit-enabled .comment-edit {
.comment-edit-enabled .comment-edit,
.comment-reply-enabled .comment-reply {
  display:inherit;
}

실행 결과

'reply'버튼(실제로는 문자열)이 생겼습니다. 눌러서 댓글을 추가해 봅시다.

대댓글이 제대로 생성되는지 확인합니다.

다음으로 대댓글에도 빈 form을 전송해 봅시다.

에러메세지가 정확한 위치에 생성되는지 확인합시다.

마지막으로 대댓글의 부모를 지워봅시다.

부모는 지워졌지만, 대댓글은 여전히 정상적으로 표시되고 있습니다.

마치며..

recursive, flat-tree 등을 처음 접하시는 분들은 꽤 어려울 수도 있는 댓글/대댓글 강의였습니다. recursive코드는 항상 한번에 이해하기가 힘들기 때문에 여러번 생각을 해 봐야 합니다. 잘 이해가 안되는 부분이 있다면 댓글 남겨주세요. 감사합니다!

댓글

취직하고싶은기먼식 2020.02.09
안녕하세요 형님 오늘 아침부터 하루종일 이 블로그를 통해서 따라도 해보고 잘 배우고 있습니다.  그런데 덧글의 대댓글, 수정, 삭제를 post.author와 비교하는 문제가 있어서 이를 comment.author와 비교하도록 수정을 했습니다. 그리고 reply 자체가 덧글을 달은 사람밖에 보이지 않아 대댓글은 isAuthenticated 인증만 된 사용자라면 누구라도 달 수 있도록 다시 수정을 해봤습니다. 최종적인 수정은 다음과 같습니다. ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- // comment-form.ejs ...               <% if(isAuthenticated){ %>                 <a href="javascript:void(0)" onclick="$('.comment-reply-enabled').removeClass('comment-reply-enabled'); $('.comment-edit-enabled').removeClass('comment-edit-enabled'); $('#comment-<%= comment._id %>').addClass('comment-reply-enabled')">reply</a>               <% } %>               <% if(isAuthenticated && comment.author && currentUser.id == comment.author.id){ %>                   |                   <a href="javascript:void(0)" onclick="$('.comment-reply-enabled').removeClass('comment-reply-enabled'); $('.comment-edit-enabled').removeClass('comment-edit-enabled'); $('#comment-<%= comment._id %>').addClass('comment-edit-enabled')">Edit</a>                   |                   <form action="/comments/<%= comment._id %>?postId=<%= post._id %>&_method=delete<%= getPostQueryString(true) %>" method="post" class="d-inline">                     <a href="javascript:void(0)" onclick="confirm('Do you want to delete this?')?this.parentElement.submit():null;">Delete</a>                   </form>               <% } %> ... ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- 마음에 드실지 모르겠네요. 도움이 되었으면 좋겠습니다. 형님의 글번호, 조회수, 방문자 횟수, 제목 옆 덧글 갯수확인, 제목과 덧글 길이 제한, 추천, 인기글, 관리자 아이디와 공지사항,사진첨부 등 수많은 기능들을 만드는 게시글이 업데이트 되는 그날까지 기다리겠습니다.
행복한 하루 보내세요.
I
Ian H 2020.02.10
@취직하고싶은기먼식,
안녕하세요. 강의 코드에 오류가 있었네요 진심으로 사과드립니다 ㅠㅠ 현재 강의 코드는 수정했어요. 제시해주신 코드와 거의 유사하게 수정됐어요^^ 
또한 감사하게도 강의 요청을 많이 주셨어요. 우선 글번호, 조회수, 덧글 갯수는 제가 강의로 한번 준비해 보겠습니다. 관리자 아이디, 공지사항, 사진첨부 등은 시간을 좀 두고 강의를 준비해 볼게요.
제목과 덧글 길이 제한은 너무 간단하기 때문에 이정도는 직접 구글링하실 수 있어야 합니다. 코드 한 두줄 정도로 정말 간단해요. (how to limit string size in mongoose, how to limit text input size 등으로 구글에 검색해 보세요)
추천과 인기글은 지금까지 배운 내용 + 미래의 공지사항 강의로 으로 충분히 응용가능하다고 생각합니다.합니다. 강의로 만들면 새롭게 배우는 내용이 없어요. 아마 나중에 '스스로 생각해서 기능 추가하기' 정도의 제목의 강의로, 코드는 제공하지는 않고 방향만 잡아주는 강의로 만들 수는 있겠네요.
방문자 횟수는.. 싸이월드 미니 홈피 이후로 아무도 신경쓰지 않는 기능이라.. ㅋㅋ 다만 사이트의 관리를 위해 내 사이트를 얼마나 방문하는지를 알고 싶으시다면 google analytics나 다른 유사한 서비스를 사용하는 것이 좋습니다.
강의에 건설적인 피드백 주셔서 대단히 감사합니다!
가정교사 2020.10.16
대댓글을  내용없이 빈칸으로 Add Reply를 요청하면 에러 메세지가 reply의 <textarea> 바로 아래 flash 되야하는데 왜 Comment <textarea> 아래에 에러메세지가 flash되는거죠?
대댓글을 수정할때 에러가 발생하면 제대로 나옵니다.
정확하게 대댓글을 작성할때 에러가 다른위치에서 발생합니다. 
I
Ian H 2020.10.19
@가정교사,
강의 github의 코드를 사용하면 add reply를 내용없이 작성할 경우 에러 위치가 정확하게 출력됩니다. 강의 github의 코드와 현재 가지고 계신 코드를 한번 비교해 보시기 바랍니다.
혹시 그래도 문제점을 못찾으시면 가지고 계신 코드를 github에 올려주시면 제가한번 살펴보겠습니다^^
댓글쓰기

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

UP