Menu
CoddyTech

임베딩 또는 참조

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

리뷰와 평점은 모두 책에 속하지만, 각각을 저장하기에 가장 적합한 곳은 다릅니다.

내장은 관련 데이터를 문서 안에 넣으므로, 한 번 읽으면 책과 리뷰를 함께 가져올 수 있습니다. 부모 데이터와 함께 읽고 크기가 작게 유지되는 데이터에 적합합니다. 참조는 ratings처럼 부모의 id와 함께 별도의 컬렉션에 데이터를 저장합니다. 제한 없이 커지거나 모든 평점의 평균처럼 단독으로 조회하는 데이터에 적합합니다.

문서는 최대 16 MB까지 저장할 수 있으므로 내장 배열에는 한도를 설정해야 합니다. $push 안에서 $each는 추가할 값을 나열하고 $slice: -3은 마지막 세 개를 유지합니다.

await players.updateOne(
    { name: 'Ada' },
    { $push: { recentScores: { $each: [120], $slice: -3 } } }
);
challenge icon

챌린지

쉬움

리뷰는 짧고 항상 책과 함께 표시되며, 책에는 가장 최근의 리뷰 5개만 표시됩니다. 따라서 리뷰는 책 문서 안의 reviews 배열에 저장하고, 배열이 5개를 넘지 않도록 하세요.

  1. server.js에 POST /books/:id/reviews를 추가하세요. 요청 본문은 { "user": "ada", "text": "Loved it" }과 같은 형태입니다. 잘못된 ID에는 parseId를 사용하세요. 이 경우 상태 코드 400을 반환합니다.
  2. 한 번의 업데이트로 리뷰(예: { "user": "ada", "text": "Loved it" })를 책의 reviews 배열 끝에 추가하세요. 같은 업데이트에서 배열을 마지막 5개 항목으로 줄여 가장 오래된 리뷰가 빠지게 하세요. 리뷰가 없는 책에는 새 배열이 생성됩니다.
  3. 해당 _id를 가진 책이 없으면 상태 코드 404를 반환하세요.
  4. 그렇지 않으면 상태 코드 201과 책 전체가 아닌 책의 reviews 배열만 반환하세요. 배열은 가장 오래된 리뷰부터 표시합니다. 첫 번째 리뷰를 추가한 후의 응답은 다음과 같습니다.
    [ { "user": "ada", "text": "Loved it" } ]
    실습해 보세요: GET /books를 보내고 Dune의 _id를 복사하세요. { "user": "ada", "text": "Loved it" }을 포함해 POST /books/<Dune's _id>/reviews를 보내면 상태 코드 201과 위 배열이 반환됩니다. 리뷰를 5개 더 보내세요. 텍스트는 "Review 2"부터 "Review 6"까지 사용하세요. 마지막 응답에는 Review 2부터 Review 6까지 리뷰 5개가 포함되고, Loved it은 사라집니다. GET /books/<Dune's _id>를 보내세요. Dune에는 reviews에 같은 리뷰 5개가 저장되어 있고, Data 탭에는 리뷰가 책 안에 표시됩니다.

직접 해보기

import express from 'express';
import { z } from 'zod';
import { ObjectId } from 'mongodb';
import { books, ratings } from './db.js';

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;
}

const BookInput = z.object({
    title: z.string().min(1),
    author: z.string().min(1),
    year: z.number().int(),
    isbn: z.string().min(1),
    genre: z.string().min(1),
});

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 page = Number(req.query.page) || 1;
    const limit = Number(req.query.limit) || 10;
    const items = await books
        .find(filter)
        .skip((page - 1) * limit)
        .limit(limit)
        .toArray();
    const total = await books.countDocuments(filter);
    res.json({ items, total });
});

app.post('/books', async (req, res) => {
    const parsed = BookInput.safeParse(req.body);
    if (!parsed.success) {
        const issue = parsed.error.issues[0];
        return sendError(res, 400, `${issue.path.join('.')}: ${issue.message}`);
    }
    const book = parsed.data;
    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.get('/stats/top', async (req, res) => {
    const top = await ratings
        .aggregate([
            { $group: { _id: '$bookId', avg: { $avg: '$stars' }, count: { $sum: 1 } } },
            { $match: { count: { $gte: 2 } } },
            { $sort: { avg: -1 } },
            { $limit: 3 },
        ])
        .toArray();
    res.json(top);
});

app.get('/books/:id/full', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    const [book] = await books
        .aggregate([
            { $match: { _id: id } },
            {
                $lookup: {
                    from: 'authors',
                    localField: 'author',
                    foreignField: 'name',
                    as: 'authorInfo',
                },
            },
        ])
        .toArray();
    if (!book) {
        return sendError(res, 404, 'No such book');
    }
    res.json(book);
});

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

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

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

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