문서 설계와 트랜잭션문서 설계와 트랜잭션

MongoDB를 효과적으로 활용하기 위해서는 도메인별로 적절한 문서 구조를 설계하는 것이 중요하다. 실제 서비스 사례를 통해 문서 설계 전략을 살펴보자

컨텐츠 관리 시스템 (CMS)

CMS는 MongoDB의 유연한 스키마를 활용하기에 적합한 대표적인 사례이다

초기 설계안 – 단일 문서 구조

// posts collection
{
  "_id": ObjectId("..."),
  "title": "MongoDB 완벽 가이드",
  "content": "MongoDB는 문서 지향 데이터베이스입니다...",
  "author": {
    "name": "홍길동",
    "email": "hong@example.com"
  },
  "tags": ["MongoDB", "NoSQL", "Database"],
  "comments": [
    {
      "user": "임꺽정",
      "text": "MongoDB는 정말 유용하네요!",
      "date": ISODate("2025-12-05T10:30:00Z")
    }
  ],
  "metadata": {
    "views": 1000,
    "likes": 100,
    "featured": true
  },
  "published_date": ISODate("2024-12-05T09:00:00Z")
}
이 구조의 장점
  • 한 번에 쿼리로 모든 정보 조회 가능
  • 포스트와 관련된 모든 데이터가 하나의 문서에 존재
  • JOIN 불필요
이 구조의 문제점

댓글이 급증하는 경우 심각한 성능 저하가 발생한다

  • 문서 크기 증가: 인기 게시물에 수백 ~ 수천 개의 댓글이 달리면 문서 크기가 MongoDB의 16MB 제한에 근접할 수 있다
  • 비효율적인 쿼리: “내가 작성한 댓글 보기”같은 기능 구현 시, 모든 포스트 문서를 스캔해야 한다
  • 업데이트 비용: 댓글 하나를 추가할 때마다 전체 문서를 다시 써야 한다
// 비효율적인 쿼리 예시
db.posts.find({
  "comments.user": "홍길동"
})
// 모든 포스트를 조회하여 댓글 배열을 검사해야 함
개선된 설계안 – 컬렉션 분리
// posts collection
{
  "_id": ObjectId("..."),
  "title": "MongoDB 완벽 가이드",
  "content": "MongoDB는 문서 지향 데이터베이스입니다...",
  "author": {
    "name": "홍길동",
    "email": "hong@example.com"
  },
  "tags": ["MongoDB", "NoSQL", "Database"],
  "metadata": {
    "views": 1000,
    "likes": 100,
    "featured": true,
    "comment_count": 42  // 댓글 수 캐싱
  },
  "published_date": ISODate("2024-12-05T09:00:00Z")
}

// comments collection
{
  "_id": ObjectId("..."),
  "post_id": ObjectId("..."),  // posts._id 참조
  "user_id": ObjectId("..."),
  "user_name": "임꺽정",
  "text": "MongoDB는 정말 유용하네요!",
  "date": ISODate("2025-12-05T10:30:00Z"),
  "likes": 5
}
개선 효과
  • 사용자별 댓글 조회 최적화
  • 문서 크기 제한 문제 해결
  • 댓글에 대한 독립적인 인덱싱 가능
// 효율적인 쿼리
db.comments.find({ user_id: ObjectId("...") })
  .sort({ date: -1 })
  .limit(20);

이커머스 (전자상거래)

제품 카탈로그는 다양한 속성과 변형을 가지므로 MongoDB의 유연성이 빛을 발한다

// products collection
{
  "_id": ObjectId("..."),
  "name": "갤럭시 Z 폴드 7",
  "price": 2000000,
  "category": "전자기기",
  "subcategory": "스마트폰",
  "description": "얇고 가볍고 접히는 혁신적인 폴더블 스마트폰",
  "specifications": {
    "display": "7.6인치 AMOLED",
    "processor": "Snapdragon 8 Gen 3",
    "camera": "200MP 트리플 카메라",
    "battery": "4400mAh"
  },
  "variants": [
    {
      "color": "팬텀 블랙",
      "storage": "256GB",
      "stock": 100,
      "sku": "ZF7-BLK-256"
    },
    {
      "color": "실버",
      "storage": "512GB",
      "stock": 50,
      "sku": "ZF7-SLV-512"
    }
  ],
  "images": [
    "https://cdn.example.com/zf7-main.jpg",
    "https://cdn.example.com/zf7-side.jpg"
  ],
  "related_products": [
    ObjectId("..."),
    ObjectId("...")
  ],
  "created_at": ISODate("2024-11-01T00:00:00Z"),
  "updated_at": ISODate("2025-12-05T14:30:00Z")
}

