Spring Data MongoDB 심화

Spring Data MongoDB란?

Spring Data MongoDB는 MongoDB를 Spring 생태계에서 Repository 패턴과 Template API로 사용할 수 있게 해주는 모듈입니다. JPA와 유사한 인터페이스를 제공하면서도 MongoDB의 도큐먼트 모델, 유연한 스키마, 집계 파이프라인 등 NoSQL 고유 기능을 그대로 활용할 수 있습니다. RDB로는 처리하기 어려운 비정형 데이터, 이벤트 로그, 카탈로그 등에 적합합니다.

의존성과 기본 설정

// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
# application.yml
spring:
  data:
    mongodb:
      uri: mongodb://user:password@localhost:27017/mydb?authSource=admin
      # 또는 개별 설정
      host: localhost
      port: 27017
      database: mydb
      username: user
      password: password
      auto-index-creation: true    # 개발 환경에서만 true

도큐먼트 매핑: @Document

JPA의 @Entity 대신 @Document를 사용합니다. MongoDB의 유연한 스키마를 활용한 도큐먼트 설계:

@Document(collection = "products")
@CompoundIndex(name = "category_name_idx",
    def = "{'category': 1, 'name': 1}")
public class Product {

    @Id
    private String id;          // MongoDB ObjectId 자동 생성

    @Indexed(unique = true)
    private String sku;

    @Field("product_name")      // MongoDB 필드명 매핑
    private String name;

    private String category;
    private BigDecimal price;

    // 내장 도큐먼트 (Embedded)
    private List<Variant> variants;

    // 동적 속성 (스키마리스 활용)
    private Map<String, Object> attributes;

    @CreatedDate
    private Instant createdAt;

    @LastModifiedDate
    private Instant updatedAt;

    @Version
    private Long version;       // Optimistic Locking
}

// 내장 도큐먼트 (별도 컬렉션 아님)
public class Variant {
    private String color;
    private String size;
    private int stock;
    private BigDecimal priceAdjustment;
}

주요 어노테이션:

어노테이션 설명
@Document 컬렉션 매핑 (JPA의 @Entity)
@Id _id 필드 매핑. String이면 ObjectId 자동 생성
@Field MongoDB 필드명 지정
@Indexed 단일 필드 인덱스
@CompoundIndex 복합 인덱스
@TextIndexed 텍스트 검색 인덱스
@DBRef 다른 컬렉션 참조 (JOIN과 유사)
@Version Optimistic Locking

MongoRepository: 기본 CRUD와 쿼리 메서드

public interface ProductRepository
    extends MongoRepository<Product, String> {

    // 메서드 이름으로 쿼리 자동 생성
    List<Product> findByCategoryAndPriceLessThan(
        String category, BigDecimal price);

    // 내장 도큐먼트 필드 쿼리
    List<Product> findByVariantsColor(String color);

    // 정렬 + 페이징
    Page<Product> findByCategoryOrderByPriceDesc(
        String category, Pageable pageable);

    // @Query로 MongoDB 네이티브 쿼리
    @Query("{ 'category': ?0, 'price': { $gte: ?1, $lte: ?2 } }")
    List<Product> findByCategoryAndPriceRange(
        String category, BigDecimal min, BigDecimal max);

    // 프로젝션: 필요한 필드만 조회
    @Query(value = "{ 'category': ?0 }",
           fields = "{ 'name': 1, 'price': 1 }")
    List<Product> findNameAndPriceByCategory(String category);

    // 존재 여부 확인
    boolean existsBySku(String sku);

    // 삭제
    long deleteByCategory(String category);

    // 텍스트 검색
    @TextScore
    List<Product> findByNameContaining(String keyword);
}

MongoTemplate: 복잡한 쿼리

Repository만으로는 부족할 때 MongoTemplate을 직접 사용합니다:

@Service
@RequiredArgsConstructor
public class ProductQueryService {

    private final MongoTemplate mongoTemplate;

