JSON 형태의 데이터를 그대로 저장하고 싶을 때 Document DB가 매력적으로 보입니다. 하지만 유연한 스키마는 검증 책임이 애플리케이션으로 이동한다는 뜻이기도 합니다. 문서형 DB의 장단점을 알아야 빠른 개발과 데이터 품질 사이에서 균형을 잡을 수 있습니다.
MongoDB는 RDBMS의 테이블 대신 컬렉션(Collection)에 JSON 유사 문서(Document)를 저장합니다. 핵심 설계 결정은 임베딩(데이터를 문서 내에 포함)과 참조(ID를 저장하고 별도 컬렉션 조회) 중 선택입니다. 접근 패턴을 먼저 정의하고 거기에 맞게 스키마를 설계하는 것이 MongoDB 방식입니다.
- 1MongoDB 핵심 개념 — Collection, Document, BSON
- 2스키마리스의 진짜 의미 — 유연성과 책임
- 3임베딩 vs 참조 — 선택 기준과 트레이드오프
- 4기본 CRUD 연산과 쿼리 연산자
- 5Aggregation Pipeline — $match, $group, $lookup
- 6언제 MongoDB를 선택하고 언제 피해야 하는가
Document DB (MongoDB) — 임베딩 vs 참조, 언제 선택하는가
쇼핑몰 상품 데이터를 PostgreSQL로 설계하다가 막혔다. 상품마다 속성이 달랐다 — 티셔츠는 색상·사이즈, 노트북은 CPU·RAM·SSD, 식품은 유통기한·알레르기. 테이블 하나로 못 담아서 결국 attributes 컬럼에 JSON 문자열을 때려박기 시작했다. 조회할 때마다 파싱하고, 인덱스도 못 걸고, 스키마 변경할 때마다 마이그레이션이 고통이었다. 그때 MongoDB를 써보고 처음으로 "이게 맞는 도구구나"라는 감각이 왔다. 문서마다 다른 구조를 자연스럽게 담고, 중첩 객체로 관련 데이터를 한 덩어리로 묶으니 상품 조회가 JOIN 없이 한 번에 끝났다. RDBMS가 나쁜 게 아니라 — 데이터 구조가 유연하고 함께 읽히는 데이터가 명확할 때는 Document DB가 훨씬 적합한 선택이다.
실행 결과를 확인할 수 있는 DB 콘솔 출력이 표시됩니다.
- 문서 구조—함께 읽는 데이터가 한 문서에 자연스럽게 묶였는지 확인합니다.
- 중복 허용—중복 저장한 필드의 동기화 책임이 명확한지 봅니다.
- 쿼리 패턴—배열·중첩 필드 조회가 실제 API 요구와 맞는지 점검합니다.
MongoDB 문서 모델 — RDBMS와 다른 설계 철학
상품 정보를 저장해야 하는데 상품마다 속성 개수가 다릅니다. 가전제품은 전압·용량이 필요하고, 의류는 사이즈·소재가 필요합니다. RDBMS에서는 공통 컬럼 외 나머지를 어떻게 저장할지 설계가 복잡해집니다. MongoDB는 문서마다 구조가 달라도 되는 유연한 모델을 제공합니다. 단, RDBMS와 설계 철학이 근본적으로 다르기 때문에 관계형 사고로 MongoDB를 쓰면 성능 문제가 생깁니다.

