Mongoose Complete Guide | MongoDB ODM for Node.js

Mongoose Complete Guide | MongoDB ODM for Node.js

이 글의 핵심

Mongoose is a MongoDB object modeling tool for Node.js. It provides schema-based validation, middleware, queries, and a elegant API for working with MongoDB.

Introduction

Mongoose is an Object Data Modeling (ODM) library for MongoDB and Node.js. It provides a schema-based solution to model your data with built-in validation, queries, and business logic.

Why Mongoose?

Native MongoDB driver:

const db = client.db('myapp');
const users = db.collection('users');

// No schema, no validation
await users.insertOne({ name: 'Alice', age: 'invalid' }); // Inserts anything!

With Mongoose:

const userSchema = new mongoose.Schema({
  name: { type: String, required: true },
  age: { type: Number, min: 0 },
});

const User = mongoose.model('User', userSchema);

await User.create({ name: 'Alice', age: 'invalid' }); // Validation error!

1. Installation

npm install mongoose

2. Connection

const mongoose = require('mongoose');

// Connect to MongoDB
await mongoose.connect('mongodb://localhost:27017/myapp');

// Or with options
await mongoose.connect(process.env.MONGODB_URI, {
  useNewUrlParser: true,
  useUnifiedTopology: true,
});

// Handle connection events
mongoose.connection.on('connected', () => {
  console.log('MongoDB connected');
});

mongoose.connection.on('error', (err) => {
  console.error('MongoDB error:', err);
});

mongoose.connection.on('disconnected', () => {
  console.log('MongoDB disconnected');
});

3. Schema Definition

const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
  // String
  name: {
    type: String,
    required: [true, 'Name is required'],
    trim: true,
    minlength: 2,
    maxlength: 50,
  },
  
  // Email with validation
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
    match: [/^\S+@\S+\.\S+$/, 'Invalid email format'],
  },
  
  // Number
  age: {
    type: Number,
    min: [0, 'Age must be positive'],
    max: 120,
  },
  
  // Enum
  role: {
    type: String,
    enum: ['user', 'admin', 'moderator'],
    default: 'user',
  },
  
  // Boolean
  isActive: {
    type: Boolean,
    default: true,
  },
  
  // Date
  createdAt: {
    type: Date,
    default: Date.now,
  },
  
  // Array
  tags: [String],
  
  // Nested object
  address: {
    street: String,
    city: String,
    zipCode: String,
  },
  
  // Reference to another model
  posts: [{
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Post',
  }],
});

const User = mongoose.model('User', userSchema);

4. CRUD Operations

Create

// Method 1: create()
const user = await User.create({
  name: 'Alice',
  email: '[email protected]',
  age: 30,
});

// Method 2: new + save()
const user = new User({
  name: 'Bob',
  email: '[email protected]',
});
await user.save();

// Create multiple
await User.insertMany([
  { name: 'Charlie', email: '[email protected]' },
  { name: 'David', email: '[email protected]' },
]);

Read

// Find all
const users = await User.find();

// Find with filter
const activeUsers = await User.find({ isActive: true });

// Find one
const user = await User.findOne({ email: '[email protected]' });

// Find by ID
const user = await User.findById('507f1f77bcf86cd799439011');

// With projection (select fields)
const users = await User.find().select('name email');

// With sorting
const users = await User.find().sort({ createdAt: -1 });

// With limit and skip (pagination)
const users = await User.find()
  .limit(10)
  .skip(20)
  .sort({ createdAt: -1 });

// Count
const count = await User.countDocuments({ isActive: true });

Update

// Update one
await User.updateOne(
  { email: '[email protected]' },
  { $set: { age: 31 } }
);

// Update many
await User.updateMany(
  { isActive: false },
  { $set: { isActive: true } }
);

// Find and update (returns updated document)
const user = await User.findOneAndUpdate(
  { email: '[email protected]' },
  { $set: { age: 31 } },
  { new: true } // Return updated document
);

