HOME INFO PROJECT BLOG ESSAY
Article Projects Lab
한국어 English
article |

MongoDB는 트랜잭션을 어떻게 보장할까

개요

지난 글 의 연장선임을 밝히기는 하지만 읽지 않아도 무방합니다.

NoSQL이라는 용어때문에 헷갈릴 수 있는데 Non-SQL, Not Only SQL 이 두 정의로 나눠져서 구체적인 정의는 없지만, 후자의 설명을 따르자면 데이터 저장 시 SQL 외에도 다른 방법으로 저장할 수 있다는 의미를 나타내기도 합니다.

다소 폭력적인(?) 어감때문에 처음에는 MongoDB가 트랜잭션을 지원하지 않는 줄 알았는데 Document 형태로 저장될 뿐 트랜잭션을 지원한다는 사실을 알았습니다.

이제 MongoDB가 어떻게 트랜잭션을 지원하는지 알아볼 것입니다.

잠깐 짚고가는 RDB

글의 메인 주제는 MongoDB지만 후에 등장하는 개념 이해를 위해 RDB에서 보장하는 ACID를 짚고 가겠습니다.

ACID는 데이터베이스 트랜잭션이 안전하게 처리되기 위해 갖춰야 하는 특성을 말합니다.

속성의미예시
Atomicity(원자성)트랜잭션 안의 작업은 모두 성공하거나 모두 실패해야 한다.ex) 재고 차감은 성공했는데 예약 저장만 실패하는 상태를 남기지 않는다.
Consistency(일관성)트랜잭션 전후에 데이터는 정해둔 제약과 규칙을 만족해야 한다.ex) 잔여 객실 수가 음수가 되지 않아야 한다.
Isolation(격리성)동시에 실행되는 트랜잭션끼리 서로의 상태를 간섭할 수 없다.ex) 다른 요청이 아직 커밋되지 않은 예약을 확정된 예약처럼 읽지 않는다.
Durability(지속성)커밋된 결과는 장애가 나도 사라지지 않아야 한다.ex) 예약 생성 응답을 받은 뒤 DB가 재시작되어도 예약은 남아 있어야 한다.

예를 들어 RDB에서 예약을 생성한다고 하면:

BEGIN;

UPDATE room_inventory
SET remaining = remaining - 1
WHERE room_type_id = 1
  AND stay_date = '2026-05-01'
  AND remaining > 0;

INSERT INTO reservations (id, room_type_id, check_in, check_out)
VALUES (100, 1, '2026-05-01', '2026-05-02');

COMMIT;

이 때 트랜잭션의 원자성을 보장하기 위해 UPDATEINSERT가 하나의 작업으로 묶여야 합니다.

둘 중 하나의 작업만 반영된다면 예약이라는 완전한 비즈니스 작업이 진행되지 않습니다. 그래서 RDB에 익숙한 개발자는 “예약 생성에는 트랜잭션이 필요하다”는 판단부터 하게 됩니다.

MongoDB에서도 이 문제는 사라지지 않습니다. 다만 MongoDB는 먼저 질문을 바꿉니다.

이 변경을 하나의 도큐먼트 안에서 끝낼 수 있는가?

MongoDB의 단일 도큐먼트 시스템

MongoDB 공식 문서에서는 단일 도큐먼트에 대한 쓰기 작업이 원자적이라고 설명합니다.

한 도큐먼트 안의 여러 필드를 한 번에 바꾸는 작업은 중간 상태를 남기지 않습니다.

db.orders.updateOne(
  { _id: "order-1", status: "PENDING" },
  {
    $set: { status: "CONFIRMED" },
    $inc: { reservedQuantity: 1 }
  }
)

위 작업은 status만 바뀌고 reservedQuantity는 안 바뀌는 식으로 끝나지 않습니다.

필터 조건에 맞는 도큐먼트 하나에 대해 변경이 하나의 원자 작업으로 적용됩니다.

여기서 원자성의 기본 경계가 “컬렉션”이나 “여러 도큐먼트”가 아니라 하나의 도큐먼트라는 것입니다.

updateMany()처럼 여러 도큐먼트를 바꾸는 명령도 각 도큐먼트에 대한 변경은 원자적이지만, 여러 도큐먼트 전체가 하나의 원자 단위가 되는 것은 아닙니다. 그 전체를 하나로 묶으려면 멀티 도큐먼트 트랜잭션이 필요합니다.

WiredTiger 스토리지 엔진

MongoDB 3.2+의 기본 스토리지 엔진인 WiredTiger가 도큐먼트 단위 동시성 제어(document-level concurrency control)를 제공합니다.

MySQL과 비교하자면:

구분MySQL InnoDBMongoDB WiredTiger
잠금 단위행(row)도큐먼트(document)
MVCC있음있음
저널링redo logWAL (Write-Ahead Log)

‘도큐먼트 단위 동시성 제어’라는 말은 서로 다른 도큐먼트를 수정하는 write가 같은 컬렉션 안에서도 병렬로 진행될 수 있다는 뜻입니다.

과거의 컬렉션 단위 잠금보다 경합 범위가 훨씬 작습니다.

