MongoDB 입문: SQL과의 차이, CRUD, Mongoose, 집계 파이프라인, 인덱싱과 성능
이 글의 핵심
스키마가 유연하다는 장점은 임베드할지 참조할지를 개발자가 직접 결정해야 한다는 뜻이기도 합니다. 주문 문서에 상품 정보를 임베드하는 이유, 부분 인덱스를 만들어도 쿼리 조건이 맞지 않으면 쓰이지 않는 함정, 프로젝션과 커넥션 풀 설정 같은 성능 포인트를 짚어 실제 서비스에 적용할 기준을 제시합니다.
MongoDB란?
MongoDB는 문서 지향(Document-Oriented) NoSQL 데이터베이스로, JSON과 유사한 BSON(Binary JSON) 형식으로 데이터를 저장합니다. 2009년 첫 공개 이후 가장 널리 쓰이는 문서형 데이터베이스 중 하나가 되었습니다.
관계형 DB와 가장 크게 다른 점은 “데이터를 어떻게 나눌지”를 정하는 기준입니다. 관계형 DB는 중복을 없애는 정규화가 기본이고 필요할 때 JOIN으로 합칩니다. MongoDB는 함께 읽히는 데이터를 한 문서에 함께 저장하는 것이 기본이고, 한 문서 안의 변경은 여러 필드를 바꾸더라도 원자적으로 처리됩니다. 그래서 스키마 설계의 출발점이 “엔터티가 무엇인가”가 아니라 “화면이나 API가 무엇을 한 번에 읽는가”가 됩니다. 이 차이를 모르고 테이블 구조를 그대로 컬렉션으로 옮기면, $lookup을 남발하게 되어 관계형 DB의 장점도 MongoDB의 장점도 얻지 못하는 설계가 됩니다.
핵심 특징
-
문서 기반
- JSON 형태 저장
- 유연한 스키마
- 중첩 구조 지원
-
확장성
- 수평 확장 (샤딩)
- 레플리카셋
- 자동 페일오버
-
강력한 쿼리
- 풍부한 쿼리 연산자
- 집계 파이프라인
- 텍스트 검색
-
고성능
- 인덱싱
- WiredTiger 스토리지 엔진 (문서 단위 동시성 제어, 압축)
- 자주 쓰는 데이터와 인덱스를 메모리 캐시에 유지
SQL vs NoSQL 비교
| 항목 | SQL (MySQL) | NoSQL (MongoDB) |
|---|---|---|
| 데이터 모델 | 테이블 (행/열) | 문서 (JSON) |
| 스키마 | 고정 | 유연 |
| 확장 | 수직 (Scale-up) | 수평 (Scale-out) |
| JOIN | 강력 | 제한적 (Lookup) |
| 트랜잭션 | 완벽 지원 | 4.0부터 지원 |
| 사용 사례 | 금융, 재고 관리 | 소셜 미디어, IoT |
| 학습 곡선 | 중간 | 낮음 |
표의 “수직/수평 확장”과 “학습 곡선 낮음”은 단순화된 표현입니다. MySQL도 읽기 복제본과 샤딩 솔루션이 있고, MongoDB의 샤딩도 샤드 키를 잘못 고르면 한 샤드에 부하가 몰립니다. 학습 곡선도 CRUD를 시작하기까지가 쉬울 뿐, 임베드와 참조를 고르는 모델링과 인덱스 설계는 관계형 DB만큼 공부가 필요합니다. 스키마가 “유연하다”는 것도 DB가 구조를 강제하지 않는다는 뜻이지 구조가 필요 없다는 뜻은 아닙니다. 같은 필드가 어떤 문서에는 문자열, 어떤 문서에는 숫자로 섞여 들어가면 조회와 집계가 조용히 틀어지므로, Mongoose 스키마나 MongoDB의 $jsonSchema 검증으로 구조를 지키는 장치를 두는 것이 일반적입니다.
설치
MongoDB를 시작하는 방법은 여러 가지가 있습니다. 로컬 개발 환경에서는 Docker를 사용하는 것이 가장 간편하며, macOS와 Ubuntu에서는 패키지 매니저를 통해 설치할 수 있습니다.
MongoDB 설치
Docker를 사용하면 몇 초 만에 MongoDB를 시작할 수 있어 개발 환경 구축이 매우 빠릅니다. 별도의 설정 없이 기본 포트(27017)로 접속할 수 있으며, 컨테이너 삭제로 깔끔하게 정리할 수 있습니다.
macOS 사용자라면 Homebrew로 설치하는 것이 편리합니다. 설치 후 백그라운드 서비스로 실행되어 시스템 부팅 시 자동으로 시작됩니다. Ubuntu에서는 공식 APT 저장소를 추가한 후 설치할 수 있습니다.
# macOS (Homebrew)
brew tap mongodb/brew
brew install mongodb-community
brew services start mongodb-community
# Ubuntu (apt-key는 사용 중단 → keyring 파일 + signed-by 방식)
curl -fsSL https://www.mongodb.org/static/pgp/server-7.0.asc | sudo gpg -o /usr/share/keyrings/mongodb-server-7.0.gpg --dearmor
echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-7.0.gpg ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/7.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-7.0.list
sudo apt-get update
sudo apt-get install -y mongodb-org
sudo systemctl start mongod
# Docker
docker run -d -p 27017:27017 --name mongodb mongo:7
Docker 명령은 빠르게 시작하기 좋지만 두 가지를 알아 두어야 합니다. 볼륨을 지정하지 않았으므로 컨테이너를 지우면 데이터도 사라지고(-v mongo-data:/data/db를 붙이면 유지됩니다), 인증 없이 포트가 열려 있으므로 이 설정을 인터넷에 노출된 서버에서 그대로 쓰면 안 됩니다. 인증 없이 공개된 MongoDB가 스캔되어 데이터가 삭제되고 몸값을 요구받는 사고는 오래전부터 반복적으로 보고되어 왔습니다. 운영에서는 MONGO_INITDB_ROOT_USERNAME/PASSWORD로 계정을 만들고 방화벽으로 접근을 제한합니다. 또 뒤에서 다룰 트랜잭션은 단일 노드로 띄운 이 컨테이너에서는 동작하지 않으므로, 트랜잭션을 개발하려면 --replSet rs0 옵션으로 띄운 뒤 rs.initiate()로 단일 노드 복제 세트를 만들어야 합니다.
Node.js 드라이버 설치
Node.js에서 MongoDB를 사용하려면 공식 드라이버나 Mongoose ODM을 설치해야 합니다. 공식 드라이버는 저수준 API를 제공하며, Mongoose는 스키마 검증과 편리한 기능을 추가로 제공합니다. 실무에서는 Mongoose를 사용하는 것이 일반적입니다.
# MongoDB 드라이버
npm install mongodb
# Mongoose ODM (권장)
npm install mongoose
기본 CRUD
연결
// MongoDB 드라이버
import { MongoClient } from 'mongodb';
const client = new MongoClient('mongodb://localhost:27017');
await client.connect();
const db = client.db('myapp');
const users = db.collection('users');
// Mongoose
import mongoose from 'mongoose';
await mongoose.connect('mongodb://localhost:27017/myapp');
서버가 켜져 있는데도 MongoServerSelectionError: connect ECONNREFUSED ::1:27017이 난다면 주소가 원인입니다. Node.js 17부터 localhost가 IPv6 주소(::1)로 먼저 해석되는데 MongoDB 기본 설정은 IPv4 127.0.0.1에만 바인딩되기 때문입니다. 연결 문자열을 mongodb://127.0.0.1:27017로 바꾸면 해결됩니다. MongoClient는 내부에 커넥션 풀을 가지고 있으므로 앱 전체에서 하나만 만들어 재사용해야 합니다. 요청마다 new MongoClient()와 connect()를 호출하면 연결 수립 비용이 매번 들고, close()를 빠뜨리면 연결이 쌓여 서버의 연결 한도에 닿습니다.
Create (삽입)
// 단일 문서
const inserted = await users.insertOne({
name: '홍길동',
email: '[email protected]',
age: 25,
tags: ['developer', 'nodejs']
});
console.log(inserted.insertedId);
// 여러 문서
const insertedMany = await users.insertMany([
{ name: '김철수', email: '[email protected]' },
{ name: '이영희', email: '[email protected]' }
]);
console.log(insertedMany.insertedIds);
Read (조회)
// 모든 문서
const allUsers = await users.find().toArray();
// 조건 조회
const adults = await users.find({ age: { $gte: 20 } }).toArray();
// 단일 문서
const user = await users.findOne({ email: '[email protected]' });
// Projection (필드 선택)
const nameAndEmail = await users.find(
{ age: { $gte: 20 } },
{ projection: { name: 1, email: 1, _id: 0 } }
).toArray();
// 정렬
const sortedByAge = await users.find().sort({ age: -1 }).toArray();
// 페이지네이션
const page = 1;
const limit = 10;
const pagedUsers = await users.find()
.skip((page - 1) * limit)
.limit(limit)
.toArray();
(원래 예제처럼 결과를 const users = await users.find()로 받으면, 같은 스코프에서 컬렉션 변수 users를 가리게 되어 ReferenceError: Cannot access 'users' before initialization이 납니다. 결과 변수에는 다른 이름을 씁니다.)
find()는 결과 배열이 아니라 커서를 돌려준다는 점이 SQL 드라이버와 다릅니다. toArray()는 결과 전체를 메모리로 모으므로, 몇십만 건을 처리해야 한다면 for await (const doc of users.find(...))로 한 건씩 흘려보내는 편이 메모리를 아낍니다. skip을 이용한 페이지네이션은 앞의 문서를 모두 건너뛰며 읽어야 하므로 페이지 번호가 커질수록 느려집니다. 목록이 길다면 find({ _id: { $lt: lastSeenId } }).sort({ _id: -1 }).limit(10)처럼 마지막으로 본 값을 기준으로 가져오는 커서 방식이 인덱스를 그대로 탑니다.
Update (수정)
// 단일 문서
await users.updateOne(
{ email: '[email protected]' },
{ $set: { age: 26 } }
);
// 여러 문서
await users.updateMany(
{ age: { $lt: 20 } },
{ $set: { category: 'minor' } }
);
// Upsert (없으면 생성)
await users.updateOne(
{ email: '[email protected]' },
{ $set: { name: '신규' } },
{ upsert: true }
);
// 증가/감소
await users.updateOne(
{ _id: userId },
{ $inc: { views: 1 } }
);
// 배열 추가
await users.updateOne(
{ _id: userId },
{ $push: { tags: 'react' } }
);
업데이트에서 가장 위험한 실수는 $set 같은 연산자를 빼는 것입니다. 드라이버의 updateOne(filter, { age: 26 })은 연산자가 없으면 Update document requires atomic operators 오류로 거부되지만, replaceOne이나 Mongoose의 일부 API는 두 번째 인자를 문서 전체를 대체하는 값으로 해석할 수 있어 나머지 필드가 사라집니다. 업서트는 편리하지만, 필터 필드(email)에 유니크 인덱스가 없으면 동시에 들어온 두 요청이 모두 “없음”으로 판단해 같은 이메일 문서를 두 개 만들 수 있으므로 유니크 인덱스와 함께 써야 합니다. $push는 배열을 계속 키우는데, 문서 하나의 최대 크기는 16MB이고 큰 배열은 업데이트와 인덱스 비용도 키우므로, 로그나 댓글처럼 끝없이 늘어나는 목록은 별도 컬렉션으로 빼거나 $push에 $slice를 붙여 최근 N개만 유지합니다.
Delete (삭제)
// 단일 문서
await users.deleteOne({ email: '[email protected]' });
// 여러 문서
await users.deleteMany({ age: { $lt: 18 } });
Mongoose ODM
스키마 정의
import mongoose from 'mongoose';
const userSchema = new mongoose.Schema({
name: {
type: String,
required: true,
trim: true,
minlength: 2,
maxlength: 50
},
email: {
type: String,
required: true,
unique: true,
lowercase: true,
match: /^\S+@\S+\.\S+$/ // 형식만 대략 검사 (TLD 길이를 제한하면 .company 같은 주소가 거부됨)
},
age: {
type: Number,
min: 0,
max: 150
},
tags: [String],
profile: {
bio: String,
avatar: String,
social: {
twitter: String,
github: String
}
},
isActive: {
type: Boolean,
default: true
}
}, {
timestamps: true // createdAt, updatedAt 자동 생성 (별도 createdAt 필드 불필요)
});
// 인덱스 (email은 unique: true가 이미 인덱스를 만들므로 다시 선언하지 않음)
userSchema.index({ name: 'text' });
// 가상 필드 (DB에 저장되지 않고 읽을 때 계산)
userSchema.virtual('displayName').get(function() {
return `${this.name} <${this.email}>`;
});
// 메서드 (password 필드와 bcrypt import가 있다고 가정)
userSchema.methods.comparePassword = async function(password) {
return bcrypt.compare(password, this.password);
};
// 스태틱 메서드
userSchema.statics.findByEmail = function(email) {
return this.findOne({ email });
};
const User = mongoose.model('User', userSchema);
unique: true는 이름과 달리 Mongoose의 검증 규칙이 아니라 MongoDB에 유니크 인덱스를 만들라는 지시입니다. 그래서 중복 이메일을 저장하면 ValidationError가 아니라 E11000 duplicate key error collection: myapp.users index: email_1 dup key 오류(코드 11000)가 나고, 이미 중복 데이터가 들어 있는 컬렉션에서는 인덱스 생성 자체가 실패해 유니크 제약이 걸리지 않은 채로 운영될 수 있습니다. 같은 필드에 unique: true와 schema.index({ email: 1 })를 함께 쓰면 Duplicate schema index on {"email":1} found 경고가 나는 것도 이 때문입니다. 또 timestamps: true와 직접 정의한 createdAt을 함께 두면 역할이 겹치므로 한쪽만 씁니다.
Mongoose CRUD
// Create
const newUser = new User({
name: '홍길동',
email: '[email protected]'
});
await newUser.save();
// 또는
const created = await User.create({
name: '홍길동',
email: '[email protected]'
});
// Read
const adults = await User.find({ age: { $gte: 20 } });
const byId = await User.findById(userId);
const byEmail = await User.findOne({ email: '[email protected]' });
// Update
const updated = await User.findByIdAndUpdate(
userId,
{ age: 26 }, // Mongoose가 내부적으로 { $set: { age: 26 } }로 변환
{ new: true, runValidators: true } // 업데이트된 문서 반환 + 스키마 검증 적용
);
// Delete
await User.findByIdAndDelete(userId);
Mongoose의 find()는 기본적으로 결과를 Mongoose 문서 객체로 만들어 돌려줍니다. 변경 추적, 가상 필드, save() 같은 기능이 붙어 있어 편하지만 그만큼 메모리와 CPU를 씁니다. 읽기 전용 API 응답처럼 수정할 필요가 없는 조회에는 .lean()을 붙여 일반 자바스크립트 객체로 받으면 가볍습니다(대신 가상 필드와 메서드는 사라집니다). 업데이트에서 runValidators: true를 빼면 save() 때는 막히던 age: -5 같은 값이 업데이트 쿼리로는 그대로 저장된다는 점도 자주 놓치는 부분입니다.
집계 파이프라인
// 사용자 연령대별 통계
const stats = await users.aggregate([
// 필터링
{ $match: { isActive: true } },
// 그룹화
{
$group: {
_id: {
$floor: { $divide: ['$age', 10] }
},
count: { $sum: 1 },
avgAge: { $avg: '$age' }
}
},
// 정렬
{ $sort: { _id: 1 } },
// 프로젝션
{
$project: {
ageGroup: { $multiply: ['$_id', 10] },
count: 1,
avgAge: { $round: ['$avgAge', 1] }
}
}
]).toArray(); // 드라이버의 aggregate()는 커서를 반환
집계 파이프라인은 각 단계의 출력이 다음 단계의 입력이 되는 구조라, 단계 순서가 곧 성능입니다. $match를 맨 앞에 두면 인덱스를 써서 처리할 문서를 먼저 줄일 수 있지만, $group이나 $project 뒤로 가면 인덱스를 쓸 수 없고 모든 문서가 앞 단계를 거쳐야 합니다. 위 예제에서 age 필드가 없는 문서는 $divide가 null을 돌려주어 _id: null 그룹으로 따로 모인다는 점도 결과를 해석할 때 알아 두어야 합니다. 또 $group과 $sort 같은 단계는 기본적으로 단계당 100MB 메모리 제한이 있어서, 큰 데이터를 그룹화하면 Exceeded memory limit for $group 오류가 날 수 있습니다. 최근 버전은 필요할 때 디스크를 쓰도록 allowDiskUse가 기본으로 켜져 있지만, 그만큼 느려지므로 $match로 먼저 범위를 좁히는 것이 우선입니다.
인덱싱
인덱스는 쿼리 성능을 크게 향상시킵니다. 하지만 인덱스를 너무 많이 만들면 쓰기 성능이 저하되고 저장 공간이 증가하므로 신중하게 선택해야 합니다.
가장 자주 조회하는 필드에 인덱스를 생성하세요. 예를 들어 이메일로 사용자를 찾는 경우가 많다면 email 필드에 인덱스를 만들어야 합니다. 복합 인덱스는 여러 필드를 조합해 조회할 때 유용합니다.
// 단일 필드 인덱스
await users.createIndex({ email: 1 });
// 복합 인덱스
await users.createIndex({ lastName: 1, firstName: 1 });
// 텍스트 인덱스
await users.createIndex({ bio: 'text', tags: 'text' });
// 지리 공간 인덱스
await locations.createIndex({ location: '2dsphere' });
// 인덱스 확인
const indexes = await users.indexes();
텍스트 인덱스는 전문 검색(full-text search)에 사용되며, 여러 필드를 하나의 텍스트 인덱스로 묶을 수 있습니다. 지리 공간 인덱스는 위치 기반 쿼리에 필수입니다.
목적별 인덱스: 부분·텍스트 인덱스
모든 문서가 아니라 일부만 자주 조회한다면 부분 인덱스가 인덱스 크기와 쓰기 비용을 함께 줄여 줍니다. 예를 들어 활성 사용자만 이메일로 찾는다면 partialFilterExpression으로 조건에 맞는 문서만 인덱싱합니다. 단, 쿼리 조건이 인덱스의 필터 조건을 포함해야 옵티마이저가 이 인덱스를 고릅니다. 제목·본문 검색에는 텍스트 인덱스와 $text를 쓸 수 있는데, 컬렉션당 하나만 만들 수 있고 형태소 분석이 영어 중심이라 한국어 검색 품질은 제한적입니다. 검색이 핵심 기능이라면 Atlas Search나 별도 검색 엔진을 고려하는 편이 낫습니다.
db.users.createIndex({ email: 1 }, { partialFilterExpression: { isActive: true } });
db.users.find({ email: '[email protected]', isActive: true }); // 필터 조건이 있어야 사용됨
db.articles.createIndex({ title: 'text', content: 'text' });
db.articles.find({ $text: { $search: 'mongodb index' } });
복제 세트, 샤딩, 트랜잭션
복제 세트(replica set) 는 운영 환경의 기본 단위입니다. 보통 3개 노드로 구성해 Primary 하나가 쓰기를 받고 Secondary들이 oplog를 따라 복제하며, Primary가 죽으면 남은 노드들이 투표로 새 Primary를 뽑습니다. 읽기를 Secondary로 돌리는 readPreference: 'secondaryPreferred'는 부하를 나눠 주지만, 복제 지연 때문에 방금 쓴 데이터가 안 보일 수 있다는 점을 감수해야 합니다. 쓰기 직후 다시 읽어야 하는 흐름은 Primary에서 읽어야 합니다.
샤딩은 데이터가 한 복제 세트에 담기지 않을 만큼 커졌을 때 여러 샤드로 나누는 방법이고, 성패는 샤드 키에 달려 있습니다. createdAt처럼 계속 증가하는 값을 범위 샤드 키로 쓰면 새 데이터가 전부 마지막 샤드로 몰려 한 샤드만 뜨거워집니다. 고르게 퍼뜨리려면 { userId: 'hashed' }처럼 해시 샤드 키를 쓰거나, 자주 쓰는 조회 조건을 앞에 둔 복합 키를 설계합니다. 샤드 키는 나중에 바꾸기 어렵기 때문에 쿼리 패턴을 먼저 정리한 뒤 정해야 합니다.
다중 문서 트랜잭션은 4.0부터 복제 세트에서 지원됩니다. 계좌 이체처럼 두 문서를 함께 바꿔야 할 때 session.withTransaction() 안에서 처리하면 일시적인 오류 시 자동 재시도까지 해 줍니다. 다만 트랜잭션은 잠금과 성능 비용이 있으므로, 함께 바뀌는 데이터를 한 문서에 임베딩해 단일 문서 원자성으로 해결할 수 있다면 그쪽이 MongoDB다운 설계입니다.
const session = client.startSession();
try {
await session.withTransaction(async () => {
await accounts.updateOne({ _id: from }, { $inc: { balance: -100 } }, { session });
await accounts.updateOne({ _id: to }, { $inc: { balance: 100 } }, { session });
});
} finally {
await session.endSession();
}
실전 사례: 전자상거래 시스템
MongoDB의 유연한 스키마는 전자상거래 시스템처럼 다양한 종류의 상품을 다룰 때 특히 유용합니다. 의류는 사이즈와 색상 정보가 필요하며, 전자제품은 스펙 정보가 필요한데 이를 하나의 컬렉션에 자연스럽게 저장할 수 있습니다.
임베디드 문서로 주문 시스템 설계
주문(Order)과 주문 항목(OrderItem)을 별도 컬렉션으로 분리하는 대신 임베디드 문서로 저장하면 한 번의 쿼리로 모든 정보를 가져올 수 있습니다. 주문 항목은 주문 없이 따로 조회될 일이 거의 없고 개수도 한정되어 있어 임베드하기에 좋은 대상입니다.
// 주문 생성
const order = await orders.insertOne({
userId: new ObjectId(userId),
orderNumber: 'ORD-2026-0001',
status: 'pending',
items: [
{
productId: new ObjectId(productId1),
productName: '노트북',
quantity: 1,
price: 1500000,
options: {
color: 'silver',
storage: '512GB'
}
},
{
productId: new ObjectId(productId2),
productName: '마우스',
quantity: 2,
price: 30000
}
],
totalAmount: 1560000,
shippingAddress: {
name: '홍길동',
phone: '010-1234-5678',
address: '서울시 강남구',
zipCode: '12345'
},
payment: {
method: 'card',
cardNumber: '1234-****-****-5678',
paidAt: new Date()
},
createdAt: new Date(),
updatedAt: new Date()
});
// 주문 조회 (한 번의 쿼리로 모든 정보 획득)
const order = await orders.findOne({
orderNumber: 'ORD-2026-0001'
});
이렇게 설계하면 주문 상세 페이지를 렌더링할 때 하나의 쿼리만 필요합니다. 관계형 DB라면 orders, order_items, products, shipping_addresses 테이블을 JOIN해야 합니다.
여기서 productName과 price를 상품 컬렉션을 참조하지 않고 주문 안에 복사해 둔 것은 의도된 비정규화입니다. 상품 가격이 나중에 바뀌어도 주문 당시의 가격이 남아 있어야 하기 때문에, 이 경우 중복은 결함이 아니라 요구사항입니다. 반대로 사용자 이름이나 프로필 이미지처럼 “항상 최신 값이 보여야 하는” 데이터를 여러 문서에 복사하면, 값이 바뀔 때마다 모든 복사본을 갱신해야 하는 부담이 생깁니다. 임베드할지 참조할지는 “이 데이터가 함께 읽히는가”, “복사본이 옛 값으로 남아도 되는가”, “배열이 끝없이 커지는가” 세 가지로 판단하는 것이 실용적입니다. 문서 하나의 크기 한도가 16MB라는 점도 세 번째 질문이 중요한 이유입니다.
집계 파이프라인으로 매출 분석
MongoDB의 집계 파이프라인은 복잡한 데이터 분석을 쉽게 만듭니다. 일별 매출, 인기 상품, 사용자별 구매 패턴 등을 효율적으로 계산할 수 있습니다.
// 월별 매출 통계
const monthlySales = await orders.aggregate([
{
$match: {
status: 'completed',
createdAt: {
$gte: new Date('2026-01-01'),
$lt: new Date('2027-01-01')
}
}
},
{
$group: {
_id: {
year: { $year: '$createdAt' },
month: { $month: '$createdAt' }
},
totalSales: { $sum: '$totalAmount' },
orderCount: { $sum: 1 },
avgOrderValue: { $avg: '$totalAmount' }
}
},
{
$sort: { '_id.year': 1, '_id.month': 1 }
},
{
$project: {
_id: 0,
period: {
$concat: [
{ $toString: '$_id.year' },
'-',
{ $toString: '$_id.month' }
]
},
totalSales: { $round: ['$totalSales', 0] },
orderCount: 1,
avgOrderValue: { $round: ['$avgOrderValue', 0] }
}
}
]).toArray();
이 파이프라인은 날짜 필터링, 그룹화, 집계, 정렬, 포맷팅을 한 번에 수행합니다. SQL로 치면 WHERE·GROUP BY·ORDER BY를 가진 쿼리 하나와 비슷하며, 단계를 나눠 쓰기 때문에 중간 결과를 하나씩 확인하며 조립하기 쉽다는 것이 장점입니다.
이 예제에는 한국 서비스에서 실제로 문제가 되는 함정이 두 가지 있습니다. 첫째, $year와 $month는 UTC 기준으로 계산됩니다. 한국 시간 3월 1일 오전 8시에 들어온 주문은 UTC로 2월 28일 23시이므로 2월 매출로 잡히고, new Date('2026-01-01')도 UTC 자정이라 한국 시간으로는 오전 9시부터가 됩니다. { $month: { date: '$createdAt', timezone: 'Asia/Seoul' } }처럼 시간대를 명시하고, $match의 경계도 new Date('2026-01-01T00:00:00+09:00')로 만들어야 회계 기준과 숫자가 맞습니다. 월별 매출이 결제 시스템 정산과 월 경계 부근에서만 어긋난다면 가장 먼저 의심할 것이 이 시간대 차이입니다. 에러 없이 숫자만 틀리기 때문에 찾기 어려운 버그입니다. 둘째, $concat으로 만든 period는 2026-3처럼 월이 한 자리로 나와 문자열 정렬이 깨지므로, $dateToString: { format: '%Y-%m', date: '$createdAt', timezone: 'Asia/Seoul' }을 쓰면 2026-03 형식으로 시간대까지 한 번에 처리할 수 있습니다.
재고 관리와 동시성 제어
여러 사용자가 동시에 같은 상품을 주문할 때 재고가 음수가 되는 문제를 방지해야 합니다. MongoDB의 원자적 연산을 사용하면 안전하게 처리할 수 있습니다.
// 재고 감소 (동시성 안전)
async function decreaseStock(productId, quantity) {
const result = await products.updateOne(
{
_id: new ObjectId(productId),
stock: { $gte: quantity } // 재고가 충분한 경우만 업데이트
},
{
$inc: { stock: -quantity }
}
);
if (result.modifiedCount === 0) {
throw new Error('재고가 부족합니다');
}
return result;
}
이 패턴은 race condition 없이 재고를 안전하게 관리합니다. 조건을 만족하는 경우에만 업데이트가 실행되어 재고가 음수가 되지 않습니다.
안전한 이유는 “재고를 읽고 → 코드에서 비교하고 → 다시 쓰는” 세 단계를 조건부 업데이트 한 번으로 합쳤기 때문입니다. 단일 문서에 대한 updateOne은 원자적이므로, 두 요청이 동시에 들어와도 한쪽이 재고를 줄인 뒤에야 다른 쪽의 조건(stock >= quantity)이 평가됩니다. 반대로 findOne으로 재고를 읽어 자바스크립트에서 if (product.stock >= quantity)로 검사한 뒤 updateOne으로 줄이는 코드는, 두 요청이 같은 재고 값을 읽고 둘 다 통과하는 전형적인 경쟁 조건이 됩니다. 다만 이 방법이 보장하는 것은 상품 하나의 원자성입니다. 장바구니에 여러 상품이 있어 전부 성공하거나 전부 실패해야 한다면, 앞의 트랜잭션 안에서 각 상품의 조건부 업데이트를 실행하고 하나라도 modifiedCount가 0이면 예외를 던져 롤백해야 합니다.
성능 최적화 팁
인덱스 전략
쿼리에서 자주 사용하는 필드에 인덱스를 만들되, 쓰기가 많은 컬렉션에는 최소한의 인덱스만 유지하세요. explain() 메서드로 쿼리 성능을 분석할 수 있습니다.
explain('executionStats') 결과에서는 winningPlan이 IXSCAN인지 COLLSCAN인지, 그리고 totalDocsExamined가 반환한 문서 수(nReturned)보다 훨씬 크지 않은지를 먼저 봅니다. 인덱스가 있어도 { email: { $regex: /.*@example\.com/ } }처럼 앞부분이 와일드카드인 정규식은 인덱스를 타지 못하고 전체를 훑습니다. 조회하는 필드와 조건이 모두 한 인덱스 안에 들어 있고 _id를 프로젝션에서 빼면, 문서를 읽지 않고 인덱스만으로 답하는 커버드 쿼리가 되어 가장 빠릅니다(totalDocsExamined: 0).
await users.createIndex({ email: 1, name: 1 });
await users.find({ email: '[email protected]' }, { projection: { email: 1, name: 1, _id: 0 } })
.explain('executionStats'); // IXSCAN, totalDocsExamined: 0 이면 커버드 쿼리
프로젝션 활용
필요한 필드만 조회하면 네트워크 전송량과 메모리 사용량을 줄일 수 있습니다. 특히 큰 문서나 배열 필드가 있는 경우 효과가 큽니다.
커넥션 풀 설정
Node.js 앱에서는 MongoClient를 싱글톤으로 관리하세요. 요청마다 새로운 연결을 만들면 성능이 크게 저하됩니다.
정리하면 MongoDB의 성능과 정확성은 대부분 문서 모델링에서 결정됩니다. 함께 읽히는 데이터를 한 문서에 두면 쿼리 하나로 끝나고 단일 문서 원자성으로 동시성 문제도 단순해지지만, 끝없이 커지는 배열이나 여러 곳에 복사된 “최신이어야 하는 값”은 나중에 비용으로 돌아옵니다. 여러 컬렉션을 자주 조인해야 하고 여러 문서를 함께 바꾸는 트랜잭션이 일상적이라면, 그 자체로 관계형 DB가 더 맞는 문제일 수 있다는 점도 함께 고려하세요.