MongoDB 핵심 가이드: 아키텍처, CRUD, 집계 파이프라인 및 Spring Data 연동

MongoDB 아키텍처 및 핵심 개념

MongoDB는 고성능, 스키마리스(Schema-less), 문서 지향의 분산 파일 저장 기반 NoSQL 데이터베이스입니다. C++로 작성되었으며, JSON과 유사한 BSON(Binary JSON) 형식을 사용하여 복잡하고 유연한 데이터 구조를 저장할 수 있습니다. 객체 지향 쿼리 언어와 유사한 강력한 쿼리 기능을 제공하며, 단일 테이블 조회의 대부분의 기능을 커버할 뿐만 아니라 인덱싱도 완벽하게 지원합니다.

주요 특징

  • 컬렉션 및 문서 기반 저장: BSON 형식의 데이터를 효율적으로 저장합니다.
  • 동적 스키마: 데이터 형식이 고정되어 있지 않아, 프로덕션 환경에서도 구조 변경이 용이합니다.
  • 복제 및 자동 장애 조치: Replica Set을 통해 높은 가용성을 보장합니다.
  • 수평적 확장성: Sharding Cluster를 통해 대용량 데이터 처리와 확장을 지원합니다.
  • 메모리 매핑 엔진: 디스크 I/O 작업을 메모리 작업으로 전환하여 성능을 극대화합니다.

RDBMS와의 개념 비교

RDBMS 용어 MongoDB 용어 설명
Database Database 데이터베이스
Table Collection 데이터가 저장되는 컬렉션
Row Document JSON 형태의 개별 문서
Column Field 문서를 구성하는 필드(속성)
Join Embedded Document / $lookup 내장 문서 또는 집계 파이프라인을 통한 조인
Primary Key _id 자동으로 생성되는 고유 식별자 (ObjectID)

적용하기 적합한 비즈니스 시나리오

MongoDB는 모든 문제를 해결하는 만능 도구는 아니지만, 특정 상황에서는 RDBMS 대비 훨씬 낮은 비용과 높은 효율을 제공합니다.

  • 빠른 반복 개발: 초기 서비스나 데이터 모델이 자주 변경되는 신생 프로젝트.
  • 대용량 데이터 및 높은 트래픽: TB/PB 급 데이터 저장과 수천 이상의 QPS를 요구하는 시스템.
  • 유연한 데이터 구조: 로그 데이터, IoT 센서 데이터, 카탈로그 등 스키마가 일정하지 않은 데이터.
  • 지리공간 및 텍스트 검색: 위치 기반 서비스(LBS)나 전문 검색이 필요한 애플리케이션.

반면, 다중 테이블 간의 복잡한 조인이나 엄격한 ACID 트랜잭션이 필수적인 금융/회계 시스템에는 적합하지 않습니다.

도커(Docker)를 활용한 환경 구축

개발 및 테스트 환경을 빠르게 구축하기 위해 Docker를 사용하는 것이 가장 효율적입니다.

docker run -d \
--name mongo-cluster-node \
-p 27017:27017 \
--restart=unless-stopped \
-v mongo_data_volume:/data/db \
-e MONGO_INITDB_ROOT_USERNAME=admin_user \
-e MONGO_INITDB_ROOT_PASSWORD=SecurePass!2023 \
mongo:6.0

# 컨테이너 내부 접속 및 인증
docker exec -it mongo-cluster-node mongosh -u "admin_user" -p "SecurePass!2023" --authenticationDatabase "admin"

# 데이터베이스 목록 확인
> show dbs
admin   40.00 KiB
config  12.00 KiB
local   40.00 KiB

기본 데이터 조작 (CRUD)

데이터베이스 및 컬렉션 관리

MongoDB는 데이터를 삽입할 때 데이터베이스와 컬렉션이 자동으로 생성되는 지연 생성(Lazy Creation) 방식을 사용합니다.

# 데이터베이스 전환 및 자동 생성
use company_db

# 컬렉션 명시적 생성
db.createCollection("employee")

# 컬렉션 및 데이터베이스 삭제
db.employee.drop()
db.dropDatabase()

문서 삽입 (Insert)

단일 문서는 insertOne, 다중 문서는 insertMany를 사용합니다.

db.employee.insertOne({
    emp_id: 101,
    full_name: "Alice Smith",
    department: "Engineering",
    years_of_service: 3,
    skills: ["Java", "MongoDB", "Spring"]
})

# _id 필드는 12바이트의 ObjectID로 자동 생성되며, 시간, 머신 식별자, 프로세스 ID, 카운터로 구성됩니다.

