Stage 3: Backend Essentials

Database · Lesson 06

MongoDB Basics

Documents, collections, Mongoose models and MongoDB Atlas.

Database3 min readFree lesson

MongoDB stores JSON-like documents instead of rows. That flexibility is useful when records genuinely differ, and dangerous when it becomes an excuse to skip schema design.

Learning objectives

  • Explain collections, documents and ObjectIds.
  • Model data with Mongoose schemas.
  • Connect to a local database or MongoDB Atlas.
  • Build CRUD routes with validation.
  • Add indexes and filter on array fields.

1. Documents and collections

database: aivasu
└── collection: memos
    ├── { _id: ObjectId("..."), title: "First", tags: ["node"], createdAt: ... }
    └── { _id: ObjectId("..."), title: "Second", tags: [], createdAt: ... }

Documents can have different fields, and nested objects and arrays are stored directly. Relations exist as references (authorId) or by embedding. Embed when the child is always read with the parent and never grows without bound; reference when it is shared or large.

2. Setup

Local: install MongoDB Community and run mongod. The default URL is mongodb://127.0.0.1:27017/aivasu.

Atlas: create a free cluster, add your IP, create a database user, and copy the connection string into .env:

MONGODB_URI=mongodb+srv://user:[email protected]/aivasu
npm install mongoose
require("dotenv").config();
const mongoose = require("mongoose");

async function connect() {
  await mongoose.connect(process.env.MONGODB_URI);
  console.log("MongoDB connected");
}

connect().catch((error) => {
  console.error(error.message);
  process.exit(1);
});

Never commit the Atlas password. Whitelist only the IPs that need access.

3. A Mongoose model

const mongoose = require("mongoose");

const memoSchema = new mongoose.Schema(
  {
    title: {
      type: String,
      required: [true, "Title is required"],
      trim: true,
      maxlength: [100, "Title must be 100 characters or fewer"],
    },
    content: { type: String, required: true, trim: true },
    tags: { type: [String], default: [] },
    isPublic: { type: Boolean, default: false },
  },
  { timestamps: true },
);

memoSchema.index({ tags: 1 });
memoSchema.index({ createdAt: -1 });

module.exports = mongoose.model("Memo", memoSchema);

mongoose.model("Memo", ...) creates a memos collection. timestamps: true maintains createdAt and updatedAt.

4. CRUD

const Memo = require("./models/Memo");

// create — runs schema validation
const memo = await Memo.create({ title: "First", content: "Hello", tags: ["intro"] });

// list with filter, sort, pagination
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(50, Number(req.query.limit) || 10);

const filter = {};
if (req.query.tag) filter.tags = req.query.tag; // matches an array element
if (req.query.q) filter.title = { $regex: req.query.q, $options: "i" };

const [items, total] = await Promise.all([
  Memo.find(filter).sort({ createdAt: -1 }).skip((page - 1) * limit).limit(limit),
  Memo.countDocuments(filter),
]);

// read one
const one = await Memo.findById(req.params.id);

// update
const updated = await Memo.findByIdAndUpdate(
  req.params.id,
  { $set: { title: "Updated" } },
  { new: true, runValidators: true },
);

// delete
await Memo.findByIdAndDelete(req.params.id);

{ new: true } returns the updated document instead of the old one. runValidators: true is required — update queries skip schema validation by default.

5. Errors

try {
  await Memo.create({ content: "No title" });
} catch (error) {
  if (error.name === "ValidationError") {
    return res.status(400).json({ error: Object.values(error.errors).map((e) => e.message) });
  }
  if (error.name === "CastError") {
    return res.status(400).json({ error: "Invalid id" });
  }
  throw error;
}

An invalid ObjectId throws CastError; guard it before returning a 500.

6. Choosing between embedded and referenced data

  • Embed: comments on a post if they are always shown together and bounded in number.
  • Reference: the post's author, because users exist independently and are shared across posts.
  • Neither: never embed an ever-growing array into a document; MongoDB documents have a 16 MB limit and large arrays are slow to update.