// Update by ID
const user = await User.findByIdAndUpdate(
  '507f1f77bcf86cd799439011',
  { $set: { age: 31 } },
  { new: true }
);

Delete

// Delete one
await User.deleteOne({ email: '[email protected]' });

// Delete many
await User.deleteMany({ isActive: false });

// Find and delete (returns deleted document)
const user = await User.findOneAndDelete({ email: '[email protected]' });

// Delete by ID
await User.findByIdAndDelete('507f1f77bcf86cd799439011');

5. Query Operators

// Comparison
User.find({ age: { $gt: 18 } });           // Greater than
User.find({ age: { $gte: 18 } });          // Greater than or equal
User.find({ age: { $lt: 65 } });           // Less than
User.find({ age: { $lte: 65 } });          // Less than or equal
User.find({ age: { $ne: 30 } });           // Not equal

// Logical
User.find({
  $and: [
    { age: { $gte: 18 } },
    { age: { $lte: 65 } }
  ]
});

User.find({
  $or: [
    { role: 'admin' },
    { role: 'moderator' }
  ]
});

// In/Not in
User.find({ role: { $in: ['admin', 'moderator'] } });
User.find({ role: { $nin: ['banned', 'suspended'] } });

// Exists
User.find({ email: { $exists: true } });

// Regex
User.find({ name: { $regex: /^A/i } }); // Names starting with A

6. Middleware (Hooks)

const bcrypt = require('bcrypt');

const userSchema = new mongoose.Schema({
  email: String,
  password: String,
});

// Pre-save hook
userSchema.pre('save', async function(next) {
  // Only hash if password is modified
  if (!this.isModified('password')) return next();
  
  // Hash password
  this.password = await bcrypt.hash(this.password, 10);
  next();
});

// Post-save hook
userSchema.post('save', function(doc) {
  console.log('User saved:', doc._id);
});

// Pre-remove hook
userSchema.pre('remove', async function(next) {
  // Delete user's posts
  await Post.deleteMany({ author: this._id });
  next();
});

const User = mongoose.model('User', userSchema);

// Usage
const user = new User({ email: '[email protected]', password: 'password123' });
await user.save(); // Password automatically hashed

7. Instance Methods

const userSchema = new mongoose.Schema({
  email: String,
  password: String,
});

// Add instance method
userSchema.methods.verifyPassword = async function(password) {
  return await bcrypt.compare(password, this.password);
};

userSchema.methods.toPublicJSON = function() {
  return {
    id: this._id,
    email: this.email,
    // Don't include password
  };
};

const User = mongoose.model('User', userSchema);

// Usage
const user = await User.findOne({ email: '[email protected]' });
const isValid = await user.verifyPassword('password123');
const publicData = user.toPublicJSON();

8. Static Methods

// Add static method
userSchema.statics.findByEmail = function(email) {
  return this.findOne({ email: email.toLowerCase() });
};

userSchema.statics.findActiveUsers = function() {
  return this.find({ isActive: true });
};

const User = mongoose.model('User', userSchema);

// Usage
const user = await User.findByEmail('[email protected]');
const activeUsers = await User.findActiveUsers();

9. Virtual Fields

const userSchema = new mongoose.Schema({
  firstName: String,
  lastName: String,
});

// Virtual property (not stored in DB)
userSchema.virtual('fullName')
  .get(function() {
    return `${this.firstName} ${this.lastName}`;
  })
  .set(function(name) {
    const parts = name.split(' ');
    this.firstName = parts[0];
    this.lastName = parts[1];
  });

// Enable virtuals in JSON
userSchema.set('toJSON', { virtuals: true });

const User = mongoose.model('User', userSchema);

// Usage
const user = new User({ firstName: 'Alice', lastName: 'Smith' });
console.log(user.fullName); // "Alice Smith"

user.fullName = 'Bob Johnson';
console.log(user.firstName); // "Bob"
console.log(user.lastName); // "Johnson"

10. Population (Relations)

const postSchema = new mongoose.Schema({
  title: String,
  content: String,
  author: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
  },
});

