Menu
CoddyTech

집계 파이프라인

Coddy Node.js 여정의 MongoDB 데이터베이스 섹션에 포함된 레슨. 29개 중 24번째.

"최고 평점" 목록에는 평균, 최소 평점 수, 정렬, 그리고 결과 수 제한이 필요합니다. 각각이 하나의 단계입니다.

$match는 find처럼 필터를 통과한 문서를 유지합니다. $avg는 $group 안에서 필드의 평균을 계산합니다. 단계는 작성한 순서대로 실행되며, 각 단계는 바로 앞 단계에서 전달된 항목만 볼 수 있습니다. $group 앞의 $match는 저장된 문서를 필터링합니다. $group 뒤의 $match는 { $match: { orders: { $gte: 3 } } }에서처럼 그룹이 만든 필드를 기준으로 그룹을 필터링합니다. 하나의 $group으로 spent: { $sum: '$total' }과 orders: { $sum: 1 }처럼 여러 필드를 한 번에 만들 수 있습니다.

await orders.aggregate([
    { $match: { status: 'paid' } },
    { $group: { _id: '$customer', spent: { $sum: '$total' } } },
    { $sort: { spent: -1 } },
    { $limit: 5 },
]).toArray();

$sort와 $limit은 커서에서와 동일한 인수를 받지만, 파이프라인은 작성한 순서대로 이들을 실행합니다. $sort 앞에 $limit을 두면 임의의 다섯 개를 남기고 그 항목만 정렬합니다.

challenge icon

챌린지

쉬움

이 단계에서는 $group를 사용한 그룹화의 프로젝트 파일(여기에는 server.js 포함)이 아니라, 강의에서 준비한 책장 API 프로젝트를 엽니다. 이전 작업은 해당 강의에 그대로 남아 있습니다. server.js를 수정하기 전에 제공된 파일을 읽어 보세요. 이 사본에는 책장 경로와 장르별 개수 요약이 들어 있습니다.

책장에는 "top rated" 목록, 즉 평균 별점이 가장 높은 책 세 권이 필요합니다. 목록에 포함되려면 책에 별점이 2개 이상 있어야 하므로, 운 좋게 별점 5점을 한 번 받았다고 해서 맨 위에 오지는 않습니다.

  1. server.js에 GET /stats/top 경로를 추가합니다. 이 경로는 books가 아닌 ratings 컬렉션에서 집계를 실행합니다.
  2. 별점을 bookId별로 그룹화합니다. 각 그룹에는 필드가 세 개 있습니다. _id는 책의 ID이고, avg는 해당 책의 stars 평균이며, count는 별점 개수입니다. 하나의 $group 단계에서 avg와 count를 모두 계산할 수 있습니다.
  3. count가 2 이상인 그룹만 남깁니다. 이 규칙은 개별 별점이 아니라 책의 그룹에 적용되므로, 이 단계는 그룹화 이후에 와야 합니다.
  4. 그룹을 avg 기준으로 높은 순서부터 정렬하고, 맨 앞의 3개만 남깁니다.
  5. 상태 코드 200과 배열을 응답으로 보냅니다. 각 항목에는 정확히 다음 세 필드가 있습니다.
    [
      { "_id": "6720a1f4c2b9e81d3a5f0c11", "avg": 4.5, "count": 2 },
      { "_id": "6720a1f4c2b9e81d3a5f0c12", "avg": 3, "count": 2 }
    ]
    _id는 책의 _id이므로, 클라이언트는 해당 ID를 GET /books/ 뒤에 붙여 책을 가져올 수 있습니다. 별점이 2개 이상인 책이 세 권보다 적으면 배열도 그보다 짧습니다. 직접 확인해 보세요. 실행할 때마다 ratings가 비워지므로, 마지막으로 실행한 후 다음 요청을 보냅니다. GET /books를 보내고 Dune, Kindred, Emma의 _id를 복사합니다. PUT /books/<Dune's _id>/ratings를 보내며 { "user": "ada", "stars": 5 }를 넣고, 이어서 { "user": "bo", "stars": 4 }를 넣어 다시 보냅니다. 같은 방식으로 Kindred에 ada가 별점 3점을, bo가 별점 3점을 매깁니다. Emma에는 { "user": "ada", "stars": 5 }를 넣어 한 번만 별점을 매깁니다. GET /stats/top을 보내면 Dune(avg 4.5, count 2)이 먼저 나오고, 그다음 Kindred(avg 3, count 2)가 나옵니다. Emma는 별점이 하나뿐이므로 표시되지 않습니다. Emma가 먼저 나온다면 개수 필터가 그룹화 전에 실행되거나, 아예 실행되지 않은 것입니다.

