Spring Data MongoDB — 도큐먼트 DB를 스프링에서 사용하는 방법
스키마가 자주 바뀌는 데이터를 저장해야 할 때, 관계형 DB의 ALTER TABLE이 부담스럽다면 어떤 선택지가 있을까요?
Spring Data MongoDB란
Spring Data MongoDB는 MongoDB를 스프링에서 사용할 수 있게 해주는 모듈입니다. 두 가지 방식으로 데이터에 접근할 수 있습니다.
- MongoTemplate: Query/Criteria API로 세밀한 쿼리 제어가 가능한 저수준 API
- MongoRepository: 메서드 이름 규칙으로 쿼리를 자동 생성하는 고수준 API
기본 설정
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
# application.yml
spring:
data:
mongodb:
uri: mongodb://localhost:27017/mydb
# 또는 개별 설정
# host: localhost
# port: 27017
# database: mydb
# username: admin
# password: secret
도메인 모델 — @Document
@Document(collection = "products") // 컬렉션 이름 지정
@Getter
@NoArgsConstructor
public class Product {
@Id
private String id; // MongoDB ObjectId와 매핑
@Indexed(unique = true) // 유니크 인덱스
private String sku;
@Field("product_name") // 필드 이름 매핑
private String name;
private BigDecimal price;
@CreatedDate, @LastModifiedDate로 문서의 생성/수정 시점을 자동 추적할 수 있습니다.
private List<String> tags; // 배열 필드
private Specification spec; // 임베디드 문서
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
임베디드 문서는 별도 컬렉션이 아니라 상위 문서 안에 포함되므로 @Document가 필요하지 않습니다.
// 임베디드 문서 — @Document 불필요
@Getter
@NoArgsConstructor
public class Specification {
private String color;
private String size;
private double weight;
}
임베디드 vs 참조
MongoDB에서는 관련 데이터를 하나의 문서에 임베디드하는 것이 기본 패턴입니다. @DBRef로 참조를 걸 수도 있지만, 추가 쿼리가 발생하므로 주의해야 합니다.
// 임베디드 (권장) — 한 번의 쿼리로 조회
private List<OrderItem> items;
// 참조 (주의) — N+1 쿼리 발생 가능
@DBRef
private Category category;
MongoRepository — 선언적 쿼리
public interface ProductRepository extends MongoRepository<Product, String> {
// 메서드 이름 기반 쿼리
List<Product> findByName(String name);
List<Product> findByPriceBetween(BigDecimal min, BigDecimal max);
List<Product> findByTagsContaining(String tag);
// 페이징 + 정렬
Page<Product> findByPriceGreaterThan(BigDecimal price, Pageable pageable);
@Query를 사용하면 메서드 이름 규칙으로 표현하기 어려운 복잡한 조건을 JSON 쿼리로 직접 작성할 수 있습니다.
// @Query로 직접 쿼리 작성
@Query("{ 'price': { $gte: ?0, $lte: ?1 }, 'tags': ?2 }")
List<Product> findByPriceRangeAndTag(
BigDecimal minPrice, BigDecimal maxPrice, String tag);
// 프로젝션 — 필요한 필드만 조회
@Query(value = "{ 'sku': ?0 }", fields = "{ 'name': 1, 'price': 1 }")
Optional<Product> findNameAndPriceBySku(String sku);
// 존재 여부 확인
boolean existsBySku(String sku);
// 삭제
long deleteByPriceLessThan(BigDecimal price);
}
MongoTemplate — 세밀한 제어
복잡한 쿼리나 업데이트가 필요할 때는 MongoTemplate을 사용합니다.
조건부 조회
@Service
@RequiredArgsConstructor
public class ProductSearchService {
private final MongoTemplate mongoTemplate;
public List<Product> search(String keyword, BigDecimal minPrice,
BigDecimal maxPrice) {
Criteria criteria = new Criteria();
// 동적 조건 조립
if (keyword != null) {
criteria.and("name").regex(keyword, "i"); // 대소문자 무시
}
if (minPrice != null) {
criteria.and("price").gte(minPrice);
}
조립한 Criteria를 Query 객체로 감싸고 정렬, 제한 조건을 추가한 뒤 실행합니다.
if (maxPrice != null) {
criteria.and("price").lte(maxPrice);
}
Query query = Query.query(criteria)
.with(Sort.by(Sort.Direction.DESC, "createdAt"))
.limit(20);
return mongoTemplate.find(query, Product.class);
}
}
부분 업데이트
JPA와 달리 MongoDB에서는 문서 전체를 교체하지 않고 특정 필드만 업데이트할 수 있습니다.
public long updatePrice(String sku, BigDecimal newPrice) {
Query query = Query.query(Criteria.where("sku").is(sku));
Update update = new Update()
.set("price", newPrice)
.set("updatedAt", LocalDateTime.now())
.inc("version", 1); // 버전 증가
UpdateResult result = mongoTemplate.updateFirst(query, update, Product.class);
return result.getModifiedCount();
}
// 배열 필드 조작
public void addTag(String productId, String tag) {
Query query = Query.query(Criteria.where("id").is(productId));
Update update = new Update().addToSet("tags", tag); // 중복 없이 추가
mongoTemplate.updateFirst(query, update, Product.class);
}
Upsert — 없으면 삽입, 있으면 업데이트
public void upsertProduct(String sku, String name, BigDecimal price) {
Query query = Query.query(Criteria.where("sku").is(sku));
Update update = new Update()
.set("name", name)
.set("price", price)
.setOnInsert("createdAt", LocalDateTime.now());
mongoTemplate.upsert(query, update, Product.class);
}
Aggregation Pipeline
MongoDB의 강력한 집계 기능을 스프링에서 사용하는 방법입니다.
// 카테고리별 평균 가격과 상품 수 집계
public List<CategoryStats> getCategoryStats() {
Aggregation aggregation = Aggregation.newAggregation(
// $match — 필터링
Aggregation.match(Criteria.where("price").gt(0)),
// $group — 그룹핑 + 집계
Aggregation.group("category")
.avg("price").as("avgPrice")
.count().as("count")
.max("price").as("maxPrice"),
// $sort — 정렬
Aggregation.sort(Sort.Direction.DESC, "count"),
// $project — 필드 선택/변환
Aggregation.project()
.and("_id").as("category")
.andInclude("avgPrice", "count", "maxPrice")
);
파이프라인을 실행하고 결과를 DTO로 매핑하여 반환합니다.
AggregationResults<CategoryStats> results =
mongoTemplate.aggregate(aggregation, "products", CategoryStats.class);
return results.getMappedResults();
}
@Getter
@NoArgsConstructor
public class CategoryStats {
private String category;
private double avgPrice;
private long count;
private double maxPrice;
}
$lookup — 컬렉션 간 조인
Aggregation aggregation = Aggregation.newAggregation(
Aggregation.lookup("reviews", "_id", "productId", "reviews"),
Aggregation.match(Criteria.where("reviews").not().size(0)),
Aggregation.project("name", "price")
.and("reviews").size().as("reviewCount")
);
인덱스 관리
@Document(collection = "products")
@CompoundIndex(
name = "category_price_idx",
def = "{'category': 1, 'price': -1}" // 복합 인덱스
)
public class Product {
@Indexed(unique = true)
private String sku;
@TextIndexed(weight = 2) // 텍스트 검색 인덱스 (가중치)
private String name;
@TextIndexed
private String description;
}
프로그래밍 방식으로도 인덱스를 관리할 수 있습니다.
@PostConstruct
public void initIndexes() {
mongoTemplate.indexOps(Product.class)
.ensureIndex(new Index()
.on("createdAt", Sort.Direction.DESC)
.expire(Duration.ofDays(365)) // TTL 인덱스
);
}
MongoTemplate vs MongoRepository 선택 기준
| 기준 | MongoTemplate | MongoRepository |
|---|---|---|
| 단순 CRUD | 코드가 많음 | 간결 |
| 동적 쿼리 | Criteria 조합으로 유연 | 어려움 |
| 부분 업데이트 | Update API 활용 | save()로 전체 교체 |
| Aggregation | 파이프라인 직접 구성 | 지원하지 않음 |
| 추천 시점 | 복잡한 쿼리, 집계, 벌크 연산 | 단순 CRUD, 빠른 개발 |
실무에서는 둘을 함께 사용하는 경우가 많습니다. 단순 CRUD는 Repository로, 복잡한 쿼리와 집계는 MongoTemplate으로 처리합니다.
실무 팁
1. 스키마 버전 관리
MongoDB는 스키마가 유연하지만, 운영 환경에서 문서 구조가 바뀔 때 마이그레이션이 필요합니다. Mongock 같은 마이그레이션 도구를 사용하세요.
2. 읽기 성능
- 필요한 필드만 프로젝션으로 조회합니다
- 쿼리 패턴에 맞는 인덱스를 반드시 생성합니다
explain()으로 쿼리 실행 계획을 확인합니다
3. 쓰기 성능
- 대량 삽입에는
mongoTemplate.insertAll()을 사용합니다 BulkOperations로 여러 업데이트를 묶어서 실행합니다
주의할 점
1. @DBRef를 사용하면 N+1 쿼리 문제가 발생한다
@DBRef로 참조를 걸면 참조 문서를 로딩할 때마다 추가 쿼리가 실행됩니다. 목록 조회 시 참조가 있는 문서 N개를 읽으면 N번의 추가 쿼리가 발생합니다. 가능하면 임베디드 문서로 설계하거나, 수동으로 ID 참조 후 배치 조회하는 방식을 사용하세요.
2. 인덱스 없이 조회하면 컬렉션 풀 스캔이 발생한다
MongoDB는 RDB와 달리 인덱스가 없어도 에러가 나지 않고 컬렉션 전체를 스캔합니다. 데이터가 적을 때는 문제가 없지만, 수십만 건 이상이 되면 조회 성능이 급격히 떨어집니다. 쿼리 패턴에 맞는 인덱스를 반드시 생성하고, explain()으로 실행 계획을 확인하세요.
3. 스키마 유연성이 데이터 품질 문제로 이어질 수 있다
MongoDB는 스키마가 자유롭기 때문에 같은 컬렉션에 서로 다른 구조의 문서가 섞일 수 있습니다. 예를 들어 price 필드가 어떤 문서에서는 숫자, 다른 문서에서는 문자열로 저장되면 집계나 정렬 시 예상치 못한 결과가 나옵니다. Schema Validation이나 애플리케이션 레벨 검증을 반드시 적용하세요.
정리
- @Document 로 도메인 객체를 MongoDB 컬렉션에 매핑합니다. 관련 데이터는 임베디드하는 것이 기본입니다.
- MongoRepository 는 단순 CRUD에 적합하고, MongoTemplate 은 동적 쿼리와 부분 업데이트에 강합니다.
- Aggregation Pipeline 은 MongoDB의 핵심 기능입니다.
$match → $group → $sort → $project흐름을 기억하세요. - 인덱스 설계가 성능의 핵심입니다. 쿼리 패턴을 분석하고 적절한 인덱스를 만드세요.