물론 모든 잠금이 사라지는 것은 아닙니다. MongoDB는 global, database, collection 레벨에 intent lock을 사용하고, 실제 write 경합은 WiredTiger가 도큐먼트 단위에서 조정합니다. 두 요청이 같은 도큐먼트를 동시에 바꾸려고 하면 write conflict가 발생할 수 있고, MongoDB는 일부 단일 write 작업을 내부적으로 재시도합니다. 트랜잭션 안에서 충돌이 나면 애플리케이션이 트랜잭션 자체를 다시 시도해야 할 수도 있습니다.

WiredTiger의 MVCC(Multi-Version Concurrency Control)는 읽기 작업이 쓰기를 차단하지 않도록 돕습니다.

쓰기가 진행 중이더라도, 읽기 요청은 자신이 읽는 시점에 맞는 스냅샷을 볼 수 있습니다. 그래서 MongoDB의 기본 전략은 “읽기와 쓰기를 최대한 덜 막고, 충돌은 작은 단위에서 처리한다”에 가깝습니다.

도큐먼트 설계가 트랜잭션을 줄인다

RDB에서는 정규화로 인해 여러 테이블에 분산되는 데이터를, MongoDB에서는 하나의 도큐먼트에 임베딩할 수 있습니다.

이것이 “트랜잭션 없이도 일관성을 유지”하는 핵심 전략입니다.

RDB (3개 테이블, 트랜잭션 필요):
  INSERT INTO orders (...) VALUES (...);
  INSERT INTO order_items (...) VALUES (...);
  INSERT INTO order_recipients (...) VALUES (...);
  COMMIT;

MongoDB (1개 도큐먼트, 트랜잭션 불필요):
  db.orders.insertOne({
    items: [...],
    recipient: {...}
  })

예를 들어 주문과 주문 항목은 보통 함께 생성되고 함께 조회됩니다. RDB에서는 orders, order_items, order_recipients를 각각 저장하고 트랜잭션으로 묶습니다. MongoDB에서는 주문 도큐먼트 안에 항목과 수령자 정보를 함께 넣을 수 있습니다.

db.orders.insertOne({
  _id: "order-1",
  memberId: "member-1",
  status: "CREATED",
  items: [
    { productId: "product-1", name: "샴푸", quantity: 2, price: 12000 },
    { productId: "product-2", name: "린스", quantity: 1, price: 9000 }
  ],
  recipient: {
    name: "홍길동",
    phone: "010-0000-0000",
    address: "서울시 ..."
  }
})

이 경우 주문 생성은 insertOne() 한 번으로 끝나며, 항목 하나만 들어가고 수령자 정보는 누락되는 중간 상태가 생기지 않습니다.

MongoDB 공식 문서도 여러 상황에서 비정규화된 데이터 모델, 즉 embedded documents와 arrays가 멀티 도큐먼트 트랜잭션보다 더 적합할 수 있다고 설명합니다. 여기서 중요한 표현은 “트랜잭션을 안 쓴다”가 아니라 트랜잭션 경계를 도큐먼트 안으로 옮긴다입니다.

하지만 이 방식에는 비용이 있습니다.

선택좋아지는 점부담
임베드한 번에 읽기 쉽고, 단일 도큐먼트 원자성을 활용할 수 있습니다.데이터 중복, 큰 도큐먼트, 배열 증가 관리가 필요합니다.
레퍼런스중복이 줄고, 독립적으로 자주 바뀌는 데이터를 다루기 쉽습니다.조회가 여러 번 필요하거나 $lookup, 애플리케이션 조립, 트랜잭션이 필요할 수 있습니다.

예를 들어 상품명은 주문 시점의 스냅샷으로 주문 도큐먼트에 복사해도 괜찮습니다. 나중에 상품명이 바뀌어도 과거 주문 내역은 당시 이름을 보여주는 것이 자연스럽습니다.

반대로 회원의 포인트 잔액, 객실 재고, 결제 상태처럼 여러 업무 흐름에서 계속 바뀌는 값은 무작정 임베드하면 중복 갱신 문제가 생깁니다. 이런 데이터는 별도 도큐먼트로 두고, 필요할 때 멀티 도큐먼트 트랜잭션이나 보상 로직을 고려하는 편이 낫습니다.

2층: Read/Write Concern

RDB에서는 격리 수준(Isolation Level)으로 일관성을 조절합니다. MongoDB에서는 Read ConcernWrite Concern이라는 두 축으로 이를 제어합니다.

Write Concern: “몇 개 노드에 쓰여야 성공으로 볼 것인가”

Write Concern: { w: 1 }
  Primary에 쓰기 완료 -> 즉시 응답
  -> 빠르지만, Primary 장애 시 데이터 유실 가능

Write Concern: { w: "majority" }
  Primary + 과반수 Secondary에 복제 완료 -> 응답
  -> 느리지만, 장애 시에도 데이터 보존

Write Concern: { w: 0 }
  쓰기 요청만 전송, 확인 안 함
  -> 가장 빠르지만, 유실 가능성 높음 (로그성 데이터에 적합)

w: 1은 Primary가 write를 받았다는 확인만 받습니다. w: "majority"는 replica set의 과반수 멤버에 기록된 뒤 성공으로 봅니다. 그래서 지연은 늘 수 있지만, Primary 장애 후 롤백될 가능성을 줄입니다.

