들어가며
MongoDB는 전 세계에서 가장 많이 사용되는 NoSQL 데이터베이스입니다. 유연한 스키마, 수평 확장성, 풍부한 쿼리 기능 덕분에 카탈로그, 로깅, 사용자 프로필, IoT 데이터 등 다양한 도메인에서 활용되고 있습니다. 하지만 RDBMS에 익숙한 개발자가 MongoDB를 사용할 때 가장 많이 하는 실수가 "관계형 모델을 그대로 옮기는 것"입니다.
이 글에서는 MongoDB의 도큐먼트 모델링 핵심 원칙, 인덱싱 전략, Aggregation Pipeline, Spring Data MongoDB 연동, 그리고 RDBMS와 MongoDB의 선택 기준까지 실무 중심으로 다루겠습니다.
도큐먼트 모델링 - 임베딩 vs 참조
핵심 원칙: 데이터를 읽는 방식대로 저장하라
RDBMS는 정규화가 기본이지만, MongoDB에서는 애플리케이션의 접근 패턴에 맞게 데이터를 설계합니다. 핵심 질문은 "이 데이터를 항상 함께 조회하는가?"입니다.
임베딩 (Embedding) 패턴
관련 데이터를 하나의 도큐먼트 안에 내장하는 방식입니다. 단일 읽기 작업으로 필요한 모든 데이터를 가져올 수 있어 성능이 뛰어납니다.
// 주문 도큐먼트 - 주문 상세 항목을 임베딩
{
"_id": ObjectId("6601a1b2c3d4e5f6a7b8c9d0"),
"orderNumber": "ORD-2024-001234",
"customer": {
"customerId": "USR-42",
"name": "김개발",
"email": "kim@example.com"
},
"items": [
{
"productId": "PRD-100",
"productName": "기계식 키보드",
"price": 89000,
"quantity": 1
},
{
"productId": "PRD-200",
"productName": "무선 마우스",
"price": 45000,
"quantity": 2
}
],
"totalAmount": 179000,
"status": "CONFIRMED",
"shippingAddress": {
"zipCode": "06234",
"address": "서울시 강남구 테헤란로 123",
"detail": "4층 401호"
},
"createdAt": ISODate("2024-03-28T09:30:00Z"),
"updatedAt": ISODate("2024-03-28T09:35:00Z")
}
참조 (Reference) 패턴
데이터를 별도의 컬렉션에 저장하고 ID로 연결하는 방식입니다. 관계형 DB의 외래키와 유사합니다.
// 상품 컬렉션 (독립적으로 관리)
{
"_id": "PRD-100",
"name": "기계식 키보드",
"brand": "KeyCraft",
"price": 89000,
"category": "peripherals",
"specs": {
"switchType": "Cherry MX Brown",
"layout": "풀배열",
"connectivity": "USB-C"
},
"stock": 150,
"reviews": [] // 리뷰가 많아지면 별도 컬렉션으로 분리
}
// 리뷰 컬렉션 (참조 방식)
{
"_id": ObjectId("..."),
"productId": "PRD-100",
"userId": "USR-42",
"rating": 5,
"content": "키감이 정말 좋습니다. 개발할 때 쾌적해요.",
"createdAt": ISODate("2024-03-20T14:00:00Z")
}
임베딩 vs 참조 선택 기준
| 기준 | 임베딩 권장 | 참조 권장 |
|---|---|---|
| 관계 | 1:1, 1:소수 | 1:다수, 다:다 |
| 접근 패턴 | 항상 함께 조회 | 독립적으로 조회 |
| 업데이트 빈도 | 드물게 변경 | 자주 변경 |
| 데이터 크기 | 16MB 이하 | 도큐먼트가 커질 수 있음 |
| 일관성 | 단일 원자적 업데이트 | 트랜잭션 필요 |
자주 쓰이는 설계 패턴
- Subset Pattern: 자주 쓰는 데이터만 임베딩, 나머지는 별도 컬렉션 (예: 최근 리뷰 3개만 임베딩)
- Bucket Pattern: 시계열 데이터를 시간 버킷으로 그룹화 (예: 1시간 단위 센서 데이터)
- Extended Reference Pattern: 참조 + 자주 쓰는 필드 복사 (예: 주문에 고객 이름 복사)
- Outlier Pattern: 대부분 소수지만 가끔 대량인 경우 (예: 인기 상품의 리뷰만 별도 관리)
인덱스 전략
단일 필드 인덱스와 복합 인덱스
// 단일 필드 인덱스
db.orders.createIndex({ "customer.customerId": 1 });
// 복합 인덱스 - ESR 규칙 (Equality, Sort, Range 순서)
// 검색 조건: status = 'CONFIRMED' AND createdAt >= '2024-01-01' ORDER BY totalAmount DESC
db.orders.createIndex(
{ status: 1, totalAmount: -1, createdAt: 1 },
{ name: "idx_status_amount_date" }
);
// 실행 계획 확인
db.orders.find({
status: "CONFIRMED",
createdAt: { $gte: ISODate("2024-01-01") }
}).sort({ totalAmount: -1 }).explain("executionStats");
특수 인덱스
// TTL 인덱스: 세션 데이터 30분 후 자동 삭제
db.sessions.createIndex(
{ lastAccessedAt: 1 },
{ expireAfterSeconds: 1800 }
);
// 텍스트 인덱스: 전문 검색
db.products.createIndex(
{ name: "text", description: "text" },
{
weights: { name: 10, description: 5 },
default_language: "none" // 한국어는 별도 형태소 분석기 필요
}
);
// 부분 인덱스: 특정 조건의 도큐먼트만 인덱싱
db.orders.createIndex(
{ createdAt: -1 },
{
partialFilterExpression: { status: "PENDING" },
name: "idx_pending_orders"
}
);
// 유니크 + Sparse 인덱스: null 허용하면서 유니크
db.users.createIndex(
{ email: 1 },
{ unique: true, sparse: true }
);
// Wildcard 인덱스: 동적 필드에 대한 인덱싱
db.products.createIndex({ "specs.$**": 1 });
인덱스 성능 분석
// 컬렉션의 인덱스 사용 통계
db.orders.aggregate([
{ $indexStats: {} },
{ $project: {
name: 1,
"accesses.ops": 1,
"accesses.since": 1
}}
]);
// 사용되지 않는 인덱스 찾기
db.orders.aggregate([
{ $indexStats: {} },
{ $match: { "accesses.ops": 0 } }
]);
Aggregation Pipeline
파이프라인 기본 구조
Aggregation Pipeline은 UNIX의 파이프(|)처럼 여러 단계(stage)를 순서대로 연결하여 데이터를 변환합니다.
// 월별 카테고리별 매출 집계
db.orders.aggregate([
// Stage 1: 기간 필터
{ $match: {
status: "COMPLETED",
createdAt: {
$gte: ISODate("2024-01-01"),
$lt: ISODate("2025-01-01")
}
}},
// Stage 2: items 배열 펼치기
{ $unwind: "$items" },
// Stage 3: 월별 + 카테고리별 그룹화
{ $group: {
_id: {
month: { $dateToString: { format: "%Y-%m", date: "$createdAt" } },
category: "$items.category"
},
totalRevenue: { $sum: { $multiply: ["$items.price", "$items.quantity"] } },
orderCount: { $sum: 1 },
avgOrderValue: { $avg: { $multiply: ["$items.price", "$items.quantity"] } }
}},
// Stage 4: 정렬
{ $sort: { "_id.month": 1, totalRevenue: -1 } },
// Stage 5: 출력 형태 변환
{ $project: {
_id: 0,
month: "$_id.month",
category: "$_id.category",
totalRevenue: { $round: ["$totalRevenue", 0] },
orderCount: 1,
avgOrderValue: { $round: ["$avgOrderValue", 0] }
}}
]);
$lookup - 컬렉션 조인
// 주문과 고객 정보 조인
db.orders.aggregate([
{ $lookup: {
from: "users",
localField: "customer.customerId",
foreignField: "_id",
as: "customerDetail",
pipeline: [
{ $project: { name: 1, grade: 1, totalPurchases: 1 } }
]
}},
{ $unwind: "$customerDetail" },
{ $match: { "customerDetail.grade": "VIP" } }
]);
Spring Data MongoDB 연동
의존성 및 설정
<!-- build.gradle -->
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
}
# application.yml
spring:
data:
mongodb:
uri: mongodb://localhost:27017/shop
auto-index-creation: true
도큐먼트 엔티티 정의
@Document(collection = "orders")
@TypeAlias("order")
public class Order {
@Id
private String id;
@Indexed
private String orderNumber;
private Customer customer;
private List<OrderItem> items;
private Long totalAmount;
@Indexed
private String status;
private Address shippingAddress;
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
// Getters, Setters...
}
public class Customer {
private String customerId;
private String name;
private String email;
}
public class OrderItem {
private String productId;
private String productName;
private Long price;
private Integer quantity;
}
public class Address {
private String zipCode;
private String address;
private String detail;
}
Repository 정의
public interface OrderRepository extends MongoRepository<Order, String> {
// 쿼리 메서드
List<Order> findByStatusAndCreatedAtAfter(String status, Instant after);
// @Query 어노테이션
@Query("{ 'customer.customerId': ?0, 'status': { $in: ?1 } }")
List<Order> findByCustomerAndStatuses(String customerId, List<String> statuses);
// Projection
@Query(value = "{ 'status': 'COMPLETED' }", fields = "{ 'orderNumber': 1, 'totalAmount': 1 }")
List<Order> findCompletedOrdersSummary();
// 페이징
Page<Order> findByStatusOrderByCreatedAtDesc(String status, Pageable pageable);
}
MongoTemplate을 이용한 복잡한 쿼리
@Repository
@RequiredArgsConstructor
public class OrderCustomRepository {
private final MongoTemplate mongoTemplate;
// 동적 검색 조건
public List<Order> searchOrders(OrderSearchCondition condition) {
Query query = new Query();
if (condition.getStatus() != null) {
query.addCriteria(Criteria.where("status").is(condition.getStatus()));
}
if (condition.getCustomerName() != null) {
query.addCriteria(Criteria.where("customer.name")
.regex(condition.getCustomerName(), "i"));
}
if (condition.getMinAmount() != null) {
query.addCriteria(Criteria.where("totalAmount")
.gte(condition.getMinAmount()));
}
if (condition.getFromDate() != null && condition.getToDate() != null) {
query.addCriteria(Criteria.where("createdAt")
.gte(condition.getFromDate())
.lt(condition.getToDate()));
}
query.with(Sort.by(Sort.Direction.DESC, "createdAt"));
query.with(PageRequest.of(condition.getPage(), condition.getSize()));
return mongoTemplate.find(query, Order.class);
}
// Aggregation Pipeline
public List<MonthlySalesResult> getMonthlySales(int year) {
Aggregation aggregation = Aggregation.newAggregation(
Aggregation.match(
Criteria.where("status").is("COMPLETED")
.and("createdAt")
.gte(LocalDate.of(year, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC))
.lt(LocalDate.of(year + 1, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC))
),
Aggregation.project()
.and(DateOperators.Month.monthOf("createdAt")).as("month")
.and("totalAmount").as("amount"),
Aggregation.group("month")
.sum("amount").as("totalRevenue")
.count().as("orderCount"),
Aggregation.sort(Sort.Direction.ASC, "_id"),
Aggregation.project()
.and("_id").as("month")
.and("totalRevenue").as("totalRevenue")
.and("orderCount").as("orderCount")
.andExclude("_id")
);
AggregationResults<MonthlySalesResult> results =
mongoTemplate.aggregate(aggregation, "orders", MonthlySalesResult.class);
return results.getMappedResults();
}
// Bulk Write
public void bulkUpdateStatus(List<String> orderIds, String newStatus) {
BulkOperations bulk = mongoTemplate.bulkOps(
BulkOperations.BulkMode.UNORDERED, Order.class);
for (String orderId : orderIds) {
Query query = Query.query(Criteria.where("_id").is(orderId));
Update update = Update.update("status", newStatus)
.set("updatedAt", Instant.now());
bulk.updateOne(query, update);
}
BulkWriteResult result = bulk.execute();
log.info("Bulk update: matched={}, modified={}",
result.getMatchedCount(), result.getModifiedCount());
}
}
RDBMS vs MongoDB 선택 기준
| 기준 | RDBMS 권장 | MongoDB 권장 |
|---|---|---|
| 데이터 구조 | 스키마가 고정적이고 정규화가 중요 | 스키마가 유동적이거나 비정형 데이터 |
| 관계 | 복잡한 JOIN이 빈번 | 임베딩으로 해결 가능한 간단한 관계 |
| 트랜잭션 | 복잡한 다중 테이블 트랜잭션 | 단일 도큐먼트 원자성으로 충분 |
| 확장성 | 수직 확장 (Scale-Up) | 수평 확장 (Sharding) |
| 읽기 패턴 | 다양한 조건의 ad-hoc 쿼리 | 특정 패턴의 반복적 조회 |
| 쓰기 부하 | 중간 수준의 쓰기 | 대량의 쓰기 (로그, IoT 등) |
| 적합한 도메인 | 금융, ERP, 주문/결제 | 카탈로그, CMS, 게임, 실시간 분석 |
실무에서의 조합 사용
대부분의 실무 프로젝트에서는 RDBMS와 MongoDB를 함께 사용합니다.
- 주문/결제: PostgreSQL (트랜잭션 안정성)
- 상품 카탈로그: MongoDB (유연한 스키마, 카테고리별 다른 속성)
- 사용자 활동 로그: MongoDB (대량 쓰기, TTL 인덱스로 자동 정리)
- 검색: Elasticsearch (전문 검색 엔진)
마치며
MongoDB는 "스키마가 없는 DB"가 아니라 "유연한 스키마를 가진 DB"입니다. 스키마 설계를 소홀히 하면 RDBMS보다 더 큰 고통을 겪게 됩니다. 이 글에서 다룬 핵심 내용을 정리합니다.
- 도큐먼트 모델링에서는 "데이터를 읽는 방식대로 저장"하는 것이 핵심입니다. 임베딩과 참조를 접근 패턴에 맞게 선택하세요
- 인덱스 설계는 ESR 규칙(Equality, Sort, Range)을 따르고, TTL/부분/와일드카드 인덱스를 상황에 맞게 활용하세요
- Aggregation Pipeline은 SQL의 GROUP BY + JOIN을 대체하는 강력한 도구입니다
- Spring Data MongoDB는 Repository와 MongoTemplate 두 가지 방식을 제공하며, 복잡한 쿼리는 MongoTemplate이 적합합니다
- RDBMS와 MongoDB는 경쟁이 아닌 보완 관계입니다. 각각의 강점에 맞는 도메인에 적용하세요
다음 글에서는 Elasticsearch를 활용한 검색 엔진 구축을 다루겠습니다. MongoDB와 Elasticsearch를 함께 활용하면 데이터 저장과 검색 모두 강력한 시스템을 구축할 수 있습니다.
'Database' 카테고리의 다른 글
| MySQL 성능 튜닝 실전 - 슬로우 쿼리부터 인덱스 최적화까지 (1) | 2026.04.14 |
|---|---|
| Elasticsearch 입문 - Spring Boot로 검색 엔진 구축하기 (0) | 2026.04.06 |
| PostgreSQL 성능 튜닝 완벽 가이드 - 쿼리 최적화부터 파티셔닝까지 (0) | 2026.04.03 |
| 트랜잭션 격리 수준과 동시성 제어 - 실무에서 겪는 문제들 (0) | 2026.03.31 |
| Redis 캐시 전략 가이드 - 실무에서 바로 쓰는 패턴 (0) | 2026.03.30 |