Common mistakes

  • Treating Mongoose as optional validation — findOneAndUpdate bypasses validators unless told otherwise.
  • Storing the connection string in code.
  • Returning the whole document including internal fields to the client.
  • Assuming MongoDB is "schemaless" and skipping indexes; unindexed queries scan every document.

Practice

Build the memo API in the assignment with tags, filtering and pagination.

Example files

Complete, runnable files used in this lesson. Copy them into your project and experiment.

models/Memo.jsJavaScript
// ============================================
// 강의06 예제: Memo 스키마 / 모델
// ============================================

const mongoose = require('mongoose');

// ─────────────────────────────────────────
// Memo 스키마 정의
// 스키마: 문서의 구조, 타입, 제약조건을 정의
// ─────────────────────────────────────────
const memoSchema = new mongoose.Schema(
    {
        // 제목: 문자열, 필수
        title: {
            type:     String,
            required: [true, '제목은 필수입니다.'],
            trim:     true,   // 앞뒤 공백 자동 제거
            maxlength:[100, '제목은 100자를 초과할 수 없습니다.'],
        },

        // 내용: 문자열, 필수
        content: {
            type:     String,
            required: [true, '내용은 필수입니다.'],
            trim:     true,
        },

        // 공개 여부: 기본값 false (비공개)
        isPublic: {
            type:    Boolean,
            default: false,
        },
    },
    {
        // timestamps: true → createdAt, updatedAt 자동 추가
        timestamps: true,
    }
);

// ─────────────────────────────────────────
// 모델 생성
// mongoose.model('모델명', 스키마)
// 컬렉션명: 자동으로 소문자 복수형 → 'Memo' → 'memos'
// ─────────────────────────────────────────
const Memo = mongoose.model('Memo', memoSchema);

module.exports = Memo;
routes/memo.jsJavaScript
// ============================================
// 강의06 예제: Memo CRUD 라우터
// ============================================

const express = require('express');
const Memo = require('../models/Memo');

const router = express.Router();

// ─────────────────────────────────────────
// GET /memos — 전체 메모 목록
// 쿼리: ?tag=태그명 으로 필터링 가능
// ─────────────────────────────────────────
router.get('/', async (req, res) => {
    try {
        const { tag } = req.query;

        // 필터 조건 빌드
        const filter = {};
        if (tag) {
            filter.tags = tag; // 배열 필드에서 특정 값 검색 (MongoDB 자동 처리)
        }

        // find(): 조건에 맞는 문서 전체 조회
        const memos = await Memo.find(filter).sort({ createdAt: -1 }); // 최신 순

        res.json({ success: true, count: memos.length, data: memos });
    } catch (err) {
        res.status(500).json({ success: false, message: err.message });
    }
});

// ─────────────────────────────────────────
// GET /memos/:id — 단일 메모 조회
// ─────────────────────────────────────────
router.get('/:id', async (req, res) => {
    try {
        // findById(): ObjectId로 단건 조회 (없으면 null)
        const memo = await Memo.findById(req.params.id);

        if (!memo) {
            return res.status(404).json({ success: false, message: '메모를 찾을 수 없습니다.' });
        }

        res.json({ success: true, data: memo });
    } catch (err) {
        // ObjectId 형식이 잘못된 경우 CastError 발생
        res.status(400).json({ success: false, message: '유효하지 않은 ID 형식입니다.' });
    }
});

// ─────────────────────────────────────────
// POST /memos — 메모 생성
// Body: { title, content, isPublic? }
// ─────────────────────────────────────────
router.post('/', async (req, res) => {
    try {
        const { title, content, isPublic } = req.body;

        // Memo.create(): new Memo() + save()를 한 번에 처리
        const memo = await Memo.create({ title, content, isPublic });

        res.status(201).json({ success: true, data: memo });
    } catch (err) {
        // 유효성 검사 실패 (required 등) → ValidationError
        res.status(400).json({ success: false, message: err.message });
    }
});

// ─────────────────────────────────────────
// PUT /memos/:id — 메모 수정
// Body: { title?, content?, isPublic? }
// ─────────────────────────────────────────
router.put('/:id', async (req, res) => {
    try {
        const { title, content, isPublic } = req.body;

        // findByIdAndUpdate(): 찾고 + 수정을 한 번에
        // { new: true } → 수정 후 문서 반환 (기본은 수정 전 문서)
        // { runValidators: true } → 스키마 유효성 검사 적용
        const memo = await Memo.findByIdAndUpdate(
            req.params.id,
            { title, content, isPublic },
            { new: true, runValidators: true }
        );

        if (!memo) {
            return res.status(404).json({ success: false, message: '메모를 찾을 수 없습니다.' });
        }

        res.json({ success: true, data: memo });
    } catch (err) {
        res.status(400).json({ success: false, message: err.message });
    }
});

// ─────────────────────────────────────────
// DELETE /memos/:id — 메모 삭제
// ─────────────────────────────────────────
router.delete('/:id', async (req, res) => {
    try {
        // findByIdAndDelete(): 찾고 + 삭제를 한 번에
        const memo = await Memo.findByIdAndDelete(req.params.id);

        if (!memo) {
            return res.status(404).json({ success: false, message: '메모를 찾을 수 없습니다.' });
        }

        res.json({ success: true, message: `"${memo.title}" 메모가 삭제되었습니다.` });
    } catch (err) {
        res.status(400).json({ success: false, message: '유효하지 않은 ID 형식입니다.' });
    }
});

module.exports = router;
server.jsJavaScript
// ============================================
// 강의06 예제: Mongoose 연결 + Express 서버
// ============================================
// 실행 전:
//   1. .env 파일에 MONGODB_URI 설정
//   2. npm install mongoose express dotenv
//   3. node server.js
// ============================================

const express = require('express');
const mongoose = require('mongoose');
require('dotenv').config(); // .env 파일 로드

const app = express();
const PORT = process.env.PORT || 3000;

// JSON 요청 바디 파싱
app.use(express.json());

// ─────────────────────────────────────────
// 1. MongoDB 연결
//    MONGODB_URI는 .env 파일에서 읽어옴
// ─────────────────────────────────────────
mongoose.connect(process.env.MONGODB_URI)
    .then(() => {
        console.log('MongoDB 연결 성공!');
    })
    .catch((err) => {
        console.error('MongoDB 연결 실패:', err.message);
        process.exit(1); // 연결 실패 시 서버 종료
    });

// 연결 이벤트 모니터링
mongoose.connection.on('disconnected', () => {
    console.log('MongoDB 연결 끊김');
});

// ─────────────────────────────────────────
// 2. 라우터 등록
// ─────────────────────────────────────────
const memoRouter = require('./routes/memo');
app.use('/memos', memoRouter); // /memos 경로에 메모 라우터 연결

// 기본 경로
app.get('/', (req, res) => {
    res.json({
        message: '메모 API 서버',
        endpoints: {
            'GET    /memos':            '전체 메모 목록',
            'GET    /memos?tag=태그':   '태그로 필터링',
            'GET    /memos/:id':        '단일 메모 조회',
            'POST   /memos':            '메모 생성',
            'PUT    /memos/:id':        '메모 수정',
            'DELETE /memos/:id':        '메모 삭제',
        },
    });
});

// ─────────────────────────────────────────
// 3. 서버 시작
// ─────────────────────────────────────────
app.listen(PORT, () => {
    console.log(`서버 실행 중: http://localhost:${PORT}`);
});

Assignment

Assignment 06 — Memo API with MongoDB

Build a memo API with Mongoose, backed by a local MongoDB or Atlas.

Setup

npm install express mongoose dotenv

.env holds MONGODB_URI and PORT. Add .env to .gitignore.

Model

Memo: title (required, max 100, trimmed), content (required), tags (array of strings, default []), isPublic (default false), timestamps enabled.

Add indexes on tags and createdAt.

Endpoints

Method Path Notes
GET /memos?tag=&q=&page=&limit= filter, newest first, paginate
GET /memos/:id 404 for missing or invalid id
POST /memos require title and content; 201
PUT /memos/:id update, runValidators: true
DELETE /memos/:id delete, 404 when missing

Requirements

  1. Connect once at startup and exit with a clear message if the connection fails.
  2. GET returns { success, total, page, totalPages, data }.
  3. limit is clamped to a maximum of 50.
  4. tag matches an element of the tags array; q is a case-insensitive title search.
  5. ValidationError returns 400, CastError returns 400 with "Invalid id".
  6. No document's __v field is exposed.