const Post = mongoose.model('Post', postSchema);

// Create post
const post = await Post.create({
  title: 'My First Post',
  content: 'Hello world',
  author: user._id, // User's ObjectId
});

// Populate author
const postWithAuthor = await Post.findById(post._id).populate('author');
console.log(postWithAuthor.author.name); // "Alice"

// Populate specific fields
const post = await Post.findById(postId)
  .populate('author', 'name email');

// Nested populate
const post = await Post.findById(postId)
  .populate({
    path: 'author',
    populate: { path: 'company' }
  });

11. Validation

const productSchema = new mongoose.Schema({
  name: {
    type: String,
    required: [true, 'Product name is required'],
    minlength: [3, 'Name must be at least 3 characters'],
  },
  price: {
    type: Number,
    required: true,
    min: [0, 'Price must be positive'],
    validate: {
      validator: function(v) {
        return v > 0;
      },
      message: 'Price must be greater than 0',
    },
  },
  category: {
    type: String,
    enum: {
      values: ['electronics', 'clothing', 'food'],
      message: '{VALUE} is not a valid category',
    },
  },
  email: {
    type: String,
    validate: {
      validator: function(v) {
        return /^\S+@\S+\.\S+$/.test(v);
      },
      message: props => `${props.value} is not a valid email`,
    },
  },
});

const Product = mongoose.model('Product', productSchema);

// Validation happens on save
try {
  await Product.create({ name: 'AB', price: -10 });
} catch (error) {
  console.error(error.errors);
  // ValidationError: name: Name must be at least 3 characters
  // ValidationError: price: Price must be positive
}

12. TypeScript Integration

import mongoose, { Document, Schema } from 'mongoose';

interface IUser extends Document {
  name: string;
  email: string;
  age: number;
  createdAt: Date;
  verifyPassword(password: string): Promise<boolean>;
}

const userSchema = new Schema<IUser>({
  name: { type: String, required: true },
  email: { type: String, required: true, unique: true },
  age: { type: Number, min: 0 },
  createdAt: { type: Date, default: Date.now },
});

userSchema.methods.verifyPassword = async function(password: string) {
  return await bcrypt.compare(password, this.password);
};

const User = mongoose.model<IUser>('User', userSchema);

// Usage with full type safety
const user: IUser = await User.create({
  name: 'Alice',
  email: '[email protected]',
  age: 30,
});

console.log(user.name); // TypeScript knows it's a string
const isValid = await user.verifyPassword('password123'); // TypeScript knows the method

13. Real-World Example: Blog API

const mongoose = require('mongoose');

// User schema
const userSchema = new mongoose.Schema({
  username: { type: String, required: true, unique: true },
  email: { type: String, required: true, unique: true },
  password: { type: String, required: true },
  role: { type: String, enum: ['user', 'admin'], default: 'user' },
  createdAt: { type: Date, default: Date.now },
});

userSchema.methods.verifyPassword = async function(password) {
  return await bcrypt.compare(password, this.password);
};

const User = mongoose.model('User', userSchema);

// Post schema
const postSchema = new mongoose.Schema({
  title: { type: String, required: true },
  slug: { type: String, required: true, unique: true },
  content: { type: String, required: true },
  excerpt: { type: String, maxlength: 300 },
  author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
  tags: [String],
  published: { type: Boolean, default: false },
  views: { type: Number, default: 0 },
  createdAt: { type: Date, default: Date.now },
  updatedAt: { type: Date, default: Date.now },
});

// Auto-update updatedAt
postSchema.pre('save', function(next) {
  this.updatedAt = Date.now();
  next();
});

// Generate slug from title
postSchema.pre('save', function(next) {
  if (this.isModified('title')) {
    this.slug = this.title.toLowerCase().replace(/\s+/g, '-');
  }
  next();
});

const Post = mongoose.model('Post', postSchema);

// Comment schema
const commentSchema = new mongoose.Schema({
  post: { type: mongoose.Schema.Types.ObjectId, ref: 'Post', required: true },
  author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
  content: { type: String, required: true, maxlength: 1000 },
  createdAt: { type: Date, default: Date.now },
});

