스키마가 자주 바뀌는 데이터를 저장해야 할 때, 관계형 DB의 ALTER TABLE이 부담스럽다면 어떤 선택지가 있을까요?

Spring Data MongoDB란

Spring Data MongoDB는 MongoDB를 스프링에서 사용할 수 있게 해주는 모듈입니다. 두 가지 방식으로 데이터에 접근할 수 있습니다.

  • MongoTemplate: Query/Criteria API로 세밀한 쿼리 제어가 가능한 저수준 API
  • MongoRepository: 메서드 이름 규칙으로 쿼리를 자동 생성하는 고수준 API

기본 설정

JAVA
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
YAML
# application.yml
spring:
  data:
    mongodb:
      uri: mongodb://localhost:27017/mydb
      # 또는 개별 설정
      # host: localhost
      # port: 27017
      # database: mydb
      # username: admin
      # password: secret

도메인 모델 — @Document

JAVA
@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로 문서의 생성/수정 시점을 자동 추적할 수 있습니다.

JAVA
    private List<String> tags;        // 배열 필드

    private Specification spec;       // 임베디드 문서

    @CreatedDate
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;
}

임베디드 문서는 별도 컬렉션이 아니라 상위 문서 안에 포함되므로 @Document가 필요하지 않습니다.

JAVA
// 임베디드 문서 — @Document 불필요
@Getter
@NoArgsConstructor
public class Specification {
    private String color;
    private String size;
    private double weight;
}

임베디드 vs 참조

MongoDB에서는 관련 데이터를 하나의 문서에 임베디드하는 것이 기본 패턴입니다. @DBRef로 참조를 걸 수도 있지만, 추가 쿼리가 발생하므로 주의해야 합니다.

JAVA
// 임베디드 (권장) — 한 번의 쿼리로 조회
private List<OrderItem> items;

// 참조 (주의) — N+1 쿼리 발생 가능
@DBRef
private Category category;

MongoRepository — 선언적 쿼리

JAVA
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 쿼리로 직접 작성할 수 있습니다.

JAVA
    // @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을 사용합니다.

조건부 조회

JAVA
@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 객체로 감싸고 정렬, 제한 조건을 추가한 뒤 실행합니다.

JAVA
        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에서는 문서 전체를 교체하지 않고 특정 필드만 업데이트할 수 있습니다.

JAVA
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 — 없으면 삽입, 있으면 업데이트

JAVA
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의 강력한 집계 기능을 스프링에서 사용하는 방법입니다.

JAVA
// 카테고리별 평균 가격과 상품 수 집계
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로 매핑하여 반환합니다.

JAVA
    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 — 컬렉션 간 조인

JAVA
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")
);

인덱스 관리

JAVA
@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;
}

프로그래밍 방식으로도 인덱스를 관리할 수 있습니다.

JAVA
@PostConstruct
public void initIndexes() {
    mongoTemplate.indexOps(Product.class)
        .ensureIndex(new Index()
            .on("createdAt", Sort.Direction.DESC)
            .expire(Duration.ofDays(365))   // TTL 인덱스
        );
}

MongoTemplate vs MongoRepository 선택 기준

기준MongoTemplateMongoRepository
단순 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 흐름을 기억하세요.
  • 인덱스 설계가 성능의 핵심입니다. 쿼리 패턴을 분석하고 적절한 인덱스를 만드세요.
댓글 로딩 중...