Read Concern: “어느 시점의 데이터를 읽을 것인가”

Read Concern: local (기본값)
  Primary의 최신 데이터 반환
  -> 아직 과반수에 복제되지 않은 데이터도 보임 (rollback 가능)

Read Concern: majority
  과반수 노드에 복제 확인된 데이터만 반환
  -> 롤백되지 않을 데이터만 읽음

Read Concern: snapshot
  트랜잭션 시작 시점의 일관된 스냅샷
  -> 멀티 도큐먼트 트랜잭션에서 사용

Read Concern은 “무엇을 읽을 것인가”에 대한 설정입니다. local은 현재 노드가 가진 최신 데이터를 읽고, majority는 과반수에 반영된 데이터를 읽습니다. snapshot은 트랜잭션에서 같은 시점의 데이터를 일관되게 읽기 위해 사용합니다.

Write Concern과 Read Concern은 성능 스위치가 아니라 일관성 스위치에 가깝습니다. 빠른 응답을 원하면 더 약한 보장을 선택할 수 있지만, 장애나 롤백 상황에서 어떤 데이터를 읽고 쓸 수 있는지까지 같이 결정됩니다.

멀티 도큐먼트 트랜잭션

도큐먼트 설계로 해결할 수 없는 경우가 있습니다. 서로 다른 컬렉션의 도큐먼트를 함께 수정해야 할 때입니다.

// 예약 생성: inventories 컬렉션 + reservations 컬렉션을 함께 수정
fun createReservation(...) {
    inventoryApplication.reserve(...)       // inventories 컬렉션 write
    reservationRepository.save(reservation) // reservations 컬렉션 write
}

재고 차감은 성공했는데 예약 저장이 실패하면? 재고만 줄어든 채 예약이 없는 상태가 됩니다.

숙소 예약으로 조금 더 구체화해보면 다음 흐름입니다.

1. 5월 1일 재고 1개 차감
2. 5월 2일 재고 1개 차감
3. 5월 3일 재고 1개 차감
4. reservations 컬렉션에 예약 저장

3박 예약이면 재고 도큐먼트 여러 개와 예약 도큐먼트 하나가 함께 바뀝니다. 단일 도큐먼트 원자성만으로는 이 전체를 보장할 수 없습니다.

fun createReservation(command: CreateReservationCommand): Reservation {
    command.dateRange.allDates().forEach { date ->
        inventoryApplication.reserve(
            propertyId = command.propertyId,
            roomTypeId = command.roomTypeId,
            date = date
        )
    }

    return reservationRepository.save(command.toReservation())
}

위 코드에서 두 번째 날짜 재고 차감 후 세 번째 날짜에서 예외가 나면, 이미 차감한 재고는 남습니다. 예약 저장 전 서버가 죽어도 마찬가지입니다. 이때 필요한 것이 멀티 도큐먼트 트랜잭션입니다.

전제 조건: Replica Set

멀티 도큐먼트 트랜잭션은 Replica Set 또는 Sharded Cluster 환경에서 동작합니다. Standalone MongoDB에서는 사용할 수 없습니다. 그래서 로컬 개발 환경에서도 트랜잭션을 테스트하려면 replica set으로 띄워야 합니다.

# docker-compose.yml
mongodb:
  command: ["--replSet", "rs0"]

Spring에서의 사용

@Configuration
class MongoConfig {
    @Bean
    fun transactionManager(dbFactory: MongoDatabaseFactory): MongoTransactionManager {
        return MongoTransactionManager(dbFactory)
    }
}

MongoTransactionManager Bean을 등록하면, RDB와 동일하게 @Transactional을 사용할 수 있습니다.

@Transactional
fun createReservation(...): Reservation {
    // 이 안의 모든 write가 하나의 트랜잭션
    dateRange.allDates().forEach { date ->
        inventoryApplication.reserve(...)     // write 1, 2, ...
    }
    reservationRepository.save(reservation)   // write N
    // 하나라도 실패하면 전부 rollback
}

이제 재고 차감 중 하나라도 실패하거나 예약 저장이 실패하면 전체 트랜잭션이 abort됩니다. 성공하면 commit 시점에 하나의 비즈니스 작업처럼 반영됩니다.

실무에서는 read preference 설정도 같이 봐야 합니다. 올리브영 기술 블로그의 MongoDB 트랜잭션 도입 사례에서도 Spring Boot에서 트랜잭션 매니저를 구성할 때 transaction read preference를 primary로 명시합니다. MongoDB 트랜잭션 내부 작업은 기본적으로 Primary에서 수행된다고 이해하면 됩니다.

@Configuration
class MongoTransactionConfig {

    @Bean
    fun transactionManager(dbFactory: MongoDatabaseFactory): MongoTransactionManager {
        val options = TransactionOptions.builder()
            .readPreference(ReadPreference.primary())
            .readConcern(ReadConcern.SNAPSHOT)
            .writeConcern(WriteConcern.MAJORITY)
            .build()

        return MongoTransactionManager(dbFactory, options)
    }
}

위 설정은 예시입니다. 모든 서비스가 항상 snapshot + majority 조합을 써야 한다는 뜻은 아닙니다. 중요한 것은 트랜잭션 경계에서 읽기 일관성과 쓰기 내구성을 명시적으로 결정할 수 있다는 점입니다.