const Comment = mongoose.model('Comment', commentSchema);

14. Express API with Mongoose

const express = require('express');
const mongoose = require('mongoose');

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

// Connect to MongoDB
await mongoose.connect(process.env.MONGODB_URI);

// Get all posts
app.get('/api/posts', async (req, res) => {
  try {
    const { page = 1, limit = 10 } = req.query;
    
    const posts = await Post.find({ published: true })
      .populate('author', 'username')
      .sort({ createdAt: -1 })
      .limit(limit)
      .skip((page - 1) * limit);
    
    const total = await Post.countDocuments({ published: true });
    
    res.json({
      posts,
      page: parseInt(page),
      totalPages: Math.ceil(total / limit),
      total,
    });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Get single post
app.get('/api/posts/:slug', async (req, res) => {
  try {
    const post = await Post.findOne({ slug: req.params.slug })
      .populate('author', 'username email');
    
    if (!post) {
      return res.status(404).json({ error: 'Post not found' });
    }
    
    // Increment views
    post.views += 1;
    await post.save();
    
    res.json({ post });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Create post
app.post('/api/posts', authenticate, async (req, res) => {
  try {
    const post = await Post.create({
      ...req.body,
      author: req.user.id,
    });
    
    res.status(201).json({ post });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

// Update post
app.put('/api/posts/:id', authenticate, async (req, res) => {
  try {
    const post = await Post.findById(req.params.id);
    
    if (!post) {
      return res.status(404).json({ error: 'Post not found' });
    }
    
    // Check ownership
    if (post.author.toString() !== req.user.id) {
      return res.status(403).json({ error: 'Unauthorized' });
    }
    
    Object.assign(post, req.body);
    await post.save();
    
    res.json({ post });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

// Delete post
app.delete('/api/posts/:id', authenticate, async (req, res) => {
  try {
    const post = await Post.findById(req.params.id);
    
    if (!post) {
      return res.status(404).json({ error: 'Post not found' });
    }
    
    if (post.author.toString() !== req.user.id) {
      return res.status(403).json({ error: 'Unauthorized' });
    }
    
    await post.remove();
    
    res.json({ message: 'Post deleted' });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

app.listen(3000);

15. Indexes

const userSchema = new mongoose.Schema({
  email: { type: String, required: true, unique: true },
  username: { type: String, required: true },
  createdAt: { type: Date, default: Date.now },
});

// Single field index
userSchema.index({ email: 1 });

// Compound index
userSchema.index({ username: 1, createdAt: -1 });

// Text index for search
postSchema.index({ title: 'text', content: 'text' });

// Usage
const posts = await Post.find({ $text: { $search: 'mongodb tutorial' } });

16. Best Practices

1. Use Lean for Read-Only Queries

// Regular query (returns Mongoose document)
const user = await User.findById(id);
user.save(); // Has methods

// Lean query (returns plain JS object - faster)
const user = await User.findById(id).lean();
// user.save() is undefined - no methods

2. Select Only Needed Fields

// Bad: fetch everything
const users = await User.find();

// Good: select specific fields
const users = await User.find().select('name email');

3. Use Pagination

async function getUsers(page = 1, limit = 10) {
  const users = await User.find()
    .limit(limit)
    .skip((page - 1) * limit)
    .sort({ createdAt: -1 });
  
  const total = await User.countDocuments();
  
  return {
    users,
    page,
    totalPages: Math.ceil(total / limit),
  };
}

Summary

Mongoose provides powerful MongoDB abstraction:

  • Schema validation for data integrity
  • Middleware for business logic
  • Relationships via population
  • TypeScript support
  • Production-ready and battle-tested

Key Takeaways:

  1. Define schemas for validation
  2. Use middleware for hooks
  3. Populate for relationships
  4. Lean queries for performance
  5. Index frequently queried fields

Next Steps:

  • Learn MongoDB
  • Compare Prisma
  • Build Express API

Resources: