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());
}
}