MongoDB 스키마 설계: 임베드 vs 참조 선택 기준 | 도큐먼트 모델링

MongoDB 스키마 설계: 임베드 vs 참조 선택 기준 | 도큐먼트 모델링

이 글의 핵심

도큐먼트 크기·조회 패턴·갱신 경합·일관성 요구를 기준으로 임베디드와 참조를 나누고, 버킷·부분 업데이트 패턴까지 정리합니다.

들어가며

MongoDB는 스키마리스에 가깝지만, 실제 서비스에서는 스키마를 정한다고 보는 편이 운영에 유리합니다. MongoDB 스키마 설계 임베디드 참조는 “자식 데이터를 부모 도큐먼트 안에 넣을지(embedded), 별 컬렉션에 두고 id로 묶을지(referenced)“를 가르는 핵심 결정입니다.

임베드는 읽기 한 번에 끝나는 장점이 있고, 참조는 갱신·재사용·도큐먼트 크기를 제어하기 쉽습니다. 이 글은 감이 아니라 측정 가능한 기준(크기, 빈도, 일관성)으로 선택하는 방법을 정리합니다.

이 글을 읽으면

  • 임베디드 vs 참조를 판단하는 체크리스트를 적용할 수 있습니다
  • 1:N, N:M, 다국어·버전 같은 변형 패턴을 설계할 때 힌트를 얻습니다
  • 도큐먼트 16MB 제한, 인덱스 전략과의 연관을 이해합니다

목차

  1. 개념 설명
  2. 실전 구현
  3. 고급 활용
  4. 성능·비교
  5. 실무 사례
  6. 트러블슈팅
  7. 마무리

개념 설명

임베디드(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입니다. “나중에 배열이 무한히 커질 수 있는” 데이터는 처음부터 별 컬렉션 + 인덱스를 고려합니다.


실전 구현

선택 기준 체크리스트

질문임베드에 유리참조에 유리
함께 읽는가?거의 항상드물거나 일부만
함께 갱신되는가?동시에 자주독립적으로 자주
자식 개수 상한이 있는가?소수(예: 주소 몇 개)수천~무제한
여러 부모가 같은 자식을 공유?거의 없음있음
강한 일관성(외래키 느낌) 필요?단일 도큐먼트로 묶을 수 있으면여러 컬렉션 조정

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

주의사항:

  • 트랜잭션은 성능 비용이 큼
  • 가능하면 단일 도큐먼트 원자적 업데이트로 설계
  • 트랜잭션은 최후의 수단

성능·비교

읽기 성능

측면임베드참조
쿼리 횟수1회2회 이상
네트워크 왕복1회2회 이상
인덱스 활용중첩 필드 인덱스컬렉션별 인덱스
캐싱도큐먼트 전체 캐싱부분 캐싱 가능

벤치마크 예시 (게시글 + 댓글 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회 + 네트워크 오버헤드)

쓰기 성능

측면임베드참조
쓰기 경합같은 도큐먼트에 몰리면 병목분산 가능
원자성단일 도큐먼트 원자적트랜잭션 필요
인덱스 갱신중첩 필드 인덱스 갱신컬렉션별 인덱스 갱신

벤치마크 예시 (댓글 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 에러

원인: 배열이 무한히 커지는 설계

대응:

  1. 배열 분리: 참조 모델로 전환
  2. 아카이브 컬렉션: 오래된 데이터 이동
  3. 버킷 패턴: 시간/크기 단위로 분할

예시:

// 문제: 모든 이벤트를 한 도큐먼트에
{
  _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 느림

증상: 댓글 추가 시 응답 시간 증가

원인: 도큐먼트 크기 증가로 메모리 이동 비용 증가

대응:

  1. 도큐먼트 분할: 참조 모델로 전환
  2. 페이지네이션: 최근 N개만 임베드
  3. 버킷 패턴: 그룹 단위로 분할

예시:

// 문제: 댓글 1000개 임베드
{
  _id: ObjectId("post1"),
  comments: [ /* 1000개 */ ]
}

// 해결: 최근 10개만 임베드, 나머지는 참조
{
  _id: ObjectId("post1"),
  recentComments: [ /* 최근 10개 */ ],
  commentCount: 1000
}

// comments 컬렉션
{ _id: ObjectId("..."), postId: ObjectId("post1"), content: "..." }

참조 무결성 깨짐

증상: 삭제된 상품을 참조하는 주문 존재

원인: MongoDB는 외래키 제약 없음

대응:

  1. 애플리케이션 검증: 삭제 전 참조 확인
  2. Soft Delete: deletedAt 필드로 논리 삭제
  3. 트랜잭션: 여러 컬렉션 동시 업데이트

예시 (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()에서 인덱스 사용하지만 느림

원인:

  1. 워킹 세트가 메모리를 초과
  2. 프로젝션 없이 전체 도큐먼트 반환
  3. 커버링 인덱스 미사용

대응:

  1. 워킹 세트 최적화: 자주 쓰는 데이터만 메모리에
  2. 프로젝션: 필요한 필드만 반환
  3. 커버링 인덱스: 쿼리가 인덱스만으로 완료

예시 (커버링 인덱스):

// 인덱스
db.users.createIndex({ email: 1, name: 1 });

// 쿼리 (커버링 인덱스 활용)
db.users.find(
  { email: "[email protected]" },
  { _id: 0, email: 1, name: 1 }
);

$lookup 성능 문제

증상: $lookup 사용 시 응답 시간 증가

원인: 조인 비용이 큼

대응:

  1. 애플리케이션 조인: 2회 쿼리 + 캐싱
  2. 비정규화: 자주 조인하는 필드 복사
  3. 인덱스 최적화: 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이라 아무거나”가 아니라 읽기 패턴과 성장 경계를 코드로 고정하는 작업입니다.

핵심 원칙:

  1. 함께 읽으면 임베드, 독립적이면 참조
  2. 소수면 임베드, 대량이면 참조
  3. 16MB 제한 항상 염두
  4. 비정규화는 성능을 위한 트레이드오프 (일관성 관리 필요)
  5. 트랜잭션은 최후의 수단 (설계로 범위 줄이기)

관계형 DB와의 트랜잭션·조인 관점 비교가 필요하면 PostgreSQL·MySQL 선택 가이드와 함께 보시고, 이 체크리스트로 1차 설계를 잡은 뒤 부하 테스트로 검증하는 순서를 권합니다.