리뷰 데이터 설계 고려 사항

임베딩 방식 (비추천)
"reviews": [
  {
    "user_id": ObjectId("..."),
    "rating": 5,
    "comment": "화면이 크고 성능이 뛰어나요!"
  }
  // 수백~수천 개의 리뷰가 쌓이면 문제 발생
]
분리 방식 (권장)
// reviews collection (별도)
{
  "_id": ObjectId("..."),
  "product_id": ObjectId("..."),
  "user_id": ObjectId("..."),
  "user_name": "홍길동",
  "rating": 5,
  "comment": "화면이 크고 성능이 뛰어나요!",
  "helpful_count": 42,
  "images": ["review1.jpg"],
  "verified_purchase": true,
  "created_at": ISODate("2025-12-01T10:00:00Z")
}

// products collection에는 요약 정보만
"review_summary": {
  "average_rating": 4.7,
  "total_count": 1250,
  "rating_distribution": {
    "5": 850,
    "4": 250,
    "3": 100,
    "2": 30,
    "1": 20
  }
}

소셜 미디어

사용자 프로필과 활동 데이터는 특히 설계가 중요하다

// users collection
{
  "_id": ObjectId("..."),
  "username": "mister_hong",
  "name": "홍길동",
  "email": "hong@example.com",
  "profile_pic": "https://cdn.example.com/hong.jpg",
  "bio": "소셜 미디어 전문가",
  "verified": true,
  "stats": {
    "followers_count": 1500,
    "following_count": 300,
    "posts_count": 250
  },
  "created_at": ISODate("2023-01-15T00:00:00Z")
}

Followers/Following 설계 전략

소규모서비스 (임베딩 방식)
"followers": [
  ObjectId("user1"),
  ObjectId("user2"),
  // ... 수백 명 정도까지는 괜찮음
]
대규모 서비스 (분리 방식 – 권장)
// follows collection
{
  "_id": ObjectId("..."),
  "follower_id": ObjectId("..."),  // 팔로우하는 사람
  "following_id": ObjectId("..."), // 팔로우 당하는 사람
  "created_at": ISODate("2025-01-10T12:00:00Z")
}

// 복합 인덱스 생성
db.follows.createIndex({ follower_id: 1, created_at: -1 });
db.follows.createIndex({ following_id: 1, created_at: -1 });
하이브리드 방식 (Redis + MongoDB)

실시간성이 중요한 팔로우 관계는 Redis에서 관리하고, 영구 저장용으로 MongoDB를 사용하는 방식도 효과적이다

// Redis (캐시)
SADD user:123:followers user:456
SADD user:123:followers user:789

// MongoDB (영구 저장소)
// 위의 follows collection 구조 사용

게시물 설계

// posts collection (분리 권장)
{
  "_id": ObjectId("..."),
  "user_id": ObjectId("..."),
  "content": "오늘은 MongoDB를 공부했어요!",
  "media": [
    {
      "type": "image",
      "url": "https://cdn.example.com/post-image.jpg",
      "width": 1080,
      "height": 1080
    }
  ],
  "hashtags": ["MongoDB", "개발", "공부"],
  "stats": {
    "likes_count": 100,
    "comments_count": 15,
    "shares_count": 5
  },
  "created_at": ISODate("2025-12-05T15:30:00Z")
}

// likes collection (별도 관리)
{
  "_id": ObjectId("..."),
  "post_id": ObjectId("..."),
  "user_id": ObjectId("..."),
  "created_at": ISODate("2025-12-05T16:00:00Z")
}

// 복합 인덱스로 중복 좋아요 방지
db.likes.createIndex(
  { post_id: 1, user_id: 1 },
  { unique: true }
);

문서 설계 원칙 정리

임베딩 (Embedding)을 선택하는 경우
  • 1:Few 관계 (1: 수십 정도)
  • 자식 데이터가 부모 없이 독립적으로 조회되지 않는 경우
  • 데이터가 함께 조회/업데이트되는 경우
  • 자식 데이터 성장이 제한적인 경우
참조(Referencing)을 선택하는 경우
  • 1:Many 또는 Many:Many 관계
  • 자식 데이터가 독립적으로 조회되는 경우
  • 데이터 중복을 피해야 되는 경우
  • 문서 크기가 16MB에 근접할 위험이 있는 경우

JSON VS BSON

JSON (JavaScript Object Notation)

  • 텍스트 기반 (UTF-8 인코딩)
  • 사람이 읽기 쉬운 형식
  • 언어 독립적인 데이터 교환 포맷
  • 경량 구조

