게시판 - 댓글 기능 만들기 4 (댓글 수 표시)

소스코드

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

이 게시물의 소스코드는 게시판 만들기(고급) / 게시판 - 댓글 기능 만들기 3 (대댓글 기능)에서 이어집니다.

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

git reset --hard
git pull
git reset --hard 5cdc711
git reset --soft da5378e
npm install
atom .

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

git clone https://github.com/a-mean-blogger/board.git
cd board
git reset --hard 5cdc711
git reset --soft da5378e
npm install
atom .

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


게시물(post)의 index 페이지에 댓글(comment)의 수를 표시해 봅시다. 댓글이 있는 경우 게시물 제목 옆에 ()안에 댓글 수가 표시됩니다.

현재 우리는 post와 comment의 관계를 comment model에 저장하고 있습니다. comment의 post 항목에 post id를 저장하여 해당 comment가 어느 post에 연결되어 있는지를 찾을 수 있죠.

그래서 댓글을 읽어올 때는 comment모델을 이용해서 해당 post의 id를 가진 comment들을 찾아서 읽어 옵니다.

게시물의 index페이지에 댓글수를 나타내려면, 각 게시물에 연관된 댓글들을 찾아야 하는데, view에 보여줄 post들을 모두 찾고, 각 comment 모델을 이용해서 각각의 글에 대한 댓글들을 찾아야 할까요? 물론 이렇게 코드를 작성해도 작동은 합니다. 다만 DB와 서버간 자료전달은 그 횟수가 적으면 적을수록 속도가 빠른데 이러한 방식은 비효율적입니다.

한번의 DB요청으로 post의 리스트와 각각 post에 연관된 댓글들을 모두 찾을 수 있는 방법을 알아봅시다.

MongoDB - Aggregation

혹시 SQL계열의 DB를 사용해보신 분들은 위 문제를 join을 써서 아주 쉽게 해결할 수 있습니다. Join은 SQL DB에서 두개의 테이블을 어떠한 키로 연관지어 하나로 합치는 명령어입니다. mongo DB에도 이러한 기능이 있을 까요?

지금까지 우리는 강의에서 두개의 model을 연결할 때 populate함수를 사용했습니다. 이때 문제가 하나 있습니다. comment에는 post의 id를 가진 항목(post)가 존재하기 때문에 comment에 post를 populate할 수 있지만, 반대로 post에는 comment의 정보가 전혀 없기 때문에 post로부터 comment를 populate할 수 없습니다.

populate함수는 mongo DB에서 제공하는 함수가 아니라, mongoDB library인 mongoose에서 제공하는 함수입니다. 현재까지 mongoose를 이용해서 해당 model에 존재하지 않는 데이터를 이용하여 다른 collection을 읽어오는 함수는 존재하지 않습니다.

대신 우리는 mongo DB의 aggregation을 이용해 SQL의 query에 해당하는 복잡한 데이터의 처리를 할 수 있습니다.

collection_이름.aggregate([ query_오브젝트1, query_오브젝트2, query_오브젝트3...  ])

query_오브젝트로 하나의 명령어가 전달됩니다. 위 처럼 여러개의 명령어를 배열로 전달하여 데이터를 조작하고 가공할 수 있습니다. 바로 게시판 코드에 적용된 코드를 살펴봅시다.

폴더 구조

총 4개의 파일이 업데이트 되었는데요, 중요도만 보면 post.js가 99%, index.ejs가 1%, master.css, script.js는 각각 0%입니다.

코드 - js

// routes/post.js

...