Hints

const filter = {};
if (req.query.tag) filter.tags = req.query.tag;
if (req.query.q) filter.title = { $regex: req.query.q, $options: "i" };

const [data, total] = await Promise.all([
  Memo.find(filter).sort({ createdAt: -1 }).skip((page - 1) * limit).limit(limit),
  Memo.countDocuments(filter),
]);
Memo.findByIdAndUpdate(id, update, { new: true, runValidators: true });

Done when

  • GET /memos?tag=node returns only memos whose tags include node.
  • An invalid id such as /memos/abc returns 400, not a stack trace.
  • Creating a memo without a title returns the Mongoose validation message.
Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

models/Memo.jsJavaScript
// ============================================
// 과제06 정답: tags 배열 필드가 추가된 Memo 모델
// ============================================

const mongoose = require('mongoose');

// ─────────────────────────────────────────
// Memo 스키마 정의
// tags 필드(문자열 배열)를 추가하여 태그 기반 검색 지원
// ─────────────────────────────────────────
const memoSchema = new mongoose.Schema(
    {
        // 제목: 필수, 최대 100자
        title: {
            type:      String,
            required:  [true, '제목은 필수입니다.'],
            trim:      true,
            maxlength: [100, '제목은 100자를 초과할 수 없습니다.'],
        },

        // 내용: 필수
        content: {
            type:     String,
            required: [true, '내용은 필수입니다.'],
            trim:     true,
        },

        // 태그 배열: 문자열 배열, 기본값 빈 배열
        // MongoDB는 배열 필드를 기본으로 지원
        // find({ tags: '공부' }) → tags 배열에 '공부'가 포함된 문서 검색
        tags: {
            type:    [String], // 문자열 배열
            default: [],       // 태그 없으면 빈 배열
        },

        // 공개 여부: 기본값 false
        isPublic: {
            type:    Boolean,
            default: false,
        },
    },
    {
        // timestamps: true → createdAt, updatedAt 자동 관리
        timestamps: true,
    }
);

// ─────────────────────────────────────────
// 인덱스 설정 (선택)
// tags 필드에 인덱스 → 태그 검색 성능 향상
// ─────────────────────────────────────────
memoSchema.index({ tags: 1 });       // 태그 검색용
memoSchema.index({ createdAt: -1 }); // 최신 순 정렬용

// ─────────────────────────────────────────
// 모델 생성
// 컬렉션명: 'Memo' → 'memos'
// ─────────────────────────────────────────
const Memo = mongoose.model('Memo', memoSchema);

module.exports = Memo;
routes/memo.jsJavaScript
// ============================================
// 과제06 정답: 태그 기반 검색이 포함된 Memo 라우터
// ============================================

const express = require('express');
const Memo = require('../models/Memo');

const router = express.Router();

// ─────────────────────────────────────────
// GET /memos/tags — 사용 중인 전체 태그 목록
// (주의: /:id 라우트보다 먼저 등록해야 충돌하지 않음)
// ─────────────────────────────────────────
router.get('/tags', async (req, res) => {
    try {
        // distinct(): 특정 필드의 고유한(중복 없는) 값 목록 반환
        // 모든 메모의 tags 배열을 합쳐서 중복 제거
        const tags = await Memo.distinct('tags');

        // 알파벳/가나다 순 정렬
        tags.sort();

        res.json({ success: true, count: tags.length, data: tags });
    } catch (err) {
        res.status(500).json({ success: false, message: err.message });
    }
});

// ─────────────────────────────────────────
// GET /memos — 전체 메모 목록 (태그 필터 지원)
// 쿼리: ?tag=태그명
// 예: GET /memos?tag=공부 → tags에 '공부'가 포함된 메모만
// ─────────────────────────────────────────
router.get('/', async (req, res) => {
    try {
        const { tag } = req.query;

        // 필터 조건 구성
        const filter = {};

        if (tag) {
            // MongoDB 배열 검색: tags 배열에 tag 값이 포함된 문서
            // SQL의 WHERE '공부' IN tags 와 동일한 동작
            filter.tags = tag;
        }

        // find() → sort() → 최신 순 정렬
        const memos = await Memo.find(filter).sort({ createdAt: -1 });

        res.json({
            success: true,
            count: memos.length,
            filter: tag ? `태그: ${tag}` : '전체',
            data: memos,
        });
    } catch (err) {
        res.status(500).json({ success: false, message: err.message });
    }
});

