이 게시물에는 코드작성이 포함되어 있습니다. 소스코드를 받으신 후 진행해 주세요. MEAN Stack/개발 환경 구축에서 설명된 프로그램들(git, npm, atom editor)이 있어야 아래의 명령어들을 실행할 수 있습니다.
이 게시물의 소스코드는 게시판 만들기(고급) / 게시판 - 댓글 기능 만들기 3 (대댓글 기능)에서 이어집니다.
board.git 을 clone 한 적이 있는 경우: 터미널에서 해당 폴더로 이동 후 아래 명령어들을 붙여넣기합니다. 폴더 내 모든 코드가 이 게시물의 코드로 교체됩니다. 이를 원치 않으시면 이 방법을 선택하지 마세요.
board.git 을 clone 한 적이 없는 경우: 터미널에서 코드를 다운 받을 폴더로 이동한 후 아래 명령어들을 붙여넣기하여 board.git 을 clone 합니다.
- Github에서 소스코드 보기: https://github.com/a-mean-blogger/board/tree/727765a7ca5afc3f39fc1c5ae709617f47f082bd
게시물(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에 연관된 댓글들을 모두 찾을 수 있는 방법을 알아봅시다.
혹시 SQL계열의 DB를 사용해보신 분들은 위 문제를 join query을 써서 아주 쉽게 해결할 수 있습니다. Join은 SQL DB에서 두개의 테이블을 특정한 키로 연관지어 하나의 테이블로 합치는 명령어입니다. mongo DB에도 이러한 기능이 있을까요?
지금까지 우리는 강의에서 두개의 model을 연결할 때 populate
함수를 사용했습니다. 하지만 post를 통해 comment를 읽어오려고 하면 문제가 있습니다. 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 DB에서 query를 사용하는 것처럼 다양한 일들을 할 수 있습니다.
collection_이름.aggregate([ query_오브젝트1, query_오브젝트2, query_오브젝트3... ])
query_오브젝트로 하나의 명령어가 전달됩니다. 위 처럼 여러개의 명령어를 배열로 전달하여 데이터를 조작하고 가공할 수 있습니다. 바로 게시판 코드에 적용된 코드를 살펴봅시다.
총 4개의 파일이 업데이트 되었는데요, 중요도만 보면 post.js가 99%, index.ejs가 1%, master.css, script.js는 각각 0%입니다.
// 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)는 SQL의 join과 같이 현재 collection에 다른 collection을 이어주는 역할을 합니다. 네가지 하위 항목을 필수로 갖습니다.
이렇게 작성하면 현재 collection의 localField의 값과, from에 적힌 collection의 foreignField의 값이 일치하는 데이터들을 골라서 as에 적힌 항목으로 생성하게 됩니다.
우리가 만든 오브젝트를 살펴봅시다.
{ $lookup: { from: 'users', localField: 'author', foreignField: '_id', as: 'author' } },
user collection에서 (mongoose는 collection 이름을 항상 영어 복수형으로 생성합니다. 그러므로 'from'에 user가 아닌 users로 적어야 합니다) user._id가 현재 post의 author와 일치하는 user를 모두 찾아 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'}])
위와 같이 unwind 명령어를 사용할 수 있습니다. '$classes'처럼 문자열이 '$'로 시작되면 해당 값은 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(); |
위 두가지 코드는 정확히 같은 결과를 가져옵니다. 왼쪽의 코드가 더 쉽기 때문에 aggregate를 사용하지 않고 코드를 작성 할 수 있다면 굳지 aggregate를 사용하지 않아도 됩니다. 왼쪽의 .populate, .sort, .skip 등등은 mongo DB에서 제공되는 함수가 아니라, mongoose library에서 제공되는 함수들입니다.
이제 우리는 게시물에 댓글수를 추가하려고 하는데, mongoose library에서는 해당 작업을 하는 코드가 없기 때문에 전체를 aggregate 함수로 수정해야 합니다.
아래와 같이 두개의 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는 지금 사용한건 아주 기본적인 내용이고 활용방법이 아주 다양합니다.
<!-- 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에 저장되어 있으므로 어떻게 보여줄지만 고민하면됩니다.
// 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로 해결하였습니다.
// 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'); if($comment.length == 0) return; 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 관련 공식 문서 링크:
댓글
이 글에 댓글을 다시려면 SNS 계정으로 로그인하세요. 자세히 알아보기