멀티 도큐먼트 트랜잭션으로 얻는 이점은 명확합니다.

  1. 여러 컬렉션 write를 commit/rollback 단위로 묶을 수 있습니다.
  2. 예약, 재고, 결제처럼 하나의 업무 규칙에 묶인 데이터를 함께 보호할 수 있습니다.
  3. 트랜잭션 commit 이후에만 외부 이벤트를 발행하는 식으로 후속 처리 시점을 제어하기 쉬워집니다.

다만 외부 API 호출까지 DB 트랜잭션으로 rollback할 수 있는 것은 아닙니다. 결제 승인 같은 외부 상태 변경은 outbox, 보상 트랜잭션, 멱등키 같은 별도 장치가 필요합니다.

트랜잭션의 비용

MongoDB 멀티 도큐먼트 트랜잭션은 단일 도큐먼트 write보다 비용이 큽니다.

  • 트랜잭션 시작 시 스냅샷 획득
  • 트랜잭션 중 write set 추적
  • 커밋 시 모든 write를 원자적으로 반영
  • oplog 기록 및 복제 비용
  • 기본 트랜잭션 제한 시간 60초
  • lock 획득 대기와 write conflict 재시도 가능성
단일 도큐먼트 write   ████░░░░░░  빠름
멀티 도큐먼트 트랜잭션 ████████░░  느림 (스냅샷 + oplog + 커밋 비용)

MongoDB 공식 문서의 권장사항도 이 방향입니다. 많은 경우에는 적절한 데이터 모델링으로 멀티 도큐먼트 트랜잭션 필요성을 줄이고, 정말 필요한 곳에만 트랜잭션을 쓰는 것이 좋습니다.

먼저 도큐먼트 설계로 해결하고, 그래도 안 되면 트랜잭션을 사용하라.

oplog에는 어떻게 기록될까

Replica Set을 쓰면 Primary에서 일어난 write는 oplog에 기록되고, Secondary는 이 oplog를 따라가며 같은 작업을 적용합니다.

oplog는 local 데이터베이스의 oplog.rs 컬렉션에 저장됩니다. 일반적인 애플리케이션 데이터베이스에 있는 컬렉션이 아니라, 각 replica set 멤버가 로컬로 갖는 복제용 capped collection입니다.

use local
db.oplog.rs.find().sort({ $natural: -1 }).limit(3).pretty()

실제로 보면 대략 이런 형태의 문서가 저장됩니다. MongoDB 버전과 작업 종류에 따라 필드는 달라질 수 있습니다.

{
  "op": "u",
  "ns": "stayops.room_inventory",
  "ui": UUID("..."),
  "o": {
    "$v": 2,
    "diff": {
      "u": {
        "remaining": 2
      }
    }
  },
  "o2": {
    "_id": ObjectId("...")
  },
  "ts": Timestamp({ t: 1776560000, i: 1 }),
  "t": Long(12),
  "v": Long(2),
  "wall": ISODate("2026-04-19T12:00:00Z")
}

여기서 자주 보는 필드는 다음과 같습니다.

필드의미
op작업 종류입니다. i는 insert, u는 update, d는 delete, c는 command를 뜻합니다.
ns작업이 적용된 namespace입니다. 보통 database.collection 형태입니다.
o실제 작업 내용입니다. update라면 변경 diff나 update 문서가 들어갑니다.
o2update/delete 대상 식별자처럼 보조 조건이 들어갑니다.
tsoplog timestamp입니다. Secondary가 어디까지 따라왔는지 판단하는 기준이 됩니다.
treplica set term입니다. Primary 선출과 관련됩니다.
wall사람이 읽기 쉬운 실제 시각입니다.

멀티 도큐먼트 트랜잭션이라고 해서 반드시 “트랜잭션 전체가 하나의 oplog 문서”로만 저장되는 것은 아닙니다. MongoDB 문서는 트랜잭션의 write 작업이 여러 oplog entry를 만들 수 있고, 각 oplog entry는 BSON 문서 크기 제한인 16MiB를 넘을 수 없다고 설명합니다.

이 점은 운영상 꽤 중요합니다. 트랜잭션 하나가 너무 많은 도큐먼트를 바꾸면 oplog 양이 커지고, Secondary가 따라가야 할 작업도 많아집니다. 복제 지연(replication lag)이 커질 수 있고, 장애 복구 시 oplog catch-up 시간이 늘어날 수 있습니다.

정리하면 oplog는 단순한 로그 파일이 아니라 replica set 복제의 기준선입니다.

Client write
  -> Primary가 데이터 변경
  -> Primary가 local.oplog.rs에 작업 기록
  -> Secondary가 oplog를 읽어 같은 순서로 적용
  -> writeConcern에 따라 어느 시점에 클라이언트에 성공 응답

writeConcern: "majority"는 이 흐름에서 “과반수 멤버가 해당 write를 확인했는가”를 성공 기준으로 삼습니다. 그래서 단일 도큐먼트 원자성, 트랜잭션, oplog, write concern은 서로 따로 노는 개념이 아니라 같은 쓰기 경로의 서로 다른 층위입니다.

정리: 세 가지 층위