// ─────────────────────────────────────────
// GET /memos/:id — 단일 메모 조회
// ─────────────────────────────────────────
router.get('/:id', async (req, res) => {
    try {
        const memo = await Memo.findById(req.params.id);

        if (!memo) {
            return res.status(404).json({
                success: false,
                message: '메모를 찾을 수 없습니다.',
            });
        }

        res.json({ success: true, data: memo });
    } catch (err) {
        // ObjectId 형식 오류 (CastError)
        res.status(400).json({ success: false, message: '유효하지 않은 ID 형식입니다.' });
    }
});

// ─────────────────────────────────────────
// POST /memos — 메모 생성 (태그 배열 포함)
// Body: { title, content, tags?, isPublic? }
// 예: { title: "공부", content: "...", tags: ["공부", "개발"] }
// ─────────────────────────────────────────
router.post('/', async (req, res) => {
    try {
        const { title, content, tags = [], isPublic = false } = req.body;

        // 태그 배열 정제: 빈 문자열 제거, 앞뒤 공백 제거
        const cleanedTags = tags
            .map((tag) => tag.trim())        // 공백 제거
            .filter((tag) => tag.length > 0); // 빈 태그 제거

        // Memo.create(): 문서 생성 + 저장을 한 번에
        const memo = await Memo.create({
            title,
            content,
            tags: cleanedTags,
            isPublic,
        });

        res.status(201).json({
            success: true,
            message: '메모가 생성되었습니다.',
            data: memo,
        });
    } catch (err) {
        // required 필드 누락 시 ValidationError
        res.status(400).json({ success: false, message: err.message });
    }
});

// ─────────────────────────────────────────
// PUT /memos/:id — 메모 수정
// Body: { title?, content?, tags?, isPublic? }
// ─────────────────────────────────────────
router.put('/:id', async (req, res) => {
    try {
        const { title, content, tags, isPublic } = req.body;

        // 수정할 필드만 포함 (undefined이면 제외)
        const updateData = {};
        if (title     !== undefined) updateData.title     = title;
        if (content   !== undefined) updateData.content   = content;
        if (isPublic  !== undefined) updateData.isPublic  = isPublic;
        if (tags      !== undefined) {
            // 태그 배열 정제 후 업데이트
            updateData.tags = tags
                .map((tag) => tag.trim())
                .filter((tag) => tag.length > 0);
        }

        // findByIdAndUpdate(): 찾기 + 수정 한 번에
        // { new: true } → 수정 후 문서 반환
        // { runValidators: true } → 유효성 검사 실행
        const memo = await Memo.findByIdAndUpdate(
            req.params.id,
            updateData,
            { new: true, runValidators: true }
        );

        if (!memo) {
            return res.status(404).json({
                success: false,
                message: '메모를 찾을 수 없습니다.',
            });
        }

        res.json({
            success: true,
            message: '메모가 수정되었습니다.',
            data: memo,
        });
    } catch (err) {
        res.status(400).json({ success: false, message: err.message });
    }
});

// ─────────────────────────────────────────
// DELETE /memos/:id — 메모 삭제
// ─────────────────────────────────────────
router.delete('/:id', async (req, res) => {
    try {
        // findByIdAndDelete(): 찾기 + 삭제 한 번에
        const memo = await Memo.findByIdAndDelete(req.params.id);

        if (!memo) {
            return res.status(404).json({
                success: false,
                message: '메모를 찾을 수 없습니다.',
            });
        }

        res.json({
            success: true,
            message: `"${memo.title}" 메모가 삭제되었습니다.`,
        });
    } catch (err) {
        res.status(400).json({ success: false, message: '유효하지 않은 ID 형식입니다.' });
    }
});

module.exports = router;

Learn this lesson with a mentor

Work through the Build Your Homepage series on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now