MongoDB의 ACID 완전 정복 Part 2 — Atomicity·Consistency: 단일 문서부터 멀티 문서 트랜잭션까지
Part 1에서 개념과 역사를 짚었다면, 이번 글은 Atomicity와 Consistency가 MongoDB에서 실제로 어떻게 구현되는지에 초점을 둡니다. 단일 BSON 문서 단위의 원자적 갱신과, 멀티 문서 트랜잭션에서 커밋 시점 가시성·복제 로그가 맞물리는 방식을 분리해서 설명합니다. 스키마 검증·유니크 인덱스·애플리케이션 검증으로 일관성을 거는 방법과, 트랜잭션 상태 흐름·실무 코드 패턴까지 이어집니다. Atomicity가 격리 수준이나 읽기 일관성 전부를 대신하지 않는다는 점은 readConcern·writeConcern과 구분해 짚고, 샤딩·교차 샤드 비용과 재시도 시 외부 부작용 가능성은 별도 단락으로 덧붙였습니다. 예제는 특정 MongoDB·Node 드라이버 버전을 가정하므로, 운영 중인 버전과의 차이는 공식 릴리스 노트로 확인하시길 권합니다.
시리즈 구성
- Part 1 — ACID 개념과 MongoDB의 역사적 맥락
- Part 2 ← 지금 여기 | Atomicity·Consistency 심층 분석 — 단일 문서 vs 멀티 문서
- Part 3 | Isolation 레벨과 Snapshot Isolation 내부 동작
- Part 4 | Durability, WiredTiger 스토리지 엔진, Write Concern (출간 예정)
- Part 5 | 실전 코드 패턴 + 성능 최적화 + 안티패턴 (출간 예정)
목차
- 들어가며 — 이번 글에서 다루는 경계
- Atomicity — "전부 아니면 전무"의 구현 방식
- 단일 문서 원자성 — 모델링의 기준점
- 멀티 문서 트랜잭션의 Atomicity — 커밋 시점 가시성과 Oplog
- Consistency란 무엇인가?
- MongoDB에서 Consistency를 거는 방법
- 트랜잭션 상태 — Active부터 Committed까지
- 실전 코드 — 롤백이 동작하는 순간
- 성능과 운영 — 언제 트랜잭션이 부담이 되는가
- Atomicity와 읽기 일관성·배포 형태 — 헷갈리기 쉬운 지점
- 마치며 — Part 2 요약
1. 들어가며 — 이번 글에서 다루는 경계
Part 1에서 ACID의 윤곽과 MongoDB가 지나온 변화를 정리했다면, 이번 글에서는 **A(Atomicity)와 C(Consistency)**가 코드와 스토리지 엔진 관점에서 무엇을 의미하는지 더 깊게 파고듭니다.
여기서 먼저 짚고 넘어가야 할 점이 있습니다. **원자성(한 트랜잭션의 쓰기가 모두 반영되거나 모두 취소된다)**과 **격리성(동시에 실행되는 트랜잭션이 서로의 중간 결과를 어떻게 보게 할 것인가)**은 문제의 축이 다릅니다. MongoDB에서 읽기 일관성·복제 지연·스냅샷 읽기는 readConcern, 쓰기 확인은 writeConcern, 동시성·스냅샷 격리는 주로 Isolation 주제와 함께 논의됩니다. 그래서 "멀티 문서 트랜잭션을 쓰면 읽기 일관성까지 자동으로 해결된다"고 단정하기는 어렵습니다. 이번 글에서는 Atomicity·Consistency에 집중하고, 격리·읽기 시맨틱은 Part 3에서 이어서 다루겠습니다.
2. Atomicity — "전부 아니면 전무"의 구현 방식
원자성은 철학처럼 들리지만, 구현은 구체적인 엔지니어링 문제입니다. 데이터베이스는 어떻게 "모 아니면 도"를 보장할까요? 흔히 등장하는 메커니즘은 다음과 같습니다.
Write-Ahead Logging(WAL)
실제 데이터를 바꾸기 전에 로그에 먼저 남깁니다. MongoDB에서는 저널·복제 로그 등이 이 역할과 맞닿아 있습니다. 중간에 프로세스가 중단되어도 로그를 기준으로 재적용하거나 되돌릴 수 있습니다.
MVCC 등 버전 관리
원본을 즉시 덮어쓰지 않고, 변경을 쌓아 두었다가 커밋 시점에 정리하는 방식입니다. WiredTiger는 MVCC(Multi-Version Concurrency Control)를 사용합니다.
이번 글 전체를 통틀어 기억할 한 문장은 다음과 같습니다. Atomicity는 "한 트랜잭션의 변경이 반쯤 남는 것"을 막는 성질이고, 읽기가 어떤 시점의 스냅샷을 보게 할지는 별도의 설정과 격리 모델이 담당합니다.
3. 단일 문서 원자성 — 모델링의 기준점
MongoDB 공식 문서는 단일 문서 업데이트를 성능과 운영 측면에서 중요한 기본 단위로 설명합니다. 한 BSON 문서 안의 필드·배열·중첩 도큐먼트를 포함한 갱신은 하나의 원자 단위로 적용되거나 적용되지 않습니다.
3.1 왜 단일 문서 연산이 원자적인가
하나의 문서에 대한 갱신은 WiredTiger의 잠금·MVCC와 맞물려 다른 연산에게 중간 상태를 노출하지 않도록 설계됩니다. 중첩된 서브도큐먼트와 배열을 포함한 전체 도큐먼트가 그 한 덩어리입니다.
// 이 도큐먼트 전체가 하나의 원자 단위(개념 예시)
{
_id: ObjectId("..."),
userId: "user_001",
profile: {
name: "김개발",
email: "dev@example.com",
tier: "premium"
},
stats: {
loginCount: 142,
lastLogin: ISODate("2026-04-09"),
totalPurchase: 580000
},
recentOrders: [
{ orderId: "ORD-001", amount: 45000 },
{ orderId: "ORD-002", amount: 120000 }
]
}
아래와 같이 한 문서에 대해 $set, $inc, $push를 함께 쓰는 경우, 트랜잭션을 열지 않아도 해당 갱신은 한 번에 적용되거나 실패합니다.
await db.collection("users").updateOne(
{ _id: userId },
{
$set: { "profile.tier": "vip", "stats.lastLogin": new Date() },
$inc: { "stats.loginCount": 1, "stats.totalPurchase": 85000 },
$push: { recentOrders: { orderId: "ORD-003", amount: 85000 } }
}
);
3.2 단일 문서 원자성의 한계
반대로, 여러 문서에 걸친 작업은 기본적으로 "한 번에 전부"가 보장되지 않습니다.
// updateMany는 문서마다 갱신이 진행됩니다.
await db.collection("products").updateMany(
{ category: "electronics" },
{ $inc: { price: 1000 } }
);
updateMany는 내부적으로 여러 문서에 대한 개별 갱신으로 이어질 수 있습니다. 중간에 오류가 나면 일부 문서만 바뀐 채로 남을 수 있습니다. 각 문서의 수정은 원자적일 수 있지만, 질의 한 번이 의미하는 "작업 전체"는 원자적이지 않을 수 있다는 뜻입니다. 이런 요구가 있다면 멀티 문서 트랜잭션이나, 도큐먼트 모델을 다시 설계해 단일 문서로 흡수하는 쪽을 검토하게 됩니다.
4. 멀티 문서 트랜잭션의 Atomicity — 커밋 시점 가시성과 Oplog
멀티 문서 트랜잭션에서 원자성은 어떻게 맞춰질까요? 복제 세트 환경에서는 Oplog(operations log) 가 핵심적인 역할을 합니다.
4.1 Oplog란
Oplog는 Replica Set에서 쓰기 연산을 기록하는 capped collection으로, local.oplog.rs에 저장됩니다. 세컨더리는 이 로그를 따라 Primary와 같은 상태를 맞춥니다.
4.2 "커밋 시점 가시성"으로 이해하기
초안에서 흔히 쓰이는 **"멀티 문서 트랜잭션 = Oplog에 항상 단일 엔트리"**라는 표현은, 버전에 따라 그대로 두기 어렵습니다. MongoDB 4.2 이후에는 큰 트랜잭션을 여러 Oplog 엔트리로 나눌 수 있다는 설명이 공식 자료에 포함됩니다. 따라서 엔트리가 하나인지 여럿인지보다 중요한 것은 커밋이 완료되기 전에는 관련 변경이 일관된 규칙으로 외부에 보이지 않고, 커밋 이후에는 복제 세트 관점에서 함께 적용·가시화된다는 점입니다. 구현 세부는 버전별 릴리스 노트와 매뉴얼을 기준으로 확인하시길 권합니다.
트랜잭션의 대략적인 흐름은 다음과 같이 정리할 수 있습니다.
-
session.startTransaction()
WiredTiger가 트랜잭션 컨텍스트를 잡고, 변경은 커밋 전까지 다른 세션에 보이지 않게 유지됩니다(격리 설정에 따름). -
insert / update / delete 수행
세션에 연결된 연산만 트랜잭션에 포함됩니다. -
commitTransaction()
커밋이 완료되면 변경이 허용된 방식으로 가시화되고, 복제·복구 절차와 맞물립니다. -
오류 시
abortTransaction()
버퍼된 변경이 폐기되고, 트랜잭션 시작 이전 상태로 돌아갑니다.
4.3 배포 전제 — Standalone에서는 트랜잭션이 제한된다
멀티 문서 트랜잭션은 Replica Set 또는 Sharded Cluster 같은 배포 전제를 요구합니다. Standalone 인스턴스에서는 기대한 대로 동작하지 않을 수 있습니다. 개발 환경에서도 단일 노드라도 Replica Set으로 띄워 검증하는 패턴이 흔합니다.
mongod --replSet rs0 --port 27017 --dbpath /data/db
mongosh --eval "rs.initiate()"
4.4 샤딩 환경에서의 추가 비용(각도 보강)
Replica Set 중심으로 설명을 이어가면, 여러 샤드에 걸친 트랜잭션에서는 조정 비용·잠금·지연이 커질 수 있다는 점이 가려질 수 있습니다. 샤드 키 설계가 트랜잭션이 자주 건너는 경로와 맞지 않으면, 교차 샤드 트랜잭션이 잦아지고 운영 부담이 커질 수 있습니다. "가능하면 트랜잭션 경계를 줄이고, 같은 샤드·같은 문서 안에서 끝낼 수 있는지"를 먼저 검토하는 편이 안전합니다.
5. Consistency란 무엇인가?
ACID에서 말하는 Consistency는 흔히 "데이터가 일관된다"는 막연한 표현으로 번역되기 쉽습니다. 여기서는 조금 더 좁게 잡겠습니다.
Consistency는 트랜잭션 실행 전후로 데이터베이스가 정의된 규칙(스키마, 제약, 애플리케이션이 정한 불변식)을 만족하는 상태를 유지해야 한다는 성질로 이해할 수 있습니다.
예를 들어 "모든 계좌 잔액의 합이 일정하다"는 규칙이 있다면, 이체 트랜잭션 전후로 그 합이 유지되어야 합니다. 다만 그 규칙 자체를 데이터베이스가 전부 알 수 있는 것은 아니므로, 스키마 검증으로 커버되는 부분과 애플리케이션 코드에서 검증해야 하는 부분을 나누어 생각해야 합니다.
6. MongoDB에서 Consistency를 거는 방법
6.1 Schema Validation(JSON Schema)
MongoDB 3.6 이후 강화된 JSON Schema Validation으로 삽입·갱신 시 구조와 값을 검사할 수 있습니다.
await db.createCollection("accounts", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["accountId", "balance", "currency"],
properties: {
accountId: { bsonType: "string" },
balance: {
bsonType: "number",
minimum: 0
},
currency: {
bsonType: "string",
enum: ["KRW", "USD", "EUR"]
}
}
}
},
validationAction: "error"
});
6.2 Unique Index
동일 값의 중복 삽입을 막습니다. 예약 시스템에서 같은 슬롯을 이중으로 잡지 않게 하는 대표적인 장치입니다.
await db.collection("reservations").createIndex(
{ userId: 1, slotDate: 1 },
{ unique: true }
);
6.3 트랜잭션 안에서의 애플리케이션 검증
복잡한 규칙은 트랜잭션 내부에서 읽고 검증한 뒤 갱신합니다. 모든 읽기·쓰기에 동일한 session을 전달해야 트랜잭션 경계가 깨지지 않습니다.
async function transferFunds(db, fromId, toId, amount) {
const session = db.client.startSession();
try {
await session.withTransaction(async () => {
const accounts = db.collection("accounts");
const sender = await accounts.findOne({ _id: fromId }, { session });
if (!sender) throw new Error("출금 계좌를 찾을 수 없습니다");
if (sender.balance < amount) throw new Error("잔액이 부족합니다");
const receiver = await accounts.findOne({ _id: toId }, { session });
if (!receiver) throw new Error("입금 계좌를 찾을 수 없습니다");
await accounts.updateOne(
{ _id: fromId },
{ $inc: { balance: -amount } },
{ session }
);
await accounts.updateOne(
{ _id: toId },
{ $inc: { balance: amount } },
{ session }
);
await db.collection("transactions").insertOne(
{ from: fromId, to: toId, amount, ts: new Date() },
{ session }
);
});
} finally {
await session.endSession();
}
}
7. 트랜잭션 상태 — Active부터 Committed까지
운영 코드를 짤 때는 "지금 세션이 어느 단계인가"를 구분하는 것이 디버깅에 도움이 됩니다. 아래는 이해를 돕기 위한 개략적 흐름이며, 실제 서버 내부 상태 이름과 1:1로 대응한다고 보시면 안 됩니다.
| 단계(개략) | 설명 |
|---|---|
| ACTIVE | 트랜잭션 진행 중. 읽기·쓰기는 세션과 readConcern·격리 설정에 따름 |
| COMMITTED | 커밋이 완료되어 허용된 읽기에서 관찰 가능 |
| FAILED / ABORTED | 오류 또는 중단으로 롤백된 결과 |
8. 실전 코드 — 롤백이 동작하는 순간
아래 예제는 재고 차감과 주문 생성을 한 트랜잭션에 묶는 흐름입니다. 예제는 MongoDB 6.x·Node.js 공식 드라이버 6.x 전후를 가정했으며, findOne 뒤 조건부 updateOne으로 드라이버별 findOneAndUpdate 반환 형태 차이를 피했습니다. 고동시성에서는 동일 문서에 대한 경쟁 조건을 더 촘촘히 다뤄야 하므로, 운영 코드에서는 필요 시 findOneAndUpdate의 원자적 조건부 갱신·낙관적 잠금 등을 검토하시길 권합니다.
const { MongoClient } = require("mongodb");
const client = new MongoClient("mongodb://localhost:27017/?replicaSet=rs0");
async function createOrder(customerId, productId, quantity) {
const session = client.startSession();
try {
let orderId;
await session.withTransaction(
async () => {
const db = client.db("shop");
const orders = db.collection("orders");
const inventory = db.collection("inventory");
const inv = await inventory.findOne(
{ productId, quantity: { $gte: quantity } },
{ session }
);
if (!inv) {
throw new Error(`재고 부족: 상품 ${productId}`);
}
const dec = await inventory.updateOne(
{ _id: inv._id, quantity: { $gte: quantity } },
{ $inc: { quantity: -quantity } },
{ session }
);
if (dec.modifiedCount === 0) {
throw new Error(`재고 경쟁: 상품 ${productId}`);
}
const orderInsert = await orders.insertOne(
{
customerId,
productId,
quantity,
status: "confirmed",
unitPrice: inv.price,
totalAmount: inv.price * quantity,
createdAt: new Date()
},
{ session }
);
orderId = orderInsert.insertedId;
await db.collection("customers").updateOne(
{ _id: customerId },
{
$inc: {
totalOrders: 1,
totalSpent: inv.price * quantity
},
$set: { lastOrderDate: new Date() }
},
{ session }
);
},
{
readConcern: { level: "snapshot" },
writeConcern: { w: "majority" }
}
);
return { success: true, orderId };
} catch (error) {
return { success: false, error: error.message };
} finally {
await session.endSession();
}
}
session.withTransaction()은 콜백이 성공하면 커밋을, 예외가 나면 중단을 시도합니다. 일시적 오류에 대한 재시도를 포함하는 점이 문서화되어 있으므로, 직접 startTransaction()만 쓰는 것보다 운영에 유리한 경우가 많습니다.
8.1 재시도와 외부 부작용(각도 보강)
재시도가 발생하면 트랜잭션 내부의 DB 쓰기는 원자성 안에서 다시 시도되지만, 트랜잭션 밖의 부작용(메시지 발행, 외부 HTTP 호출, 이메일 발송)은 중복 실행될 수 있습니다. 이런 작업은 멱등 키, 아웃박스 패턴, 혹은 외부 시스템 쪽 중복 제거와 함께 설계하는 편이 안전합니다.
9. 성능과 운영 — 언제 트랜잭션이 부담이 되는가
트랜잭션에는 잠금·세션·로깅 비용이 따릅니다.
9.1 WiredTiger 캐시와 긴 트랜잭션
트랜잭션이 길어질수록 메모리 안에 유지해야 하는 버전·잠금 부담이 커질 수 있습니다. 기본 트랜잭션 시간 제한이 있음을 전제로, 트랜잭션은 짧게 유지하는 것이 좋습니다.
9.2 잠금 대기
잠금을 오래 잡으면 다른 연산이 대기합니다. 경합이 심한 컬렉션에서는 스키마·접근 패턴을 다시 보게 되는 경우가 많습니다.
9.3 단일 문서 vs 멀티 문서(요약)
| 구분 | 단일 문서 갱신 | 멀티 문서 트랜잭션 |
|---|---|---|
| 오버헤드 | 상대적으로 작음 | 세션·조정·로깅 비용 |
| 적합한 경우 | 한 문서 안에서 끝나는 모델 | 여러 문서·컬렉션을 한 번에 맞춰야 할 때 |
| 설계 힌트 | 도큐먼트 경계를 업무 경계에 맞추기 | 경계를 쪼개 샤드·경합 비용 줄이기 |
10. Atomicity와 읽기 일관성·배포 형태 — 헷갈리기 쉬운 지점
아래는 원자성(Atomicity)과 배포 형태를 기준으로 살펴보는 질문 순서입니다. 절대적인 결정 트리는 아니며, 설계 리뷰에서 자주 쓰는 흐름에 가깝습니다.
정리하면 다음과 같습니다.
- Atomicity는 쓰기의 "반쪽 성공"을 막는 축입니다.
- 격리(Isolation)·Read Concern은 읽기가 어느 시점·어느 복제 상태를 볼지와 연결됩니다. Part 3에서 집중적으로 다룹니다. Write Concern과 커밋의 내구성·복제 측면은 Part 4에서 이어집니다.
- 멀티 문서 트랜잭션은 Standalone이 아닌 배포를 전제로 하며, 샤딩에서는 교차 샤드 경로가 잦지 않게 모델을 점검하는 것이 좋습니다.
11. 마치며 — Part 2 요약
이번 글에서 다시 한 번 짚은 핵심은 다음과 같습니다.
Atomicity — 단일 문서 갱신은 BSON 문서 단위로 원자적으로 적용됩니다. 여러 문서가 필요하면 멀티 문서 트랜잭션과 도큐먼트 모델 재검토가 함께 나옵니다. 멀티 문서 트랜잭션의 "한 덩어리로 커밋된다"는 직관은 유지하되, Oplog 기록이 버전마다 어떻게 쪼개질 수 있는지는 매뉴얼로 확인하시길 권합니다.
Consistency — JSON Schema·유니크 인덱스·트랜잭션 내 검증으로 규칙을 강제할 수 있습니다. 규칙의 상당 부분은 여전히 애플리케이션 책임입니다.
실무 — withTransaction()과 명시적 readConcern·writeConcern은 함께 읽어야 하고, 재시도가 있는 환경에서는 외부 부작용을 별도로 설계해야 합니다.
다음 Part 3에서는 Isolation, 스냅샷 읽기, 동시성 이상 현상, Read Concern으로 읽기 일관성을 조절하는 방법을 fail-on-conflict·재시도 맥락과 묶어 설명합니다. Part 2에서 이미 짝으로 등장한 Write Concern의 내구성·복제 논의는 Part 4(Durability, Write Concern)에서 본격적으로 이어집니다.
참고
- MongoDB Manual — 단일 문서 연산과 원자성(Atomicity and write operations)
- MongoDB Manual — 트랜잭션(Transactions)
- MongoDB Manual — 애플리케이션에서의 트랜잭션(Transactions in applications)
- MongoDB Manual — Replica Set Oplog
- MongoDB Manual — 스키마 검증(Schema validation)
- MongoDB Manual — 샤딩된 클러스터의 트랜잭션
- MongoDB 4.2 릴리스 노트 — Transactions(대용량 트랜잭션과 Oplog 기록 방식 등)