지원 데이터 타입

  • String,
  • Number (정수 / 실수 구분 없음)
  • Boolean
  • Array
  • Object
  • null

JSON 예시

{
  "name": "홍길동",
  "age": 30,
  "active": true,
  "created": "2025-12-05T10:00:00Z"
}

BSON (Binary JSON)

  • 바이너리 형식
  • 기계 최적화된 인코딩
  • 빠른 직렬화/역직렬화
  • 풍부한 데이터 타입 지원
  • 효율적인 탐색 (필드 길이 정보 포함)

추가지원 데이터 타입

  • ObjectId: 12바이트 고유 식별자
  • Date: ISO 8601 날짜 / 시간
  • Binary: 바이너리 데이터
  • Int32, Int64: 명시적 정수 타입
  • Double: 64비트 부동소수점
  • Decimal128: 고정밀 십진수
  • Timestamp: 내부 복제용 타임스탬프
  • Regular Expression: 정규식 표현

BSON 예시

{
  "_id": ObjectId("675217a5f8d6c5a44e123456"),
  "name": "홍길동",
  "age": NumberInt(30),
  "balance": NumberDecimal("1000000.50"),
  "active": true,
  "created_at": ISODate("2025-12-05T10:00:00.000Z"),
  "profile_image": BinData(0, "iVBORw0KGgoAAAANS...")
}

JSON vs BSON 비교

특성JSONBSON
인코딩UTF-8 텍스트바이너리
가독성사람과 기계 모두기계만
파싱 속도상대적으로 느림빠름
크기작은 편필드명 포함으로 더 클 수 있음
데이터 타입제한적 (6개)풍부함 (15개)
탐색 효율성전체 파싱 필요길이 정보로 건너뛰기 가능
사용 사례API 통신, 설정 파일데이터베이스 저장, 내부 통신

MongoDB에서의 활용

MongoDB는 내부적으로 BSON을 사용하지만, 드라이버가 자동 변환을 처리한다

// 애플리케이션 코드 (JSON 스타일)
const user = {
  name: "홍길동",
  email: "hong@example.com",
  created: new Date()
};

db.users.insertOne(user);

// MongoDB 내부 저장 (BSON)
{
  "_id": ObjectId("..."),  // 자동 생성
  "name": "홍길동",
  "email": "hong@example.com",
  "created": ISODate("2025-12-05T10:30:00.000Z")
}

개발자가 알아야 할 핵심

  • 일반적인 개발에서는 JSON 방식으로 코드 작성
  • MongoDB 드라이버가 BSON 변환을 자동 처리
  • 날짜, ObjectId 등 특수 타입 사용 시 BSON 인식 필요
  • 대부분의 경우 타입 변환을 명시적으로 관리할 필요 없음

ObjectId

12바이트 구조의 고유 식별자

[4바이트: Timestamp][5바이트: Random Value][3바이트: Counter]
특징
  • 자동 생성 (명시하지 않을 경우)
  • 분산 환경에서 충돌 없는 고유성 보장
  • 생성 시간 정보 내장
const id = ObjectId("675217a5f8d6c5a44e123456");

// 생성 시간 추출
id.getTimestamp();  // 2025-12-05T10:00:00.000Z

// 시간순 정렬 가능
db.posts.find().sort({ _id: -1 });  // 최신순
주의 사항
  • ObjectId는 문서 생성 시점의 타임스탬프를 포함
  • 업데이트해도 _id는 변경되지 않음

ISODate

ISO 8601 형식의 날짜/시간
{
  "published_at": ISODate("2025-12-05T14:30:00.000Z"),
  "updated_at": ISODate("2025-12-06T09:15:30.500Z")
}

// 날짜 범위 쿼리
db.posts.find({
  published_at: {
    $gte: ISODate("2025-12-01T00:00:00Z"),
    $lt: ISODate("2025-12-31T23:59:59Z")
  }
});

GirdFS

16MB를 초과하는 파일을 저장할 때 사용하는 MongoDB의 파일 저장 시스템이다

동작 방식
  • 파일을 256KB 청크로 분할
  • fs.files와 fs.chunks 컬렉션에 저장
  • 스트리밍 업로드/다운로드 지원

MongoDB 트랜잭션 (ACID)

MongoDB 4.0 이전에는 단일 문서 수준의 원자성만 보장하였고 다중 문서 트랜잭션을 지원하지 않았다. 4.x이후(2018년 7월 즘)부터 다중 문서 트랜잭션을 지원하였다

트랜잭션 사용 요구사항

  • MongoDB 버전: 4.0이상
  • 클러스터 구성: 레플리카 셋 또는 샤딩된 클러스터
  • 스토리지 엔진: WiredTiger (4.0 이상에서 기본값)