    // 동적 조건 쿼리
    public List<Product> search(ProductSearchCriteria criteria) {
        Query query = new Query();

        if (criteria.getCategory() != null) {
            query.addCriteria(
                Criteria.where("category").is(criteria.getCategory()));
        }
        if (criteria.getMinPrice() != null) {
            query.addCriteria(
                Criteria.where("price").gte(criteria.getMinPrice()));
        }
        if (criteria.getMaxPrice() != null) {
            query.addCriteria(
                Criteria.where("price").lte(criteria.getMaxPrice()));
        }
        if (criteria.getColors() != null) {
            query.addCriteria(
                Criteria.where("variants.color")
                    .in(criteria.getColors()));
        }

        // 페이징 + 정렬
        query.with(PageRequest.of(
            criteria.getPage(), criteria.getSize(),
            Sort.by(Sort.Direction.DESC, "createdAt")));

        return mongoTemplate.find(query, Product.class);
    }

    // 부분 업데이트 (전체 도큐먼트 교체 아닌 필드만 수정)
    public void updatePrice(String sku, BigDecimal newPrice) {
        Query query = Query.query(Criteria.where("sku").is(sku));
        Update update = new Update()
            .set("price", newPrice)
            .set("updatedAt", Instant.now())
            .inc("version", 1);

        mongoTemplate.updateFirst(query, update, Product.class);
    }

    // Upsert: 있으면 수정, 없으면 삽입
    public void upsertProduct(String sku, Product product) {
        Query query = Query.query(Criteria.where("sku").is(sku));
        Update update = new Update()
            .set("name", product.getName())
            .set("price", product.getPrice())
            .set("category", product.getCategory())
            .setOnInsert("createdAt", Instant.now());

        mongoTemplate.upsert(query, update, Product.class);
    }

    // 배열 조작: 내장 도큐먼트 추가/제거
    public void addVariant(String productId, Variant variant) {
        Query query = Query.query(Criteria.where("id").is(productId));
        Update update = new Update().push("variants", variant);
        mongoTemplate.updateFirst(query, update, Product.class);
    }

    public void removeVariant(String productId, String color) {
        Query query = Query.query(Criteria.where("id").is(productId));
        Update update = new Update().pull("variants",
            Query.query(Criteria.where("color").is(color)));
        mongoTemplate.updateFirst(query, update, Product.class);
    }
}

Aggregation Pipeline: 집계 쿼리

MongoDB의 핵심 기능인 집계 파이프라인을 Spring에서 활용하는 방법:

@Service
@RequiredArgsConstructor
public class ProductAnalyticsService {

    private final MongoTemplate mongoTemplate;

    // 카테고리별 평균 가격·상품 수
    public List<CategoryStats> getCategoryStats() {
        Aggregation agg = Aggregation.newAggregation(
            Aggregation.group("category")
                .count().as("productCount")
                .avg("price").as("avgPrice")
                .min("price").as("minPrice")
                .max("price").as("maxPrice"),
            Aggregation.sort(Sort.Direction.DESC, "productCount"),
            Aggregation.project()
                .and("_id").as("category")
                .andInclude("productCount", "avgPrice",
                            "minPrice", "maxPrice")
        );

        return mongoTemplate
            .aggregate(agg, "products", CategoryStats.class)
            .getMappedResults();
    }

    // Unwind + Group: 변형(variants)별 재고 합계
    public List<ColorStockStats> getStockByColor() {
        Aggregation agg = Aggregation.newAggregation(
            Aggregation.unwind("variants"),
            Aggregation.group("variants.color")
                .sum("variants.stock").as("totalStock")
                .count().as("variantCount"),
            Aggregation.match(
                Criteria.where("totalStock").gt(0)),
            Aggregation.sort(Sort.Direction.DESC, "totalStock")
        );

        return mongoTemplate
            .aggregate(agg, "products", ColorStockStats.class)
            .getMappedResults();
    }