// Index
router.get('/', async function(req, res){
  ...

  if(searchQuery) {
    var count = await Post.countDocuments(searchQuery);
    maxPage = Math.ceil(count/limit);
    posts = await Post.aggregate([ // 1
      { $match: searchQuery }, // 2
      { $lookup: { // 3
          from: 'users',
          localField: 'author',
          foreignField: '_id',
          as: 'author'
      } },
      { $unwind: '$author' }, // 4
      { $sort : { createdAt: -1 } }, // 5
      { $skip: skip }, // 6
      { $limit: limit }, // 7
      { $lookup: { // 8
          from: 'comments',
          localField: '_id',
          foreignField: 'post',
          as: 'comments'
      } },
      { $project: { // 9
          title: 1,
          author: {
            username: 1,
          },
          createdAt: 1,
          commentCount: { $size: '$comments'} // 10
      } },
    ]).exec();
  }

  ...

1. mongoose에서 모델.aggregate함수로 모델에 대한 aggregation을 mongodb로 전달할 수 있습니다. 함수에 전달되는 배열의 오브젝트 형태는 mongoDB에서 사용되는 오브젝트와 정확히 일치하므로, 여기서부터는 mongoDB의 aggregation 문서(https://docs.mongodb.com/manual/aggregation)를 참고할 수 있습니다.

2. $match 오브젝트(https://docs.mongodb.com/manual/reference/operator/aggregation/match)는 모델.find함수와 동일한 역할을 합니다. 즉,

Post.find(searchQuery).exec();

Post.aggregate([{ $match: searchQuery }]).exec();

는 정확히 같은 일을 합니다. 그럼 aggregation을 왜 써야 하는가, aggregation에서만 할 수 있는 복잡한 일들이 있습니다. 이 때문에 간단한 일을 할 때는 그냥 일반 모델.함수의 형태로 사용하고, aggregation으로만 할 수 있는 일이 개입되면 이처럼 aggregation으로 고쳐주면 되겠습니다.

3. $lookup 오브젝트(https://docs.mongodb.com/manual/reference/operator/aggregation/lookup)는 한 collection과 다른 collection을 이어주는 역할을 합니다. 네가지 하위 항목을 필수로 갖습니다.

  • from: 다른 collection의 이름을 적습니다.
  • localField: 현재 collection의 항목을 적습니다.
  • foreignField: 다른 collection의 항목을 적습니다.
  • as: 다른 collection을 담을 항목의 이름을 적습니다. 이 항목의 이름으로 다른 collection의 데이터가 생성됩니다.

우리가 만든 오브젝트를 살펴봅시다.

{ $lookup: {
    from: 'users',
    localField: 'author',
    foreignField: '_id',
    as: 'author'
} },

user collection에서 (mongoose는 collection 이름을 항상 영어 복수형으로 생성합니다. 그러므로 user가 아닌 users가 되어야 합니다) post.author의 값을 가지는 user._id들을 모두 찾아 post.author로 덮어쓰고 있습니다. 이때 post.author는 배열의 값을 가집니다. 만약 하나도 없다면, 빈배열을 갖습니다.

4. $unwind 오브젝트(https://docs.mongodb.com/manual/reference/operator/aggregation/unwind)는 배열을 flat하게 풀어주는 역할을 합니다.

예를 들어

[
  { _id:1, name:"john", classes:["math", "history", "art"] }
]

이러한 document가 testDocuments 콜렉션에 있다고 가정하면,

TestDocument.aggregate([{ $unwind: '$classes'}])

위 명령어를 사용할 수 있습니다. $classes처럼 aggregation에서 값으로 '$'가 사용되면, 현재 document의 항목을 나타냅니다. 즉, testDocument의 classes항목을 값으로 전달하기 위해 '$classes'를 사용한 것입니다.

[
  { _id:1, name:"john", classes:"math" }, 
  { _id:1, name:"john", classes:"history" }, 
  { _id:1, name:"john", classes:"art" }
]

이러한 값을 가져 올 수 있습니다.(DB안의 값이 바뀌진 않습니다).

3번에서 $lookup을 사용해 user를 author로 가져왔는데, 말했던 것처럼 배열로 가져옵니다. 이를 flat하게 풀어주기 위해 사용하였습니다. 물론 post.author는 user._id를 갖기 때문에 하나 이상의 값을 가질 수 없으므로 전체 검색결과의 수가 늘어나진 않습니다.

5. $sort 오브젝트(https://docs.mongodb.com/manual/reference/operator/aggregation/sort)는 모델.sort함수와 동일한 역할을 합니다. 다만 '-createdAt'과 같은 형태는 사용할 수 없고, { createdAt: -1 }의 형태로 사용해야 합니다. (모델.sort은 두가지 형태를 모두 사용할 수 있습니다.)

6. $skip 오브젝트(https://docs.mongodb.com/manual/reference/operator/aggregation/skip)는 모델.skip함수와 동일한 역할을 합니다.

7. $limit 오브젝트(https://docs.mongodb.com/manual/reference/operator/aggregation/limit)는 모델.limit함수와 동일한 역할을 합니다.

7번까지는 정확히 지난번과 같은 코드를 aggregation으로 변경한 것입니다.

변경 전 변경 후
posts = await Post.find(searchQuery)
  .populate('author')
  .sort('-createdAt')
  .skip(skip)
  .limit(limit)
  .exec();
posts = await Post.aggregate([
      { $match: searchQuery },
      { $lookup: {
          from: 'users',
          localField: 'author',
          foreignField: '_id',
          as: 'author'
      } },
      { $unwind: '$author' },
      { $sort : { createdAt: -1 } },
      { $skip: skip },
      { $limit: limit },
    ]).exec();

여기에 아래와 같이 두개의 aggregation 오브젝트들이 추가되었습니다.

      { $lookup: { // 8
          from: 'comments',
          localField: '_id',
          foreignField: 'post',
          as: 'comments'
      } },
      { $project: { // 9
          title: 1,
          author: {
            username: 1,
          },
          createdAt: 1,
          commentCount: { $size: '$comments'}
      } },

8. 또다시 $lookup을 사용해서 post._id와 comment.post를 연결합니다. 하나의 post에 여러개의 comments가 생길 수 있으므로 이번에는 $unwind를 사용하지 않습니다.

9. $project 오브젝트(https://docs.mongodb.com/manual/reference/operator/aggregation/project)는 데이터를 원하는 형태로 가공하기 위해 사용됩니다. $project:바로 다음에 원하는 schema를 넣어주면 됩니다. 이때 1은 보여주기를 원하는 항목을 나타냅니다. 다만 _id는 반드시 표시되고 숨길 수 없습니다. 즉 title: 1은 '데이터의 title항목을 보여줄 것'이라는 뜻입니다.

commentCount라는 항목을 새로 생성하고 있는데, 여기에는 $size (https://docs.mongodb.com/manual/reference/operator/aggregation/size)를 사용해서 comments의 길이를 가져옵니다.

aggregation을 통해 post에 commentCount항목을 생성하고 관련 comments의 길이를 갖는 코드를 작성해 보았습니다. 나머지는 이 코드를 어떻게 화면에 보여줄지를 위한 코드이고, 위까지의 내용이 중요한 내용이니 잘 이해해 보도록 합시다. 특히나 $project는 지금 사용한건 아주 기본적인 내용이고 활용방법이 아주 다양합니다.

코드 - ejs

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

          ...
          <% posts.forEach(function(post) { %>
            <tr>
              <td>
     <!-- 1 --> <a href="/posts/<%= post._id %><%= getPostQueryString() %>" class="title-container">
                  <div data-search-highlight="title" class="title-ellipsis ellipsis float-left">
                    <span class="title-text"><%= post.title %></span>
                  </div>
       <!-- 2 --> <% if(post.commentCount){ %>
                    <small class="title-comments d-inline-block pl-2">(<%= post.commentCount %>)</small>
                  <% } %>
                </a>
              </td>
              ...

1. ellipsis(텍스트가 너무 길 경우 해당 범위 밖의 텍스트를 '...'로 표시하는 것)를 구현하기 위해 title-text, title-comments등의 CSS class들이 추가 되었습니다.

2. 댓글의 수는 post route를 통해서 이미 post.commentCount에 저장되어 있으므로 어떻게 보여줄지만 고민하면됩니다. 

코드 - css

// public/css/master.css

...
.board-table .author,
.board-table .date {
  width: 100px;
}
.board-table .title-container:hover .title-comments, /* 1 */
.board-table .title-container:hover .title-text
{
  text-decoration: underline;
}

...

a 태그에 하위 태그가 들어가서 게시물 제목에 마우스오버시 밑줄이 더이상 생기지 않길래 css로 해결하였습니다.

코드 - client js

// public/js/script.js

...

$(function(){
  function resetTitleEllipsisWidth(){
    $('.board-table .title-text').each(function(i,e){
      var $text = $(e);
      var $ellipsis = $(e).closest('.title-ellipsis');
      var $comment = $(e).closest('.title-container').find('.title-comments');

      var textWidth = $text.width();
      var ellipsisWidth = $ellipsis.outerWidth();
      var commentWidth = $comment.outerWidth();
      var padding = 1;

      if(ellipsisWidth <= (textWidth+commentWidth+padding)){
        $ellipsis.width(ellipsisWidth-(commentWidth+padding));
      }
      else {
        $ellipsis.width(textWidth+padding);
      }
    });
  }
  $(window).resize(function(){
    $('.board-table .title-ellipsis').css('width','');
    resetTitleEllipsisWidth();
  });
  resetTitleEllipsisWidth();
});

이 코드는 이번 강의의 내용과는 정말 아무상관도 없는 내용인데요, 게시물의 제목이 아주 긴 경우 댓글의 수를 '...' 다음에 표시해 주기 위한 코드입니다. 정말 신경안쓰셔도 괜찮아요.

마치며...

Mongo DB의 aggregation은 그 한 주제만 가지고도 책한권이 나올 만큼 많은 내용을 담고 있습니다. 이 강의의 목적은 이번 강의에서 소개했던 기능들에 대한 맛보기 정도로, 이러한 것들이 있다는 것을 알려드리는 것입니다. 보통 sql로 만드는 복잡한 query는 mongo DB의 aggregation으로 동일한 일을 할 수 있습니다. 이러한 점을 기억하고 나중에 필요할 때 찾아서 공부할 수 있도록 합시다.

Aggregation 관련 공식 문서 링크:

댓글

아띠 엔터테인먼트 2020.02.12
구글로그인은 internal server error가 나는군욤...
이안 선생님 정말 감사하게 잘 보고 배우며 응용하고 사용하고 있습니다만, 기존에 엑셀파일을 가지고 excel -> json 변형해서 mongo atlas 서버에 mongoimport로 저장을했습니다.
그래서 그걸 불러와서 게시판형식으로 이용하려고 했으나... post부분을 이용해서 다른 게시판이나 이런것들은 다른 응용은 전부 잘 되는데
json으로 mongoimport 한 부분은 삼일째 고생중이나 불러와 지지가 않네요;;
혹시 좋은 방법 아실까요?
I
Ian H 2020.02.12
@아띠 엔터테인먼트,
안녕하세요 구글 로그인 오류 제보주셔서 감사합니다. 현재는 수정하여 잘 작동합니다. 감사합니다!
atlas에 로그인하면 Clusters -> SANDBOX -> COLLECTIONS 에서 DB상의 데이터들을 볼 수 있는데, 거기서 data들이 제대로 import되었는지 먼저 확인해 보시구요, 다음으로 import된 데이터와 기존의 데이터를 한번 비교해 보세요.
P
Peter Kim 2020.02.12
@Ian H,
한번도 몽구스스키마를 통해 데이터 입력없이 import로만 데이터를 입력해서 였을까요?
Author항도 ObjectId 하나하나 입력해서 잘 들어갔는데...
다시 한번 해봐야겠어요.
I
Ian H 2020.02.12
@Peter Kim,
몽구스 스키마를 통해 데이터를 입력하진 않았지만 몽구스 스키마는 있으시죠? 없다면 만들어야 합니다.
P
Peter Kim 2020.02.14
@Ian H,
ㅎㅎ 스승님 이번 문제는 해결했습니다. 추가로 하나 여쭤보고 싶은게.. db.find() 항에서 멋지게 작성해주신 searchQuery 와 searchText를 이용하고있는데요.
mongoose schema에서 제가 datatype을 Boolean으로 만든거를 검색이나 아니면 url로 해서 Boolean에서 true 나 false로 구분해서 목록을 불러오게 하고싶은데, Boolean을 searchQuery 로 만들거나 text+boolean 으로 검색해서 data를 구분하려면 어떻게 해야할까요?
물론 db.find() 에서 조건을 여러가지로 page를 따로 만들어서 해도 될것같긴한데(사실도전해도 될지안될지.. page도 급수적으로 늘어날것같고욤..) 멋진 searchQuery를 만들어 놓으신 내에서 하게 된다면 어떻게 될지 여쭙니다. regex와 regexp는 어렵네여...
(질문료는 어떻게 해야할지 ㅠㅠ)
I
Ian H 2020.02.14
@Peter Kim,
routes/post.js의 createSearchQuery 함수 속 코드를 수정하시면 되는데요, 만드시려는 것이 현재 기능에서 boolean query를 추가하고, 현재 searchText와 새로운 boolean 조건 둘 다 만족되는 게시물만 검색하려는 것이 맞나요?
createSearchQuery 함수 의 return 직전에 boolean query가 있는 경우,  최종 searchQuery를 {$and:[{$or:postQueries},새조건]} 으로 만드는 코드를 추가해 보세요.
I
Ian H 2020.02.14
질문료는 블로그를 다른 사람들에게도 많이 알려주세요^^ 감사합니다!
댓글쓰기

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

UP