┌─────────────────────────────────────────────────┐
│ 3층: 멀티 도큐먼트 트랜잭션                       │
│   여러 컬렉션에 걸친 원자적 write                  │
│   Replica Set 필수, 성능 비용 있음                │
├─────────────────────────────────────────────────┤
│ 2층: Read/Write Concern                         │
│   데이터 읽기/쓰기의 일관성 수준 조절              │
│   majority, snapshot 등 조합                     │
├─────────────────────────────────────────────────┤
│ 1층: 단일 도큐먼트 원자성                         │
│   WiredTiger의 document-level concurrency + MVCC │
│   도큐먼트 설계로 트랜잭션 필요성을 줄임           │
└─────────────────────────────────────────────────┘

RDB에서 넘어온 개발자가 가장 먼저 할 일은 “트랜잭션을 어떻게 거는가”가 아니라, **“이 데이터를 하나의 도큐먼트로 설계할 수 있는가”**를 먼저 묻는 것입니다. 1층에서 해결되면 가장 좋고, 안 되면 2층으로, 그래도 안 되면 3층으로 올라가는 것이 MongoDB의 트랜잭션 전략입니다.

의문점: NoSQL은 RDB에 비해 빠르다?

흔히 “NoSQL은 RDB보다 빠르다”라는 말을 듣습니다. 반은 맞고 반은 위험한 말입니다.

MongoDB가 빠를 수 있는 대표적인 상황은 접근 패턴이 도큐먼트 모델과 잘 맞을 때입니다.

주문 상세 화면에 필요한 데이터:
- 주문 기본 정보
- 주문 항목
- 배송지
- 결제 요약

이 데이터가 하나의 주문 도큐먼트에 들어 있다면 애플리케이션은 한 번의 조회로 화면에 필요한 대부분의 데이터를 가져올 수 있습니다. RDB였다면 orders, order_items, shipments, payments를 join하거나 여러 번 조회해야 할 수 있습니다. 이 경우 MongoDB의 임베딩 모델은 네트워크 왕복, join 비용, 트랜잭션 경계를 줄일 수 있습니다.

하지만 이것이 “MongoDB 엔진이 항상 RDB 엔진보다 빠르다”는 뜻은 아닙니다.

예를 들어 다음 상황에서는 RDB가 더 자연스러울 수 있습니다.

  1. 데이터 관계가 복잡하고 ad-hoc join이 자주 필요합니다.
  2. 강한 외래키 제약과 정규화가 업무 규칙을 단순하게 만듭니다.
  3. 여러 엔티티가 독립적으로 자주 바뀌어 임베딩하면 중복 갱신이 많아집니다.
  4. 쿼리 패턴이 자주 바뀌어 특정 도큐먼트 구조에 맞춰 최적화하기 어렵습니다.

AWS의 NoSQL 설명도 NoSQL 데이터베이스를 특정 데이터 모델과 접근 패턴에 맞춰 성능과 확장성을 얻는 선택지로 설명합니다. MongoDB 공식 문서 역시 데이터 모델링을 할 때 애플리케이션의 query pattern과 데이터 관계를 먼저 보라고 말합니다.

즉 질문은 이렇게 바꾸는 편이 정확합니다.

"NoSQL이 RDB보다 빠른가?"
-> "이 서비스의 읽기/쓰기 패턴은 MongoDB 도큐먼트 모델에 맞는가?"

맞으면 MongoDB는 매우 빠르고 단순해질 수 있습니다. 맞지 않으면 트랜잭션, 중복 갱신, $lookup, 애플리케이션 조립 비용이 늘어나면서 기대했던 장점이 사라질 수 있습니다.

그래서 MongoDB를 고를 때는 “NoSQL이라 빠르다”보다 “함께 읽고 함께 바꾸는 데이터를 한 도큐먼트로 모델링할 수 있다”가 더 좋은 근거입니다.

마무리

MongoDB는 트랜잭션이 없는 데이터베이스가 아닙니다. 다만 트랜잭션을 사용하는 순서가 RDB와 다릅니다.

  1. 단일 도큐먼트 원자성으로 해결할 수 있는지 먼저 봅니다.
  2. 읽기/쓰기 보장 수준은 Read Concern과 Write Concern으로 조절합니다.
  3. 여러 도큐먼트를 반드시 하나의 작업으로 묶어야 할 때 멀티 도큐먼트 트랜잭션을 사용합니다.
  4. Replica Set에서는 oplog가 이 write 흐름을 복제하고 복구하는 기준이 됩니다.

결국 MongoDB의 트랜잭션 전략은 기능 하나의 문제가 아니라 데이터 모델링, 동시성 제어, 복제, 운영 비용이 함께 얽힌 문제입니다. RDB처럼 트랜잭션부터 떠올리기보다, 원자성의 경계를 어디에 둘지 먼저 정하는 것이 MongoDB다운 접근이라고 볼 수 있습니다.

출처

article |

MongoDB와 RDB를 비교해보자

개요

MongoDB를 프로젝트에 사용하면서 NoSQL이라는 사실만 알고 있었는데, 그나마(?) 저에게 익숙한 개념인 RDB(관계형 데이터베이스)와 비교하여 정리해봤습니다.

이 글에서는 MongoDB를 “테이블 대신 컬렉션을 쓰는 DB” 정도로만 보지 않고, RDB와 사고방식이 얼마나 다른지 위주로 비교하겠습니다.