RDBMS vs MongoDB 용어 비교
MongoDB는 RDBMS와 개념이 대응되지만 이름이 다릅니다. 가장 중요한 차이는 JOIN 대신 임베딩을 기본으로 사용하고, 스키마를 DB가 아닌 애플리케이션이 관리한다는 점입니다.
| RDBMS | MongoDB | 설명 |
|---|---|---|
| Database | Database | 동일 |
| Table | Collection | 행/문서의 집합 |
| Row | Document | 하나의 데이터 단위 |
| Column | Field | 데이터 속성 |
| Primary Key | _id | 자동 생성되는 ObjectId |
| JOIN | $lookup (또는 임베딩) | 관련 데이터 결합 |
| Index | Index | 동일한 목적 |
| View | View | 동일 (MongoDB 3.4+) |
BSON 문서 구조
MongoDB는 JSON의 이진 인코딩인 BSON(Binary JSON) 형식으로 데이터를 저장합니다. ObjectId는 12바이트 자동 생성 ID로, 생성 시각·머신 ID·프로세스 ID·카운터로 구성되어 분산 환경에서도 충돌이 없습니다. 배열, 중첩 문서, 날짜 타입 등을 네이티브로 지원합니다.
{
"_id": ObjectId("65a1b2c3d4e5f67890123456"),
"title": "MongoDB 설계 패턴",
"slug": "mongodb-design-patterns",
"author": {
"userId": ObjectId("..."),
"name": "김개발",
"avatar": "https://cdn.example.com/avatars/1.jpg"
},
"tags": ["mongodb", "database", "nosql"],
"status": "published",
"viewCount": 1523,
"publishedAt": ISODate("2024-03-15T09:00:00Z"),
"metadata": {
"readTime": 8,
"wordCount": 2400
}
}
스키마리스의 진짜 의미
MongoDB는 컬렉션에 문서를 추가할 때 스키마를 사전에 정의하지 않아도 됩니다. 같은 컬렉션 안의 문서들이 서로 다른 필드를 가져도 됩니다. 이는 유연하지만 책임이 따릅니다. 스키마 검증은 애플리케이션 코드나 MongoDB의 Schema Validation 기능이 담당합니다.
{ "_id": 1, "name": "노트북", "brand": "삼성", "ram_gb": 16 }
{ "_id": 2, "name": "셔츠", "brand": "유니클로", "size": "L", "color": "white" }
{ "_id": 3, "name": "책", "title": "MongoDB 완벽 가이드", "isbn": "978-..." }
MongoDB의 Schema Validation으로 최소한의 구조를 강제할 수 있습니다.
db.createCollection("users", {
validator: {
$jsonSchema: {
required: ["email", "name"],
properties: {
email: { type: "string", pattern: "^.+@.+$" },
age: { type: "int", minimum: 0, maximum: 150 }
}
}
}
});
임베딩 vs 참조 — 의사결정 표
MongoDB 스키마 설계의 핵심 질문은 "관련 데이터를 같은 문서에 넣을 것인가(임베딩), 아니면 별도 컬렉션에 두고 ID로 참조할 것인가(참조)"입니다. 접근 패턴을 먼저 파악하고 그에 맞게 결정하세요.
| 기준 | 임베딩 선택 | 참조 선택 |
|---|---|---|
| 조회 패턴 | 항상 함께 조회 | 독립적으로 접근 필요 |
| 데이터 크기 | N이 작고 고정적 | N이 무한히 증가 가능 |
| 읽기/쓰기 비율 | 읽기 위주 | 자주 업데이트 필요 |
| 공유 여부 | 한 부모에만 속함 | 여러 문서에서 공유 |
| 관계 유형 | 1:1, 제한된 1:N | N:M, 대규모 1:N |
임베딩 예시로 주문 항목은 주문 문서와 항상 함께 조회되고, 항목 수가 현실적으로 제한되므로 임베딩이 적합합니다.
{
"_id": ObjectId("..."),
"orderId": "ORD-2024-001",
"customerId": ObjectId("..."),
"status": "delivered",
"orderItems": [
{ "productId": "P001", "name": "노트북", "qty": 1, "price": 1200000 },
{ "productId": "P002", "name": "마우스", "qty": 2, "price": 35000 }
],
"totalAmount": 1270000,
"createdAt": ISODate("2024-03-15")
}
참조 예시로 댓글은 무한히 증가할 수 있으므로 별도 컬렉션으로 분리합니다.
{ "_id": ObjectId("article_001"), "title": "MongoDB 설계 패턴" }
{ "_id": ObjectId("..."), "articleId": ObjectId("article_001"), "body": "좋은 글이에요" }
{ "_id": ObjectId("..."), "articleId": ObjectId("article_001"), "body": "감사합니다" }
소셜 피드 서비스에서 게시글 문서에 댓글을 배열로 임베딩하면 초기에는 잘 동작합니다. 그러나 인기 게시글에 수천, 수만 개의 댓글이 쌓이면 단일 문서가 16MB 제한에 도달해 BSONObjectTooLarge 오류가 발생합니다. 문서가 커질수록 업데이트 성능도 저하됩니다.
해결책은 댓글을 별도 comments 컬렉션으로 분리하고 articleId로 참조하는 것입니다. 댓글 수가 많아도 문서 크기 문제가 없고, 페이지네이션도 자연스럽게 구현됩니다. 마이그레이션 시에는 기존 임베딩 댓글을 읽어 comments 컬렉션에 벌크 삽입하고, 게시글 문서에서 댓글 배열을 제거하면 됩니다.
소셜 피드 서비스를 설계할 때 댓글 처리 방식은 서비스 규모와 접근 패턴에 따라 다릅니다.
작은 커뮤니티 서비스에서 게시글당 댓글이 평균 10개 미만이고 "게시글과 최신 댓글 3개"를 항상 함께 보여주는 UI라면, 제한적 임베딩(최근 N개만 임베딩, 나머지는 참조)을 고려할 수 있습니다. 반면 트위터나 인스타그램처럼 인기 게시글에 댓글이 수만 개가 달릴 수 있는 서비스라면 처음부터 참조 방식으로 설계해야 합니다.
일반적인 권장 기준은 댓글 수가 현실적으로 100개를 초과할 가능성이 있다면 참조를 선택하는 것입니다. 나중에 임베딩에서 참조로 마이그레이션하는 것보다 처음부터 참조로 설계하는 편이 훨씬 안전합니다.
MongoDB 기본 CRUD와 Aggregation Pipeline
MongoDB CRUD를 처음 쓰는데 업데이트 후 문서 전체가 바뀌었습니다. $set을 빠뜨리고 업데이트하면 기존 필드가 모두 날아가는 MongoDB 고유 동작을 몰랐던 겁니다. 집계 쿼리는 SQL의 GROUP BY처럼 쓰려고 했는데 Aggregation Pipeline이라는 전혀 다른 개념이 등장합니다. MongoDB의 CRUD와 집계 문법은 처음엔 낯설지만 패턴을 익히면 강력한 데이터 처리가 가능합니다.