    // Lookup: 다른 컬렉션 JOIN
    public List<ProductWithReviews> getProductsWithReviews(
            String category) {
        Aggregation agg = Aggregation.newAggregation(
            Aggregation.match(
                Criteria.where("category").is(category)),
            Aggregation.lookup(
                "reviews",          // 조인할 컬렉션
                "_id",              // 로컬 필드
                "productId",        // 외래 필드
                "reviews"),         // 결과 필드명
            Aggregation.addFields()
                .addFieldWithValue("reviewCount",
                    new Document("$size", "$reviews"))
                .build(),
            Aggregation.sort(Sort.Direction.DESC, "reviewCount")
        );

        return mongoTemplate
            .aggregate(agg, "products", ProductWithReviews.class)
            .getMappedResults();
    }
}

인덱스 전략

MongoDB 성능의 핵심은 인덱스입니다. 프로덕션에서는 auto-index-creation: false로 설정하고 마이그레이션으로 인덱스를 관리하세요:

@Configuration
public class MongoIndexConfig {

    @Autowired
    private MongoTemplate mongoTemplate;

    @EventListener(ApplicationReadyEvent.class)
    public void ensureIndexes() {
        IndexOperations ops = mongoTemplate
            .indexOps(Product.class);

        // TTL 인덱스: 30일 후 자동 삭제
        ops.ensureIndex(new Index()
            .on("createdAt", Sort.Direction.ASC)
            .expire(Duration.ofDays(30))
            .named("ttl_created_at"));

        // 부분 인덱스: 특정 조건의 도큐먼트만 인덱싱
        ops.ensureIndex(new Index()
            .on("price", Sort.Direction.ASC)
            .partial(PartialIndexFilter.of(
                Criteria.where("category").is("electronics")))
            .named("price_electronics_partial"));

        // 텍스트 인덱스
        ops.ensureIndex(new TextIndexDefinition.TextIndexDefinitionBuilder()
            .onField("name", 10F)           // 가중치 10
            .onField("category", 5F)        // 가중치 5
            .named("text_search_idx")
            .build());
    }
}

트랜잭션 지원 (Replica Set 필수)

MongoDB 4.0+에서 멀티 도큐먼트 트랜잭션을 지원합니다. Spring Transaction과 동일한 @Transactional을 사용합니다:

@Configuration
public class MongoTransactionConfig {

    @Bean
    MongoTransactionManager transactionManager(MongoDatabaseFactory dbFactory) {
        return new MongoTransactionManager(dbFactory);
    }
}

@Service
@Transactional
public class OrderService {

    public Order placeOrder(CreateOrderDto dto) {
        // 재고 차감
        productRepository.decrementStock(dto.getProductId(), dto.getQty());
        // 주문 생성
        Order order = orderRepository.save(new Order(dto));
        // 결제 기록
        paymentRepository.save(new Payment(order));
        return order;
    }
}

Change Streams: 실시간 변경 감지

@Component
public class ProductChangeListener {

    @Autowired
    private MongoTemplate mongoTemplate;

    @EventListener(ApplicationReadyEvent.class)
    public void watchProducts() {
        MessageListenerContainer container =
            new DefaultMessageListenerContainer(mongoTemplate);
        container.start();

        ChangeStreamRequest<Product> request =
            ChangeStreamRequest.builder()
                .collection("products")
                .filter(Aggregation.newAggregation(
                    Aggregation.match(Criteria.where("operationType")
                        .in("insert", "update"))))
                .publishTo(message -> {
                    Product product = message.getBody();
                    log.info("Product changed: {}", product.getSku());
                    // 캐시 무효화, 알림 전송 등
                })
                .build();

        container.register(request, Product.class);
    }
}

마치며

Spring Data MongoDB는 Repository의 간결함MongoTemplate의 유연함을 모두 제공합니다. 도큐먼트 설계에서 내장 도큐먼트를 적극 활용하여 JOIN을 최소화하고, Aggregation Pipeline으로 복잡한 집계를 처리하세요. 인덱스 전략(TTL, 부분, 텍스트)을 정확히 설계하고, 프로덕션에서는 auto-index-creation을 끄고 마이그레이션으로 관리하는 것이 핵심입니다. Spring Data Redis와 함께 캐시 계층을 구성하면 더욱 강력한 데이터 아키텍처를 구축할 수 있습니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux