MongoDB 스키마 설계: 임베드 vs 참조 선택 기준 | 도큐먼트 모델링
이 글의 핵심
MongoDB 스키마 설계 임베디드 참조: 1:N·업데이트 빈도·트랜잭션·문서 크기 한도를 기준으로 임베드와 참조를 고르는 실무 체크리스트입니다.
들어가며
MongoDB는 스키마가 없다는 말을 자주 듣는데, 실서비스 가면 스키마가 가장 먼저 갈등이 납니다. 여기서 말하는 갈등은 임베드(한 도큐먼트 안에 박아 넣기) 할지, 레퍼런스(다른 컬렉션에 두고 id로 잇기) 할지죠.
솔직히 난 예전에 “읽기 한 번이면 끝이니까 임베드가 이긴다”는 식으로만 생각한 적이 있어요. 그랬다가 댓글이 늘고, 쓰기가 몰리고, 문서가 커질수록 뒤늦게 스키마를 쪼개느라 밤을 샌 기억이 있습니다. 이 글은 표로 정리한 누가 이기냐가 아니라, 그때 왜 꼬였는지와 지금은 어떻게 판단하는지를 적었습니다. 아래 스키마 설계로 한번 굴러가 본 이야기도 같이 읽으면 흐름이 잡힐 거예요.
이 글을 읽으면
- 임베디드 vs 참조를 고를 때 내가 쓰는 말(기준)을 갖고 갈 수 있어요
- 1:N, N:M, 다국어 같은 꼬인 패턴도 예제로 훑을 수 있어요
- 16MB, 인덱스가 왜 임베드/참조 논쟁에 끼어드는지 감이 옵니다
스키마 설계로 한번 굴러가 본 이야기
첫 MongoDB 서비스였을 때, 게시글 + 댓글을 한 도큐먼트에 넣었어요. posts 안에 comments 배열. MVP 시절엔 정말 빨랐죠. findOne 한 번이면 본문이랑 댓글이 다 왔으니까요.
문제는 댓글이 “조금” 달리기 시작한 순간이었어요. 스팸이 섞이고, 작성자가 동시에 여러 댓글을 달고, 모더레이션(숨김/삭제)이 붙으면 같은 postId에 대한 쓰기가 겹쳤습니다. 임베드면 그 큰 덩어리(게시글+댓글 전체)를 계속 다시 쓰는 셈이에요. 읽기는 여전히 한 방이 좋은데, 쓰기 경합과 배열 인덱스가 느려지는 건 별개 문제였죠.
그때 댓글만 comments 컬렉션으로 뺐어요. postId + createdAt 인덱스, 게시글에는 commentCount만 덴 정도로 비정규화. 마이그레이션은 꼬였고(당연히), 배포는 더 조심스러워졌어요. 그때 느낀 게 이거였어요: “함께 읽힌다”가 “함께 쓰인다”와 같지 않다는 것, 그리고 성장할 수 있는 자식은 처음부터 밖에 두는 편이 마음이 편하다는 것. 아래 내가 쓰는 취향은 그 경험에서 나온 편이에요. 표가 아니라 그때의 감을 글로 풀었으니, 본인 도메인에 맞게만 가져가면 됩니다.
개념 설명
임베디드(Embedded)
관련 데이터를 한 BSON 도큐먼트 안에 중첩 배열·서브 도큐먼트로 넣습니다.
{
_id: ObjectId("..."),
name: "Kim",
addresses: [
{ label: "home", city: "Seoul", zip: "03000" },
{ label: "work", city: "Seongnam", zip: "13000" }
]
}
장점: 자주 함께 읽으면 쿼리 1회로 끝납니다. 같은 도큐먼트 안에서 갱신하면 원자적 업데이트를 활용하기 쉽습니다.
참조(Referenced)
별 컬렉션에 두고 ObjectId 등으로 연결합니다.
// users
{ _id: ObjectId("u1"), name: "Kim" }
// addresses
{ _id: ObjectId("a1"), userId: ObjectId("u1"), city: "Seoul" }
장점: 주소가 수만 건으로 불어나도 사용자 도큐먼트가 비대해지지 않음. 여러 상위 엔티티가 같은 하위를 가리키는 모델에 적합.
BSON 도큐먼트 크기
단일 도큐먼트는 최대 16MB입니다. “나중에 배열이 무한히 커질 수 있는” 데이터는 처음부터 별 컬렉션 + 인덱스를 고려합니다.
실전 구현
임베드 vs 레퍼런스, 내가 쓰는 취향
여기서부터는 강한 개인 의견이에요. “정답”이라고 쓰지는 않을게요. 다만 설계할 때 혼자 중얼거리는 말을 정리한 거라서, 팀 룰을 만들 때 토론 출발점으로 쓰기 좋아요.
레퍼런스 쪽이 기본에 가깝다고 봅니다. 이유는 단순해요. 서비스는 처음엔 1:소수라고 착각했다가, 몇 달 뒤 1:무한이 되는 경우가 흔하거든요. 그때 임베드로 박아 둔 배열을 쪼개는 건, 스키마 마이그레이션 + 다운타임/이중 쓰기까지 생각하면 RDB에 비해 편하다고 말하기 어려워요. 반대로 나중에 합치는 건 읽기 경로를 바꾸는 것보다 쉬운 편이고요.
임베드는 “정말로” 이렇게 말할 수 있을 때만 고릅니다. (1) 읽을 때 거의 100% 같이 가져가고, (2) 쓰기도 대부분 한 트랜잭션에 느낌으로 같이 흘러가고, (3) 자식 덩어리가 커질 수 없다는 비즈니스 룰이 박혀 있을 때. 주소 2~3개, 주문의 라인 아이템처럼 상한이 계약으로 고정된 경우가 여기에 가깝죠.
함께 읽는다고 임베드할 게 아닙니다. 위 스토리처럼 같이 보이는 것과 같이 쓰는 것이 다르면, 자식은 밖으로 빼는 쪽이 스트레스가 적어요. 정리하자면, 성장·동시 쓰기·다른 뷰(관리 콘솔, 검색) 가 걸릴 수 있으면 레퍼런스를 먼저 염두에 두는 편이 낫다—가 제 취향입니다.
패턴 1: 1:소수 — 임베드
댓글이 항상 게시글과 함께 보이고, 댓글 수가 제한적이면(또는 페이지 단위로만) 임베드를 검토합니다. 블로그 게시글 + 댓글 (최대 100개)
// 실행 예제
{
_id: ObjectId("..."),
title: "MongoDB 스키마 설계 가이드",
content: "...",
author: "pkglog",
createdAt: ISODate("2026-03-30T10:00:00Z"),
comments: [
{
_id: ObjectId("..."),
author: "user1",
content: "유용한 글입니다!",
createdAt: ISODate("2026-03-30T11:00:00Z"),
likes: 5
},
{
_id: ObjectId("..."),
author: "user2",
content: "감사합니다",
createdAt: ISODate("2026-03-30T12:00:00Z"),
likes: 2
}
],
commentCount: 2
}
장점:
- 게시글 조회 시 댓글도 한 번에 가져옴 (쿼리 1회)
- 댓글 추가/수정 시 단일 도큐먼트 원자적 업데이트
- 인덱스 단순 (
_id하나로 충분) 주의사항: - 댓글이 100개를 넘으면 도큐먼트 크기 증가
- 댓글 수가 무제한이면 참조로 전환 고려 사용자 프로필 + 주소 목록
{
_id: ObjectId("..."),
name: "Kim",
email: "[email protected]",
addresses: [
{
_id: ObjectId("..."),
label: "home",
street: "123 Main St",
city: "Seoul",
zip: "03000",
country: "KR",
isDefault: true
},
{
_id: ObjectId("..."),
label: "work",
street: "456 Office Rd",
city: "Seongnam",
zip: "13000",
country: "KR",
isDefault: false
}
],
createdAt: ISODate("2026-03-30T10:00:00Z")
}
주소 추가
// 실행 예제
db.users.updateOne(
{ _id: userId },
{
$push: {
addresses: {
_id: new ObjectId(),
label: "vacation",
street: "789 Beach Ave",
city: "Busan",
zip: "48000",
country: "KR",
isDefault: false
}
}
}
);
특정 주소 수정
// 실행 예제
db.users.updateOne(
{ _id: userId, "addresses._id": addressId },
{
$set: {
"addresses.$.street": "123 New St",
"addresses.$.city": "Incheon"
}
}
);
주소 삭제
db.users.updateOne(
{ _id: userId },
{
$pull: { addresses: { _id: addressId } }
}
);
패턴 2: 1:다(대량) — 참조 + 인덱스
댓글이 수천~수만 개로 늘어날 수 있거나, 댓글을 독립적으로 관리해야 하면 참조 방식을 선택합니다. 게시글 + 댓글 (무제한)
// posts 컬렉션
// 실행 예제
{
_id: ObjectId("post1"),
title: "MongoDB 스키마 설계 가이드",
content: "...",
author: "pkglog",
createdAt: ISODate("2026-03-30T10:00:00Z"),
commentCount: 15234
}
// comments 컬렉션
{
_id: ObjectId("comment1"),
postId: ObjectId("post1"),
author: "user1",
content: "유용한 글입니다!",
createdAt: ISODate("2026-03-30T11:00:00Z"),
likes: 5,
replies: []
}
인덱스 설정
// 실행 예제
db.posts.createIndex({ slug: 1 }, { unique: true });
db.posts.createIndex({ author: 1, createdAt: -1 });
db.comments.createIndex({ postId: 1, createdAt: -1 });
db.comments.createIndex({ author: 1, createdAt: -1 });
댓글 조회 (페이지네이션)
const post = await db.posts.findOne({ _id: postId });
const comments = await db.comments
.find({ postId: postId })
.sort({ createdAt: -1 })
.skip(page * 50)
.limit(50)
.toArray();
장점:
- 댓글 수가 무제한으로 늘어나도 게시글 도큐먼트 크기 일정
- 댓글 독립 수정/삭제 가능 (게시글 잠금 없음)
- 댓글별 인덱스 최적화 가능 주의사항:
- 쿼리 2회 필요 (게시글 + 댓글)
- 댓글 카운트는 비정규화하여 캐시 (일관성 관리 필요) 전자상거래 주문 + 상품
// orders 컬렉션
{
_id: ObjectId("order1"),
userId: ObjectId("user1"),
status: "completed",
items: [
{
productId: ObjectId("prod1"),
name: "노트북",
price: 1200000,
quantity: 1
},
{
productId: ObjectId("prod2"),
name: "마우스",
price: 50000,
quantity: 2
}
],
totalAmount: 1300000,
createdAt: ISODate("2026-03-30T10:00:00Z")
}
// products 컬렉션
{
_id: ObjectId("prod1"),
name: "노트북",
price: 1200000,
stock: 50,
category: "electronics"
}
주문 생성 시 상품 정보 스냅샷
const product = await db.products.findOne({ _id: productId });
await db.orders.insertOne({
userId: userId,
items: [
{
productId: product._id,
name: product.name,
price: product.price,
quantity: 1
}
],
totalAmount: product.price,
createdAt: new Date()
});
패턴 3: 중간: 버킷(Bucket)
시계열 로그처럼 그룹 단위로 묶어 도큐먼트 수를 줄입니다. IoT 센서 데이터 (시계열)
{
_id: ObjectId("..."),
sensorId: "sensor1",
day: ISODate("2026-03-30T00:00:00Z"),
readings: [
{ t: ISODate("2026-03-30T00:05:00Z"), temp: 23.1, humidity: 55 },
{ t: ISODate("2026-03-30T00:10:00Z"), temp: 23.3, humidity: 54 }
],
count: 288,
avgTemp: 23.5,
maxTemp: 25.0,
minTemp: 22.0
}
인덱스 설정
db.sensor_readings.createIndex({ sensorId: 1, day: -1 });
데이터 추가 (upsert + push)
db.sensor_readings.updateOne(
{
sensorId: "sensor1",
day: new Date("2026-03-30T00:00:00Z")
},
{
$push: {
readings: {
t: new Date(),
temp: 23.5,
humidity: 55
}
},
$inc: { count: 1 }
},
{ upsert: true }
);
장점:
- 도큐먼트 수 대폭 감소 (1분당 1개 → 하루당 1개)
- 집계 쿼리 효율적 (일별 평균/최대/최소)
- 인덱스 크기 감소 주의사항:
- 버킷 크기 제한 설계 필요 (예: 하루 최대 288개)
- 버킷이 16MB를 넘지 않도록 모니터링 MongoDB 스키마 설계 임베디드 참조에서 “임베드이지만 버킷으로 끊는” 타협이 자주 나옵니다.
$lookup (조인)
참조 모델에서 가끔 조인이 필요하면 집계 파이프라인의 $lookup을 쓰되, 고빈도 경로는 애플리케이션에서 두 번 읽거나 캐시를 검토합니다.
사용자 + 주문 조인
db.users.aggregate([
{ $match: { _id: userId } },
{
$lookup: {
from: "orders",
localField: "_id",
foreignField: "userId",
as: "orders"
}
},
{
$project: {
name: 1,
email: 1,
orderCount: { $size: "$orders" },
recentOrders: { $slice: ["$orders", -5] }
}
}
]);
주의사항:
$lookup은 인덱스를 타지만 여전히 비용이 큼- 고빈도 경로는 애플리케이션에서 2회 쿼리 + 캐싱 권장
- 필요한 필드만 프로젝션하여 네트워크 비용 절감
고급 활용
다국어 필드
패턴 1: 임베드 (소수 언어)
{
_id: ObjectId("..."),
title: {
ko: "MongoDB 스키마 설계 가이드",
en: "MongoDB Schema Design Guide",
ja: "MongoDBスキーマ設計ガイド"
},
content: {
ko: "...",
en: "...",
ja: "..."
}
}
조회
db.posts.findOne(
{ _id: postId },
{ [`title.${lang}`]: 1, [`content.${lang}`]: 1 }
);
패턴 2: 참조 (다수 언어, 번역 팀 워크플로)
// posts 컬렉션
// 실행 예제
{
_id: ObjectId("post1"),
slug: "mongodb-schema-design",
createdAt: ISODate("2026-03-30T10:00:00Z")
}
// translations 컬렉션
{
_id: ObjectId("..."),
postId: ObjectId("post1"),
lang: "ko",
title: "MongoDB 스키마 설계 가이드",
content: "...",
status: "published"
}
인덱스
db.translations.createIndex({ postId: 1, lang: 1 }, { unique: true });
부분 업데이트 (배열 필터)
특정 조건의 배열 원소만 수정
db.posts.updateOne(
{ _id: postId },
{
$set: {
"comments.$[elem].status": "approved"
}
},
{
arrayFilters: [{ "elem.author": "user1" }]
}
);
여러 원소 동시 수정
// 실행 예제
db.posts.updateOne(
{ _id: postId },
{
$inc: {
"comments.$[elem].likes": 1
}
},
{
arrayFilters: [{ "elem.createdAt": { $gte: new Date("2026-03-01") } }]
}
);
스키마 검증
JSON Schema로 필수 필드 강제
// 실행 예제
db.createCollection("users", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["name", "email", "createdAt"],
properties: {
name: {
bsonType: "string",
minLength: 1,
maxLength: 100
},
email: {
bsonType: "string",
pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"
},
addresses: {
bsonType: "array",
maxItems: 10,
items: {
bsonType: "object",
required: ["label", "city", "zip"],
properties: {
label: { bsonType: "string" },
city: { bsonType: "string" },
zip: { bsonType: "string" }
}
}
}
}
}
}
});
기존 컬렉션에 검증 추가
db.runCommand({
collMod: "users",
validator: {
$jsonSchema: { /* ....*/ }
},
validationLevel: "moderate",
validationAction: "warn"
});
트랜잭션 (MongoDB 4.0+)
멀티 도큐먼트 트랜잭션
const session = client.startSession();
session.startTransaction();
try {
await db.accounts.updateOne(
{ _id: fromAccountId },
{ $inc: { balance: -amount } },
{ session }
);
await db.accounts.updateOne(
{ _id: toAccountId },
{ $inc: { balance: amount } },
{ session }
);
await session.commitTransaction();
} catch (error) {
await session.abortTransaction();
throw error;
} finally {
session.endSession();
}
주의사항:
- 트랜잭션은 성능 비용이 큼
- 가능하면 단일 도큐먼트 원자적 업데이트로 설계
- 트랜잭션은 최후의 수단
성능·비교
읽기 성능
읽기만 놓고 보면 임베드가 유리한 경우가 많아요. 쿼리·왕복이 한 번이니까요. 대신 캐시에 넣을 때는 도큐먼트 전체가 딸려오는 걸 감수해야 하고, 참조면 필요한 조각만 캐싱하기 쉬운 면이 있어요. 인덱스는 임베드는 중첩 필드를 건드리게 되고, 참조는 컬렉션마다 따로 봅니다. “뭐가 항상 이긴다”보다 P95에 뭐가 뜨는지를 보는 게 맞다고 봅니다.
벤치마크 예시 (게시글 + 댓글 100개)
// 임베드 방식
const start1 = Date.now();
const post1 = await db.posts.findOne({ _id: postId });
console.log(`임베드: ${Date.now() - start1}ms`);
// 참조 방식
const start2 = Date.now();
const post2 = await db.posts.findOne({ _id: postId });
const comments2 = await db.comments.find({ postId: postId }).toArray();
console.log(`참조: ${Date.now() - start2}ms`);
결과:
- 임베드: 5-10ms
- 참조: 15-25ms (쿼리 2회 + 네트워크 오버헤드)
쓰기 쪽은 임베드가 언제나 이기는 그림이 아니에요. 같은 _id에 사람들이 update를 몰아넣으면, 문서가 커질수록 그 덩어리를 통째로 다뤄야 해서 꼬이기 쉽고요. 원자성은 한 도큐먼트 안이면 update 한 번에 되는 데, 여러 컬렉션이면 앱이나 트랜잭션이 필요해집니다. 그래서 “읽기는 임베드가 싸다”는 말 뒤에 쓰기 패턴을 안 붙이면 나중에 욕 나와요(제가 그랬음).
벤치마크 예시 (댓글 1000개 추가)
// 임베드 방식 (쓰기 경합 발생)
const start1 = Date.now();
for (let i = 0; i < 1000; i++) {
await db.posts.updateOne(
{ _id: postId },
{ $push: { comments: { author: `user${i}`, content: "..." } } }
);
}
console.log(`임베드: ${Date.now() - start1}ms`);
// 참조 방식 (병렬 처리 가능)
const start2 = Date.now();
const bulkOps = [];
for (let i = 0; i < 1000; i++) {
bulkOps.push({
insertOne: {
document: { postId: postId, author: `user${i}`, content: "..." }
}
});
}
await db.comments.bulkWrite(bulkOps);
console.log(`참조: ${Date.now() - start2}ms`);
결과:
- 임베드: 5000-8000ms (순차 처리, 도큐먼트 잠금)
- 참조: 500-1000ms (벌크 삽입, 병렬 처리)
도큐먼트 크기
임베드 방식 크기 증가
// 댓글 1000개 임베드 시 도큐먼트 크기
const doc = await db.posts.findOne({ _id: postId });
console.log(`크기: ${Object.bsonsize(doc)} bytes`);
결과:
- 댓글 100개: ~50KB
- 댓글 1000개: ~500KB
- 댓글 10000개: ~5MB
- 댓글 30000개: ~15MB (16MB 제한 근접)
실무 사례
전자상거래 주문
요구사항:
- 주문 헤더 + 라인 아이템 (수십 개)
- 상품 마스터 데이터 (수만 개)
- 주문 시점 가격/이름 스냅샷 설계:
- 주문 헤더 + 라인 아이템: 임베드 (함께 조회, 소수)
- 상품 마스터: 참조 (독립 관리, 대량)
// orders 컬렉션
// 실행 예제
{
_id: ObjectId("order1"),
userId: ObjectId("user1"),
status: "completed",
items: [
{ productId: ObjectId("prod1"), name: "노트북", price: 1200000, quantity: 1 }
],
totalAmount: 1200000,
shippingAddress: {
street: "123 Main St",
city: "Seoul",
zip: "03000"
},
createdAt: ISODate("2026-03-30T10:00:00Z")
}
SNS 피드
요구사항:
- 게시글 (수백만 개)
- 댓글 (무제한)
- 좋아요 (무제한) 설계:
- 게시글: 참조 (대량, 독립 관리)
- 댓글: 참조 (무제한)
- 좋아요 수: 비정규화 (캐시 필드)
// posts 컬렉션
// 실행 예제
{
_id: ObjectId("post1"),
userId: ObjectId("user1"),
content: "...",
likeCount: 1234,
commentCount: 567,
createdAt: ISODate("2026-03-30T10:00:00Z")
}
// likes 컬렉션
{
_id: ObjectId("like1"),
postId: ObjectId("post1"),
userId: ObjectId("user2"),
createdAt: ISODate("2026-03-30T11:00:00Z")
}
좋아요 추가 (이벤트 기반 일관성)
await db.likes.insertOne({
postId: postId,
userId: userId,
createdAt: new Date()
});
await db.posts.updateOne(
{ _id: postId },
{ $inc: { likeCount: 1 } }
);
B2B 멀티 테넌트
요구사항:
- 테넌트별 데이터 격리
- 테넌트별 쿼리 성능
- 테넌트별 용량 제한 설계:
tenantId를 모든 도큐먼트에 포함- 인덱스 선두에
tenantId배치
// users 컬렉션
// 실행 예제
{
_id: ObjectId("..."),
tenantId: ObjectId("tenant1"),
name: "Kim",
email: "[email protected]"
}
인덱스
db.users.createIndex({ tenantId: 1, email: 1 }, { unique: true });
db.users.createIndex({ tenantId: 1, createdAt: -1 });
쿼리 (항상 tenantId 포함)
db.users.find({ tenantId: tenantId, email: email });
시계열 로그
요구사항:
- 센서 데이터 (초당 수백 건)
- 일별 집계 (평균/최대/최소)
- 오래된 데이터 아카이브 설계:
- 버킷 패턴 (시간 단위로 묶음)
- TTL 인덱스로 자동 삭제
// sensor_readings 컬렉션
// 실행 예제
{
_id: ObjectId("..."),
sensorId: "sensor1",
day: ISODate("2026-03-30T00:00:00Z"),
readings: [
{ t: ISODate("2026-03-30T00:05:00Z"), temp: 23.1 }
],
count: 288,
avgTemp: 23.5
}
TTL 인덱스 (90일 후 자동 삭제)
db.sensor_readings.createIndex(
{ day: 1 },
{ expireAfterSeconds: 7776000 }
);
트러블슈팅
도큐먼트가 16MB 근처
증상: Document exceeds maximum size 에러
원인: 배열이 무한히 커지는 설계
대응:
- 배열 분리: 참조 모델로 전환
- 아카이브 컬렉션: 오래된 데이터 이동
- 버킷 패턴: 시간/크기 단위로 분할 예시:
// 문제: 모든 이벤트를 한 도큐먼트에
{
_id: ObjectId("user1"),
events: [
{ type: "login", at: ISODate("...") },
// ....수만 개
]
}
// 해결: 참조 모델
// users 컬렉션
{ _id: ObjectId("user1"), name: "Kim" }
// events 컬렉션
{ _id: ObjectId("..."), userId: ObjectId("user1"), type: "login", at: ISODate("...") }
임베드 배열이 커져 update 느림
증상: 댓글 추가 시 응답 시간 증가 원인: 도큐먼트 크기 증가로 메모리 이동 비용 증가 대응:
- 도큐먼트 분할: 참조 모델로 전환
- 페이지네이션: 최근 N개만 임베드
- 버킷 패턴: 그룹 단위로 분할 예시:
// 문제: 댓글 1000개 임베드
{
_id: ObjectId("post1"),
comments: [ /* 1000개 */ ]
}
// 해결: 최근 10개만 임베드, 나머지는 참조
{
_id: ObjectId("post1"),
recentComments: [ /* 최근 10개 */ ],
commentCount: 1000
}
// comments 컬렉션
{ _id: ObjectId("..."), postId: ObjectId("post1"), content: "..." }
참조 무결성 깨짐
증상: 삭제된 상품을 참조하는 주문 존재 원인: MongoDB는 외래키 제약 없음 대응:
- 애플리케이션 검증: 삭제 전 참조 확인
- Soft Delete:
deletedAt필드로 논리 삭제 - 트랜잭션: 여러 컬렉션 동시 업데이트 예시 (Soft Delete):
// products 컬렉션
{
_id: ObjectId("prod1"),
name: "노트북",
price: 1200000,
deletedAt: ISODate("2026-03-30T10:00:00Z")
}
// 조회 시 deletedAt 필터
db.products.find({ _id: productId, deletedAt: { $exists: false } });
인덱스를 탔는데도 느림
증상: explain()에서 인덱스 사용하지만 느림
원인:
- 워킹 세트가 메모리를 초과
- 프로젝션 없이 전체 도큐먼트 반환
- 커버링 인덱스 미사용 대응:
- 워킹 세트 최적화: 자주 쓰는 데이터만 메모리에
- 프로젝션: 필요한 필드만 반환
- 커버링 인덱스: 쿼리가 인덱스만으로 완료 예시 (커버링 인덱스):
// 인덱스
db.users.createIndex({ email: 1, name: 1 });
// 쿼리 (커버링 인덱스 활용)
db.users.find(
{ email: "[email protected]" },
{ _id: 0, email: 1, name: 1 }
);
$lookup 성능 문제
증상: $lookup 사용 시 응답 시간 증가
원인: 조인 비용이 큼
대응:
- 애플리케이션 조인: 2회 쿼리 + 캐싱
- 비정규화: 자주 조인하는 필드 복사
- 인덱스 최적화:
foreignField에 인덱스 예시 (비정규화):
// 문제: 매번 $lookup
db.posts.aggregate([
{
$lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "author"
}
}
]);
// 해결: 자주 쓰는 필드 비정규화
{
_id: ObjectId("post1"),
userId: ObjectId("user1"),
authorName: "Kim",
content: "..."
}
마무리
MongoDB 스키마 설계 임베디드 참조는 “NoSQL이라 아무거나”가 아니라 읽기·쓰기·앞으로의 데이터 크기를 도큐먼트 경계로 박는 일이에요. 표로 “이기는 쪽”을 정리해놓는 것보다, 우리 서비스에서 댓글이/로그가/주문 라인이 얼마나 불어나는지를 먼저 이야기하는 편이 낫습니다.
내가 겪고 나서 굳이 적어두는 원칙이에요(팀 룰과 다르면 팀 룰이 우선):
- “같이 읽힌다”만으로 임베드하지 않는다 — 같이 쓰는지, 누가 쓰는지까지 본다.
- 상한이 애매하면 레퍼런스 — 나중에 쪼개느라 울지 않으려고.
- 16MB는 멀다고 느껴질 때쯤이면 이미 늦은 경우가 많다.
- 비정규화(카운트, 스냅샷) 는 편한 만큼 일치시키는 코드를 짊어진다.
- 멀티 도큐먼트 트랜잭션은 있지만, 설계로 쿼리 수·경합·범위를 줄이는 쪽이 보통 싸다.
관계형이랑 트랜잭션·조인 감이 필요하면 PostgreSQL·MySQL 쪽 글도 같이 보고, 부하 테스트로 “임베드로 박은 게 P95에 안 나오는지”만 확인해도 밤이 짧아져요.
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「MongoDB 스키마 설계: 임베드 vs 참조 선택 기준 | 도큐먼트 모델링」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
- 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.
프로덕션 운영 패턴
표로 묶지 않고, 놓치기 쉬운 질문만 짚을게요. 관측은 상관 ID·p95·p99·의존성 타임아웃/재시도가 대시보드에 한눈에 있나, 안전은 경로마다 검증/권한/감사가 똑같은지, 신뢰는 재시도가 멱등에만 갔는지(서킷·백오프·DLQ), 성능은 캐시/풀/인덱스/백프레셔가 지금 데이터 규모에 맞는지, 배포는 롤백·카나리·마이그레이션이 문서로 남는지, 용량은 피크·디스크·FD·스레드 상한을 주기적으로 찍는지. 스키마를 Mongo로 짰으면, 운영 질문도 도큐먼트 경계랑 같이 봐야 합니다.
스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.
확장 예시: 엔드투엔드 미니 시나리오
앞선 본문 주제(「MongoDB 스키마 설계: 임베드 vs 참조 선택 기준 | 도큐먼트 모델링」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
간헐하게 터지면—레이스, 타임아웃, 외부 망, DNS 쪽을 의심하고, 최소 재현 + 트레이스/로그 상관 + 재시도/서킷을 같이 봅니다. 느리면 N+1·동기 I/O·락·직렬화·캐시 미스를 하나씩 걷어내고(APM 먼저), 메모리만 쑥 올라가면 캐시/리스너/버퍼/커넥션 누수를 봅니다. 빌드만 죽으면 env/권한/플랫폼/lockfile·이미지 핀, 설정이 꼬이면 시크릿/프로필/리전, 데이터가 갈리면 멱등 재시도·부분 쓰기·캐시 무효—여기서 임베드/참조 전환 중간에 나간 케이스가 생기기도 해요(카운트만 올리고 본문은 실패했다든지).
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
배포 전에는 git add → git commit → git push 후 npm run deploy 순서를 권장합니다.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. MongoDB 스키마 설계 임베디드 참조: 1:N·업데이트 빈도·트랜잭션·문서 크기 한도를 기준으로 임베드와 참조를 고르는 실무 체크리스트입니다. MongoDB·NoSQL·스키마 설계 중심으로 설명합니다. Start… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- MongoDB 고급 가이드 | 인덱싱·Aggregation·샤딩·복제·성능 최적화
- C++ 데이터베이스 엔진 구현 | B-Tree·트랜잭션·쿼리 최적화 [#50-4]
- C++ Algorithm MinMax | ‘최소/최대 알고리즘’ 가이드
이 글에서 다루는 키워드 (관련 검색어)
MongoDB, NoSQL, 스키마 설계, 임베디드, 참조, 모델링 등으로 검색하시면 이 글이 도움이 됩니다.