등장 배경

MongoDB를 왜 쓰는지에 대해 알아보기 전에 등장 배경에 대해 알아보겠습니다.

MongoDB의 공동 창립자인 드와이트 메리먼, 엘리엇 호로위츠, 케빈 라이언은 온라인 광고 회사 DoubleClick에서 대규모 트래픽을 다뤘던 경험이 있었습니다. MongoDB 공식 소개에서도 이들이 초당 40만 건 이상의 광고를 처리하는 규모에서 관계형 데이터베이스의 한계를 경험했습니다.

문제는 당시 RDB가 그 수준의 트래픽을 수평으로 확장하기 쉽게 설계되지 않았고, 그들은 직접 해결책을 만들기로 하고 2007년에 10gen을 창업했습니다.

그런데 그들의 처음 목표는 데이터베이스가 아닌 클라우드 기반 PaaS 플랫폼이었습니다.

Google App Engine과 유사한 플랫폼을 만들려 했고, 데이터베이스는 그 안의 부품 중 하나였습니다.

그런데 당시 존재하던 데이터베이스가 원하는 클라우드 아키텍처 요건을 충족하지 못했습니다. 결국 팀은 데이터베이스까지 직접 만들기 시작했죠.

흥미로운 점은 개발자들이 플랫폼보다 그 안의 데이터베이스에 더 큰 관심을 보였다는 것입니다.

결국 10gen은 PaaS 전체를 내려놓고, 데이터베이스 하나에 집중하기로 결정합니다. 이 후 2009년, MongoDB가 오픈소스로 공개되었습니다.

당시에는 인터넷 서비스가 커지고, 장치와 사용자 로그가 폭발적으로 늘어나면서 전통적인 RDB로 다루기 까다로운 데이터가 많아졌습니다. 완전히 정해진 테이블 구조에 넣기 어려운 데이터, 빠르게 바뀌는 요구사항, 큰 트래픽을 처리하기 위한 수평 확장이 중요한 문제가 됐습니다.

MongoDB는 이 문제를 “관계형 모델을 더 잘 흉내내는 방식”이 아니라, 도큐먼트 모델로 풀었습니다.

RDB와 MongoDB 비교하기

테이블(table) vs 도큐먼트(document)

CREATE TABLE reservations (
  id BIGINT PRIMARY KEY,
  property_id BIGINT NOT NULL,
  guest_name VARCHAR(255) NOT NULL,
  check_in DATE NOT NULL,
  check_out DATE NOT NULL
);

먼저 RDB의 기초적인 데이터 집합인 테이블은 행과 열로 구성되고, 테이블 간 관계는 보통 Primary Key와 Foreign Key로 표현합니다.

{
  "_id": "reservation-1",
  "propertyId": "property-1",
  "guestName": "홍길동",
  "dateRange": {
    "checkIn": "2026-05-01",
    "checkOut": "2026-05-03"
  }
}

MongoDB는 데이터를 컬렉션 안의 도큐먼트로 저장하며, BSON(Binary JSON)이라는 데이터 형식으로 저장합니다.

일반 JSON과 큰 차이가 없어 보이는데 무슨 특성을 가지고 있을까요?

{
  "_id": "507f1f77bcf86cd799439011",
  "createdAt": "2026-04-12T12:00:00Z",
  "price": 1000
}

BSON은 JSON 형태에 날짜, ObjectId 같은 타입을 더한 이진 표현입니다.

일반 JSON 파일에서는 _id는 문자열이고, createdAt도 문자열입니다.

반면 BSON은 필드마다 타입 정보를 함께 저장합니다.

[문서 전체 길이]
[필드 타입][필드명][값]
[필드 타입][필드명][값]
...
[문서 끝]

그래서 MongoDB는 createdAt을 문자열이 아니라 BSON의 Date 타입으로 저장할 수 있고, _id도 단순 문자열이 아니라 ObjectId 타입으로 저장할 수 있습니다.

바이너리 데이터도 base64 문자열로 우회하지 않고 Binary 타입으로 저장할 수 있습니다.

Binary로 다뤄서 얻는 이점은 크게 세 가지인데:

  1. Date, ObjectId, Binary, Decimal128, int32, int64 같은 타입을 MongoDB 내부 타입으로 다룰 수 있으며

  2. 텍스트를 매번 파싱하지 않아도 됩니다. BSON은 타입과 길이 정보를 갖고 있기 때문에 MongoDB가 값을 읽을 때 어디까지가 해당 값인지 더 빠르게 알 수 있고

  3. 숫자와 바이너리 데이터를 문자열로 바꾸지 않고, 숫자는 숫자 타입으로, 바이너리 데이터는 바이트 배열로 저장할 수 있습니다.

BSON은 필드 타입과 길이 정보 같은 메타데이터를 추가로 저장하는 특징이 있는데, 예를 들어 JSON의 1은 한 글자지만, BSON의 int32 값은 값 자체만 4바이트를 사용하게 됩니다.

이로써 MongoDB가 문서를 타입 정보와 함께 빠르게 다룰 수 있습니다.

스키마(Schema)를 다루는 법

처음 공식 문서를 보고 의아했던 것은 schema라는 표현을 계속 사용하는데, RDB에서 말하는 스키마와 MongoDB 문서에서 말하는 스키마는 느낌이 조금 달랐던 것입니다.

CREATE TABLE reservations (
  id BIGINT PRIMARY KEY,
  guest_name VARCHAR(255) NOT NULL,
  status VARCHAR(20) NOT NULL,
  check_in DATE NOT NULL,
  check_out DATE NOT NULL
);

RDBMS 시스템에서는 스키마를 강제합니다. guest_name이 없거나, check_in에 날짜로 해석할 수 없는 값이 들어오면 DB가 거부하고, 컬럼을 추가하거나 타입을 바꾸려면 ALTER TABLE 같은 DDL을 실행해야 합니다.

즉 RDB에서의 스키마는 대체로 테이블 구조 + 컬럼 타입 + 제약 조건입니다.

하지만 여기서 비교하는 것은 namespace로서의 스키마가 아니라, 테이블이 어떤 컬럼과 제약을 갖는지에 대한 테이블 스키마입니다.

반면 MongoDB에서 말하는 schema는 기본적으로 컬렉션 안의 도큐먼트들이 대체로 어떤 구조를 갖는가에 가깝습니다.

MongoDB는 기본적으로 같은 컬렉션 안에서도 도큐먼트마다 필드가 다를 수 있습니다.

db.reservations.insertMany([
  {
    _id: "reservation-1",
    guestName: "홍길동",
    status: "CONFIRMED"
  },
  {
    _id: "reservation-2",
    guest: {
      name: "김철수"
    },
    memo: "늦은 체크인 요청"
  }
])

둘 다 같은 reservations 컬렉션에 들어갈 수 있는데:

첫 번째 도큐먼트는 guestName을 문자열로 갖고 있고, 두 번째 도큐먼트는 guest라는 중첩 객체를 갖고 있습니다.

이게 MongoDB 공식 문서에서 말하는 flexible schema입니다. 문서마다 반드시 같은 필드 집합을 가질 필요가 없고, 같은 필드라도 도큐먼트마다 타입이 달라질 수 있습니다.

하지만 이 말이 “스키마가 없다”는 뜻은 아니고, 애플리케이션이 기대하는 구조는 여전히 존재합니다.

예를 들어 예약을 처리하는 코드가 guestNamestatus를 기대한다면, 이 구조가 사실상의 application 스키마가 됩니다.

차이는 그 스키마가 처음부터 DB에 의해 강제되는지, 아니면 애플리케이션과 규칙으로 먼저 존재하는지입니다.

필요하다면 schema validation을 걸 수 있습니다.

db.createCollection("reservations", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["guestName", "status"],
      properties: {
        guestName: { bsonType: "string" },
        status: {
          enum: ["PENDING", "CONFIRMED", "CANCELLED"]
        }
      }
    }
  }
})

이렇게 하면 guestNamestatus 같은 필드를 검증할 수 있습니다. MongoDB의 validation rule은 필요한 부분에만 적용할 수 있고, 모든 필드를 빠짐없이 정의해야 하는 것도 아닙니다.

그래서 MongoDB를 schema-less가 아니라 schema를 미리 강하게 고정하지 않는 flexible schema 모델에 가깝다고 볼 수 있습니다.

레퍼런스(reference)와 임베드(embed)

MongoDB에서 컬렉션은 도큐먼트를 묶는 단위입니다. RDB의 테이블과 비슷한 위치에 있지만, table처럼 모든 row가 같은 column 구조를 가져야 하는 것은 아닙니다.

관계가 있는 데이터를 저장할 때 MongoDB 공식 문서는 크게 두 가지 모델을 이야기합니다.

  • 레퍼런스(reference): 다른 도큐먼트의 id를 저장하고 필요할 때 따라가는 방식
  • 임베드(embed): 관련 데이터를 현재 도큐먼트 안에 중첩 객체나 배열로 함께 넣는 방식

예를 들어 예약과 숙소가 있다고 해보겠습니다.

{
  "_id": "reservation-1",
  "guestName": "홍길동",
  "propertyId": "property-1"
}

이건 레퍼런스 방식입니다. 예약 도큐먼트는 숙소 이름과 주소를 직접 갖고 있지 않고, propertyId만 갖고 있습니다.

{
  "_id": "property-1",
  "name": "강릉 오션뷰 스테이",
  "address": "강원도 강릉시 ..."
}

숙소 정보의 원본은 properties 컬렉션의 도큐먼트에 있습니다.

반대로 같은 데이터를 임베드 방식으로 설계한다면:

{
  "_id": "reservation-1",
  "guestName": "홍길동",
  "property": {
    "_id": "property-1",
    "name": "강릉 오션뷰 스테이",
    "address": "강원도 강릉시 ..."
  }
}

관련 데이터를 별도 도큐먼트로 분리하지 않고, 현재 도큐먼트 안에 함께 저장하는 방식입니다.

위 예시에서는 예약 도큐먼트 안에 숙소 정보 일부를 중첩 객체로 넣었습니다.

reservation document
  └─ property
      ├─ _id
      ├─ name
      └─ address

이 경우에는 예약 하나를 조회할 때 숙소 이름과 주소까지 같이 읽을 수 있습니다.