레플리카 셋이 필요한 이유

데이터 무결성 보장

  • Standalone (단일 노드)에서는 장애 발생 시 데이터 복구가 불가능하다
Standalone 서버 장애 발생
  ↓
데이터 손실 (복구 불가)
  ↓
서비스 중단
  • 레플리카 셋에서는 트랜잭션 데이터가 복제되어 안전하다
Primary 장애 발생
  ↓
Secondary가 Primary로 승격 (자동)
  ↓
트랜잭션 데이터 보존
  ↓
서비스 지속

Oplog 기반 복제

  • MongoDB는 Oplog (Operation Log)를 통해 데이터를 복제한다
// Primary 노드의 Oplog
{
  "ts": Timestamp(1733472000, 1),
  "op": "i",  // insert
  "ns": "mydb.users",
  "o": { "_id": ObjectId("..."), "name": "홍길동" }
}
  • Standalone 노드는 Oplog를 생성하지 않음
  • 트랜잭션은 Oplog를 기반으로 원자성을 보장

트랜잭션 로그 관리

  • 레플리카 셋 환경에서만 트랜잭션 로그를 안전하게 관리하고 복제할 수 있다

ACID 속성

속성설명MongoDB 구현
Atomicity (원자성)모두 성공 또는 실패트랜잭션 롤백 지원
Consistency (일관성)데이터 무결성 규칙 유지스키마 검증, 인덱스 제약조건
Isolation (격리성)동시 실행 트랜잭션 간 독립성Snapshot Isolation
Durability (지속성)커밋된 데이터 영구 보존레플리카 셋 복제, Journal

격리 수준 (Isolcation Level)

Read Committed (기본값)
  • 커밋된 데이터만 읽을 수 있다
// 트랜잭션 A
session.startTransaction();
db.accounts.updateOne(
  { _id: "account1" },
  { $inc: { balance: -100 } }
);
// 아직 커밋 안 함

// 트랜잭션 B (동시 실행)
db.accounts.findOne({ _id: "account1" });
// 트랜잭션 A의 변경사항은 보이지 않음 (커밋 전)

session.commitTransaction();
// 이제 트랜잭션 B에서 변경사항 확인 가능
Snapshot Isolation
  • 트랜잭션이 시작된 시점의 데이터 스냅샷을 기준으로 동작한다
// 시간 T1: 초기 상태
{ "_id": "account1", "balance": 1000 }

// 트랜잭션 X 시작 (T2)
const sessionX = client.startSession();
sessionX.startTransaction();
// 스냅샷 생성: { balance: 1000 }

// 트랜잭션 Y 시작 (T3)
const sessionY = client.startSession();
sessionY.startTransaction();
// 스냅샷 생성: { balance: 1000 }

// T4: 트랜잭션 X가 잔액 변경
db.accounts.updateOne(
  { _id: "account1" },
  { $set: { balance: 900 } },
  { session: sessionX }
);
sessionX.commitTransaction();  // 커밋 성공

// T5: 트랜잭션 Y도 잔액 변경 시도
db.accounts.updateOne(
  { _id: "account1" },
  { $set: { balance: 800 } },
  { session: sessionY }
);
sessionY.commitTransaction();  // WriteConflict 에러 발생!

Write Conflict 문제와 해결

Write Conflict 발생 시나리오
초기 상태: { "_id": 1, "stock": 100 }

트랜잭션 A (스냅샷: stock=100)
  ↓ stock을 80으로 변경
  ↓ 커밋 성공
현재 상태: { "stock": 80 }

트랜잭션 B (스냅샷: stock=100)
  ↓ stock을 90으로 변경하려 시도
  ↓ 스냅샷과 현재 데이터 불일치 감지
  ↓ WriteConflict 에러 발생
해결 방법
  • 자동 재시도
  • 트랜잭션 범위 최소화
  • 낙관적 잠금

MongoDB의 실무 활용은 올바른 문서 설계에서 시작된다. 데이터 특성과 엑세스 패턴을 고려하여 임베딩과 참조를 적절히 조합하고, BSON의 풍부한 타입 시스템을 활용하며, 필요한 경우에만 트랜잭션을 사용하는 것이 핵심이다. 특히 급증하는 데이터(댓글, 리뷰, 팔로워 등)는 별도 컬렉션으로 관리하고, 트랜잭션은 금융 거래나 재고 관리 등 데이터 무결성이 중요한 경우에만 제한적으로 사용하는 것이 좋다

출처 – 가장 쉽고 깊게 알려주는 MongoDB 완벽 가이드 [ By. 비전공자 & Kakao 개발자 ]