문서 수정 (Update)

업데이트 연산자를 사용하여 특정 필드만 원자적으로 수정할 수 있습니다.

연산자설명
$set특정 필드의 값을 지정된 값으로 변경
$inc숫자형 필드의 값을 증가 또는 감소
$push배열 필드에 새로운 요소 추가
$pull배열 필드에서 조건에 맞는 요소 제거
$unset문서에서 특정 필드 자체를 삭제
# 부서 변경 및 근속 연수 1년 증가
db.employee.updateOne(
    { emp_id: 101 },
    { 
        $set: { department: "R&D" },
        $inc: { years_of_service: 1 },
        $push: { skills: "Kubernetes" }
    }
)

# 조건에 맞는 모든 문서 업데이트
db.employee.updateMany(
    { department: "Engineering" },
    { $set: { status: "active" } }
)

문서 삭제 (Delete)

# 조건에 맞는 단일 문서 삭제
db.employee.deleteOne({ emp_id: 101 })

# 조건에 맞는 다중 문서 삭제
db.employee.deleteMany({ years_of_service: { $lt: 1 } })

고급 조회 (Query)

다양한 비교 및 논리 연산자를 사용하여 정교한 쿼리를 작성할 수 있습니다.

# 근속 연수가 3년 이상이고 부서가 R&D인 직원 조회
db.employee.find({
    years_of_service: { $gte: 3 },
    department: "R&D"
})

# 특정 스킬을 보유한 직원 조회 (배열 요소 매칭)
db.employee.find({ skills: { $in: ["Java", "Python"] } })

# 페이징 및 정렬 (근속 연수 내림차순, 10개 건너뛰고 5개 가져오기)
db.employee.find()
    .sort({ years_of_service: -1 })
    .skip(10)
    .limit(5)

# 특정 필드만 투영(Project)하여 조회 (비밀번호 등 민감 정보 제외)
db.employee.find({ department: "R&D" }, { full_name: 1, department: 1, _id: 0 })

인덱스 및 지리공간(Geospatial) 쿼리

인덱스는 조회 성능을 비약적으로 향상시킵니다. 특히 MongoDB는 위치 기반 서비스를 위한 2dsphere 인덱스를 강력하게 지원합니다.

# 단일 필드 인덱스 생성 (1: 오름차순, -1: 내림차순)
db.employee.createIndex({ emp_id: 1 })

# 지리공간 인덱스 생성
db.store.createIndex({ geo_coords: "2dsphere" })

# 매장 데이터 삽입
db.store.insertMany([
    { name: "Seoul Central", geo_coords: { type: "Point", coordinates: [126.978, 37.566] } },
    { name: "Busan Port", geo_coords: { type: "Point", coordinates: [129.042, 35.101] } }
])

지리공간 쿼리 실행

# 특정 좌표에서 반경 5km 이내의 매장 찾기
db.store.find({
    geo_coords: {
        $near: {
            $geometry: { type: "Point", coordinates: [126.978, 37.566] },
            $maxDistance: 5000
        }
    }
})

# 특정 다각형 영역 내에 포함된 매장 찾기
db.store.find({
    geo_coords: {
        $geoWithin: {
            $geometry: {
                type: "Polygon",
                coordinates: [[[126.9, 37.5], [127.0, 37.5], [127.0, 37.6], [126.9, 37.6], [126.9, 37.5]]]
            }
        }
    }
})

벌크 쓰기 작업 (Bulk Write)

네트워크 오버헤드를 줄이기 위해 여러 쓰기 작업을 한 번에 처리할 수 있습니다.

db.employee.bulkWrite([
    { insertOne: { document: { emp_id: 105, full_name: "Charlie", department: "HR" } } },
    { updateOne: { 
        filter: { emp_id: 101 }, 
        update: { $set: { status: "inactive" } } 
    }},
    { deleteOne: { filter: { emp_id: 99 } } }
], { ordered: false }) # ordered: false는 순차적 실행을 강제하지 않아 성능이 더 좋습니다.

집계 파이프라인 (Aggregation Pipeline)

집계 파이프라인은 UNIX의 파이프 개념과 유사하게, 데이터를 여러 단계(Stage)를 거치며 변환하고 분석하는 강력한 프레임워크입니다.

# 상태가 'completed'인 거래 내역을 item_code별로 그룹화하여 총 수량을 계산하고 내림차순 정렬
db.transactions.aggregate([
    { $match: { state: "completed" } },
    { $group: { 
        _id: "$item_code", 
        total_sales: { $sum: "$quantity" },
        avg_price: { $avg: "$price" }
    }},
    { $sort: { total_sales: -1 } },
    { $limit: 10 }
])