기본 CRUD 연산
MongoDB CRUD의 핵심은 연산자 접두사입니다. 쿼리 연산자($gte, $in 등)는 조건을 표현하고, 업데이트 연산자($set, $inc, $push)는 변경 방식을 지정합니다. $set 없이 업데이트하면 문서 전체가 교체되므로 주의하세요.
db.products.insertOne({
name: "무선 키보드",
brand: "로지텍",
price: 89000,
stock: 150,
tags: ["keyboard", "wireless"],
createdAt: new Date()
});
db.products.insertMany([
{ name: "마우스", price: 45000, stock: 200 },
{ name: "헤드셋", price: 120000, stock: 80 }
]);
db.products.find({
price: { $gte: 50000, $lte: 200000 },
stock: { $gt: 0 },
tags: { $in: ["keyboard", "mouse"] }
});
db.products.find(
{ brand: "로지텍" },
{ name: 1, price: 1, _id: 0 }
);
db.products.find({ stock: { $gt: 0 } })
.sort({ price: -1 })
.skip(20)
.limit(10);
db.products.updateOne(
{ _id: ObjectId("...") },
{ $set: { price: 95000, updatedAt: new Date() } }
);
db.products.updateOne(
{ _id: ObjectId("...") },
{ $inc: { stock: 50 } }
);
db.articles.updateOne(
{ _id: ObjectId("...") },
{ $push: { tags: "performance" } }
);
db.products.updateMany(
{ brand: "로지텍" },
{ $set: { brand: "Logitech" } }
);
db.products.deleteOne({ _id: ObjectId("...") });
db.products.deleteMany({ stock: 0, discontinued: true });
Aggregation Pipeline
Aggregation Pipeline은 여러 단계를 순서대로 통과시키며 데이터를 변환·집계합니다. 각 단계의 출력이 다음 단계의 입력이 됩니다. $match는 항상 파이프라인 앞쪽에 두어 처리할 문서 수를 먼저 줄이세요.
db.products.aggregate([
{ $match: { stock: { $gt: 0 } } },
{ $group: {
_id: "$brand",
avgPrice: { $avg: "$price" },
totalStock: { $sum: "$stock" },
productCount: { $sum: 1 }
}},
{ $project: {
brand: "$_id",
avgPrice: { $round: ["$avgPrice", 0] },
totalStock: 1,
productCount: 1,
_id: 0
}},
{ $sort: { avgPrice: -1 } }
]);
$lookup은 다른 컬렉션과 조인을 수행합니다. RDBMS의 LEFT OUTER JOIN과 유사합니다. $lookup이 파이프라인에 자주 등장한다면 스키마 설계에 임베딩을 더 활용할 여지가 있는지 재검토하세요.
db.orders.aggregate([
{ $match: { status: "completed", createdAt: { $gte: new Date("2024-01-01") } } },
{
$lookup: {
from: "customers",
localField: "customerId",
foreignField: "_id",
as: "customer"
}
},
{ $unwind: "$customer" },
{ $project: {
orderId: 1,
totalAmount: 1,
"customer.name": 1,
"customer.email": 1
}}
]);
인덱스 생성
MongoDB 인덱스는 RDBMS와 동일한 원칙이 적용됩니다. 자주 쓰는 쿼리 조건과 정렬 필드에 복합 인덱스를 만드세요. TTL 인덱스는 세션, 임시 토큰처럼 자동으로 만료해야 하는 데이터에 유용합니다.
db.products.createIndex({ price: 1 });
db.orders.createIndex({ customerId: 1, createdAt: -1 });
db.articles.createIndex({ title: "text", content: "text" });
db.articles.find({ $text: { $search: "MongoDB 성능" } });
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 });
db.orders.getIndexes();
언제 MongoDB를 선택하는가
MongoDB가 유리한 상황과 RDBMS가 더 적합한 상황을 명확히 구분해야 합니다. 기술 선택은 팀의 익숙함보다 데이터 특성과 접근 패턴이 우선입니다.
| MongoDB가 유리 | RDBMS가 유리 |
|---|---|
| 스키마가 자주 바뀌는 MVP | 복잡한 비즈니스 트랜잭션 (결제, 재고) |
| 계층적/중첩 구조 데이터 | 강한 데이터 일관성과 FK 무결성 필수 |
| 지리 위치 데이터 | 복잡한 다중 테이블 조인이 빈번한 경우 |
| 로그, 이벤트, 실시간 분석 | 집계 및 리포팅 중심 워크로드 (OLAP) |
| 수평 샤딩이 필요한 대용량 | 트랜잭션 롤백이 빈번한 경우 |
다음 모듈에서는 캐시 전략, 세션 스토어, Pub/Sub 등 Redis의 실무 활용 패턴을 다룹니다.