db.reservations.findOne({ _id: "reservation-1" })

여기서 “조회 한 번”이라는 말은 reservations 컬렉션에서 예약 도큐먼트 하나를 읽었는데, 그 도큐먼트 안에 화면에 필요한 property.name, property.address가 이미 들어 있다는 의미입니다.

대신 같은 숙소 정보가 여러 예약 도큐먼트에 반복 저장될 수 있습니다.

{
  "_id": "reservation-2",
  "guestName": "파랑 피크민",
  "property": {
    "_id": "property-1",
    "name": "강릉 오션뷰 스테이",
    "address": "강원도 강릉시 ..."
  }
}

이 경우 reservation-1reservation-2는 서로 다른 예약 도큐먼트입니다. 따라서 도큐먼트 자체가 중복된 것은 아닙니다.

다만 두 도큐먼트 안에 같은 property-1의 이름과 주소가 복사되어 있습니다. MongoDB에서 말하는 비정규화나 중복 허용은 보통 이런 상황을 말합니다.

RDB와 MongoDB에서의 관계(Relation)

RDB에서는 관계를 보통 Foreign Key와 join으로 다룹니다.

reservations
  id
  property_id
  guest_name

properties
  id
  name
  address

여기서 reservations.property_idproperties.id를 가리키는 참조 값이며, join은 그 참조 값을 이용해 조회 시점에 두 테이블의 데이터를 합치는 연산입니다.

SELECT r.id, r.guest_name, p.name, p.address
FROM reservations r
JOIN properties p ON r.property_id = p.id
WHERE r.id = 1;

MongoDB에서도 레퍼런스를 저장하고 나중에 따라갈 수 있습니다.

{
  "_id": "reservation-1",
  "guestName": "홍길동",
  "propertyId": "property-1"
}

이 방식에서는 숙소 정보가 필요할 때 애플리케이션에서 properties 컬렉션을 한 번 더 조회할 수 있습니다.

db.properties.findOne({ _id: "property-1" })

또는 aggregation의 $lookup을 사용해 컬렉션 간 데이터를 합칠 수 있습니다.

db.reservations.aggregate([
  {
    $lookup: {
      from: "properties",
      localField: "propertyId",
      foreignField: "_id",
      as: "property"
    }
  }
])

다만 MongoDB에서는 RDB처럼 join을 설계의 기본 전제로 두기보다, 애플리케이션이 데이터를 읽는 모양에 맞춰 reference와 embed 중 하나를 선택합니다.

레퍼런스는 데이터를 정규화하는 쪽에 가깝고, 임베드는 데이터를 비정규화하는 쪽에 가깝습니다.

수평 확장과 샤딩(sharding)

전통적인 RDB에서는 샤딩을 애플리케이션이나 운영 설계에서 직접 감당해야 하는 경우가 많습니다.

예를 들어 예약 데이터를 property_id 기준으로 나눈다고 해보겠습니다.

reservations_property_1_1000   -> shard A
reservations_property_1001_2000 -> shard B
reservations_property_2001_3000 -> shard C

이렇게 나누면 애플리케이션은 어떤 예약이 어느 shard에 있는지 알아야 합니다.

property_id = 1200이면 shard B로 보내고, property_id = 300이면 shard A로 보내야 합니다.

즉 RDB에서 샤딩은 결국

  • shard 간 join이 어려워지고
  • 특정 shard에 트래픽이 몰릴 수 있으며
  • 데이터가 커지면 다시 쪼개고 옮기는 작업이 필요하고
  • transaction과 consistency 경계가 복잡해져서

애플리케이션과 운영 복잡도가 함께 커지는 방식에 가깝습니다.

MongoDB는 이 문제를 데이터베이스 기능으로 더 직접적으로 다루며 sharded cluster를 다음과 같이 구성합니다:

애플리케이션은 보통 mongos라는 라우터에 요청을 보냅니다. mongos는 shard key를 보고 어떤 shard로 요청을 보낼지 결정합니다.

예를 들어 reservations 컬렉션을 propertyId 기준으로 shard한다고 하면, MongoDB는 shard key 값을 기준으로 데이터를 여러 shard에 나눠 저장합니다.

sh.shardCollection("pms.reservations", { propertyId: 1 })

이때 중요한 설계 포인트는 shard key입니다.

shard key를 잘 고르면 특정 숙소의 예약을 찾는 쿼리를 필요한 shard로 보낼 수 있습니다.

db.reservations.find({ propertyId: "property-1" })

반대로 shard key를 잘못 고르면 특정 shard에만 데이터나 요청이 몰릴 수 있습니다.

MongoDB의 설계 철학은 “정규화된 데이터를 조인해서 조립한다”보다, 애플리케이션의 접근 패턴에 맞춰 document와 shard key를 설계한다에 가깝습니다.

어떤 필드로 데이터를 나눌지, 어떤 쿼리가 자주 들어오는지, 한 도큐먼트 안에 어떤 데이터를 함께 둘지 같이 설계해야 합니다.

정리

사실 이것 외에도 굉장히 내용이 방대해서 두 번째 파트로 나누려고 합니다.

이후에는 mongoDB에서 트랜잭션을 어떻게 다루는지, replica set과 같은 특징을 이어서 다뤄보겠습니다.

출처