$lookup을 활용한 조인(Join) 연산

다른 컬렉션의 데이터를 참조하여 RDBMS의 JOIN과 유사한 결과를 도출할 수 있습니다.

db.transactions.aggregate([
    { $match: { state: "completed" } },
    { $lookup: {
        from: "items_catalog",
        localField: "item_code",
        foreignField: "sku",
        as: "item_details"
    }},
    { $unwind: "$item_details" },
    { $project: { 
        transaction_id: 1, 
        item_name: "$item_details.name", 
        quantity: 1 
    }}
])

Spring Data MongoDB 연동

Spring Boot 환경에서는 spring-data-mongodb를 활용하여 객체 지향적인 방식으로 MongoDB를 제어할 수 있습니다.

의존성 및 설정

<!-- build.gradle -->
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
# application.yml
spring:
  data:
    mongodb:
      uri: mongodb://admin_user:SecurePass!2023@localhost:27017/company_db?authSource=admin
      auto-index-creation: true

엔티티(Entity) 설계

package kr.tech.nosql.mongo.domain;

import lombok.*;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.geo.GeoJsonPoint;
import org.springframework.data.mongodb.core.index.GeoSpatialIndexType;
import org.springframework.data.mongodb.core.index.GeoSpatialIndexed;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.core.mapping.Field;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "customers")
public class Customer {
    @Id
    private ObjectId customerId;

    @Indexed(unique = true)
    private String email;

    @Field("years_as_member")
    private int membershipYears;

    @GeoSpatialIndexed(type = GeoSpatialIndexType.GEO_2DSPHERE)
    private GeoJsonPoint currentLocation;

    private ShippingInfo shippingInfo;
}
package kr.tech.nosql.mongo.domain;

import lombok.*;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ShippingInfo {
    private String postalCode;
    private String city;
    private String detailAddress;
}

서비스 레이어 및 MongoTemplate 활용

package kr.tech.nosql.mongo.service;

import kr.tech.nosql.mongo.domain.Customer;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;

import java.util.List;

@Service
@RequiredArgsConstructor
public class CustomerServiceImpl implements CustomerService {
    
    private final MongoTemplate mongoTemplate;

    @Override
    public void registerCustomer(Customer customer) {
        mongoTemplate.insert(customer);
    }

    @Override
    public void updateMembershipYears(ObjectId id, int additionalYears) {
        Query query = Query.query(Criteria.where("_id").is(id));
        Update update = new Update().inc("years_as_member", additionalYears);
        mongoTemplate.updateFirst(query, update, Customer.class);
    }

    @Override
    public List<Customer> searchByEmail(String email) {
        Query query = Query.query(Criteria.where("email").regex(email, "i"));
        return mongoTemplate.find(query, Customer.class);
    }

    @Override
    public void removeCustomer(ObjectId id) {
        Query query = Query.query(Criteria.where("_id").is(id));
        mongoTemplate.remove(query, Customer.class);
    }
}

통합 테스트

package kr.tech.nosql.mongo.service;

import kr.tech.nosql.mongo.domain.Customer;
import kr.tech.nosql.mongo.domain.ShippingInfo;
import org.bson.types.ObjectId;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.mongodb.core.geo.GeoJsonPoint;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class CustomerServiceIntegrationTest {

    @Autowired
    private CustomerService customerService;

    @Test
    @DisplayName("신규 고객 등록 및 정보 업데이트 테스트")
    void testRegisterAndUpdateCustomer() {
        Customer newCustomer = Customer.builder()
                .customerId(ObjectId.get())
                .email("tech.writer@example.com")
                .membershipYears(1)
                .currentLocation(new GeoJsonPoint(126.9786567, 37.566826))
                .shippingInfo(new ShippingInfo("04524", "Seoul", "110 Sejong-daero"))
                .build();

        customerService.registerCustomer(newCustomer);
        
        customerService.updateMembershipYears(newCustomer.getCustomerId(), 2);
        
        List<Customer> result = customerService.searchByEmail("tech.writer");
        assertThat(result).isNotEmpty();
        assertThat(result.get(0).getMembershipYears()).isEqualTo(3);
        
        customerService.removeCustomer(newCustomer.getCustomerId());
    }
}

태그: MongoDB NoSQL spring-data-mongodb aggregation-pipeline geospatial-query

6월 2일 22:44에 게시됨