직접 해보기

import express from 'express';
import { MongoClient, ObjectId } from 'mongodb';

const client = new MongoClient('mongodb://localhost:27017');
const db = client.db('shelf');
const books = db.collection('books');
const ratings = db.collection('ratings');

await books.createIndex({ isbn: 1 }, { unique: true });
await books.insertMany([
    { title: 'Dune', author: 'Frank Herbert', year: 1965, isbn: '9780441013593', genre: 'scifi' },
    { title: 'Kindred', author: 'Octavia E. Butler', year: 1979, isbn: '9780807083697', genre: 'scifi' },
    { title: 'Emma', author: 'Jane Austen', year: 1815, isbn: '9780141439587', genre: 'classic' },
]);

const app = express();
app.use(express.json());

// 모든 오류는 { error: message } 형식으로 응답합니다.
function sendError(res, status, message) {
    res.status(status).json({ error: message });
}

// :id를 ObjectId로 반환하거나, 유효하지 않은 id에 대해 400으로 응답한 후 null을 반환합니다.
function parseId(req, res) {
    if (ObjectId.isValid(req.params.id)) {
        return new ObjectId(req.params.id);
    }
    sendError(res, 400, 'Not a valid id');
    return null;
}

app.get('/', (req, res) => {
    res.send('Bookshelf API');
});

app.get('/books', async (req, res) => {
    const filter = {};
    if (req.query.author) {
        filter.author = req.query.author;
    }
    if (req.query.from || req.query.to) {
        filter.year = {};
        if (req.query.from) {
            filter.year.$gte = Number(req.query.from);
        }
        if (req.query.to) {
            filter.year.$lte = Number(req.query.to);
        }
    }
    const list = await books.find(filter).toArray();
    res.json(list);
});

app.post('/books', async (req, res) => {
    const book = req.body;
    try {
        await books.insertOne(book);
    } catch (err) {
        if (err.code === 11000) {
            return sendError(res, 409, 'That ISBN is already on the shelf');
        }
        throw err;
    }
    res.status(201).json(book);
});

app.get('/books/newest', async (req, res) => {
    const n = Number(req.query.n) || 3;
    const list = await books
        .find({}, { projection: { title: 1, year: 1, _id: 0 } })
        .sort({ year: -1 })
        .limit(n)
        .toArray();
    res.json(list);
});

app.get('/books/:id', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    const book = await books.findOne({ _id: id });
    if (!book) {
        return sendError(res, 404, 'No such book');
    }
    res.json(book);
});

app.patch('/books/:id', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    const result = await books.updateOne({ _id: id }, { $set: req.body });
    if (result.matchedCount === 0) {
        return sendError(res, 404, 'No such book');
    }
    res.json(await books.findOne({ _id: id }));
});

app.post('/books/:id/copies', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    const result = await books.updateOne({ _id: id }, { $inc: { copies: 1 } });
    if (result.matchedCount === 0) {
        return sendError(res, 404, 'No such book');
    }
    res.json(await books.findOne({ _id: id }));
});

app.post('/books/:id/tags', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    const result = await books.updateOne({ _id: id }, { $push: { tags: req.body.tag } });
    if (result.matchedCount === 0) {
        return sendError(res, 404, 'No such book');
    }
    res.json(await books.findOne({ _id: id }));
});

app.delete('/books/:id', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    const result = await books.deleteOne({ _id: id });
    if (result.deletedCount === 0) {
        return sendError(res, 404, 'No such book');
    }
    res.status(204).end();
});

app.put('/books/:id/ratings', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    const { user, stars } = req.body;
    await ratings.updateOne({ bookId: id, user }, { $set: { stars } }, { upsert: true });
    res.json({ user, stars });
});

app.get('/books/:id/ratings', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    res.json(await ratings.find({ bookId: id }).toArray());
});

app.get('/stats', async (req, res) => {
    const filter = req.query.genre ? { genre: req.query.genre } : {};
    res.json({
        books: await books.countDocuments(filter),
        ratings: await ratings.countDocuments(),
    });
});

app.get('/stats/genres', async (req, res) => {
    const genres = await books
        .aggregate([{ $group: { _id: '$genre', count: { $sum: 1 } } }])
        .toArray();
    res.json(genres);
});

app.listen(3000, () => {
    console.log('Bookshelf is running on port 3000');
});
quiz icon실력 점검

이 레슨에는 짧은 퀴즈가 포함되어 있습니다. 레슨을 시작해 문제를 풀고 진행 상황을 기록하세요.

MongoDB 데이터베이스의 모든 레슨

직접 연습해 보세요: Express 플레이그라운드