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

이 글의 핵심

읽기 한 번이면 끝난다는 이유로 모든 것을 임베드했다가, 댓글과 쓰기가 늘어 문서가 커지자 뒤늦게 스키마를 쪼개야 하는 상황이 자주 생깁니다. 이 글은 그런 실패에서 출발해 1:소수·1:다 관계별 패턴과 전자상거래·SNS 피드·멀티 테넌트 사례를 보여주고, 배열이 커질 때 update가 느려지는 문제도 짚습니다.

들어가며

MongoDB는 스키마가 없다는 말을 자주 듣지만, 실서비스에 들어가면 가장 먼저 부딪히는 문제가 바로 스키마입니다. 관련 데이터를 한 도큐먼트 안에 넣을지(임베드), 다른 컬렉션에 두고 id로 이을지(참조)를 정해야 하기 때문입니다.

저도 예전에는 “읽기 한 번이면 끝나니 임베드가 낫다”는 식으로만 생각했습니다. 그러다 댓글이 늘고 쓰기가 몰리면서 문서가 커지자, 뒤늦게 스키마를 쪼개느라 밤을 샌 적이 있습니다. 이 글은 어느 쪽이 이기는지를 표로 정리하기보다, 그때 왜 꼬였는지와 지금은 어떻게 판단하는지를 적었습니다. 먼저 스키마 설계로 한번 굴러가 본 이야기를 읽으면 흐름이 잡힐 겁니다.

이어서 1:소수, 1:다, 버킷 같은 관계별 패턴을 예제로 살펴보고, 16MB 한도와 인덱스가 이 선택에 어떻게 끼어드는지 다룹니다.


스키마 설계로 한번 굴러가 본 이야기

처음 만든 MongoDB 서비스에서 게시글과 댓글을 한 도큐먼트에 넣었습니다. posts 안에 comments 배열을 두는 구조였고, MVP 시절에는 정말 빨랐습니다. findOne 한 번이면 본문과 댓글이 함께 왔으니까요.

문제는 댓글이 조금씩 달리기 시작하면서 생겼습니다. 스팸이 섞이고, 한 사람이 연달아 댓글을 달고, 숨김·삭제 같은 모더레이션 기능이 붙자 같은 게시글 도큐먼트에 대한 쓰기가 겹쳤습니다. 임베드 구조에서는 댓글 하나를 바꿀 때마다 게시글과 댓글 전체가 든 큰 도큐먼트를 다시 쓰게 됩니다. 읽기는 여전히 한 번에 끝나서 좋았지만, 쓰기 경합과 배열 크기에 따른 갱신 비용은 전혀 다른 문제였습니다.

결국 댓글만 comments 컬렉션으로 뺐습니다. postId와 createdAt에 인덱스를 걸고, 게시글에는 commentCount만 비정규화해 남겼습니다. 마이그레이션은 예상대로 꼬였고, 배포도 훨씬 조심스러워졌습니다. 그때 배운 것은 “함께 읽힌다”와 “함께 쓰인다”가 같지 않다는 점, 그리고 커질 수 있는 자식 데이터는 처음부터 밖에 두는 편이 마음이 편하다는 점입니다. 아래 내가 쓰는 기준은 그 경험에서 나왔습니다. 도메인에 맞게 골라서 가져가면 됩니다.

개념 설명

임베디드(Embedded)

관련 데이터를 하나의 BSON 도큐먼트 안에 중첩 배열이나 서브 도큐먼트로 넣는 방식입니다.

{
  _id: ObjectId("..."),
  name: "Kim",
  addresses: [
    { label: "home", city: "Seoul", zip: "03000" },
    { label: "work", city: "Seongnam", zip: "13000" }
  ]
}

항상 함께 읽는 데이터라면 쿼리 한 번으로 끝납니다. MongoDB는 단일 도큐먼트에 대한 쓰기를 원자적으로 처리하므로, 같은 도큐먼트 안의 여러 필드를 한 번의 update로 일관되게 바꿀 수 있다는 점도 장점입니다.

참조(Referenced)

별도 컬렉션에 두고 ObjectId 같은 키로 연결하는 방식입니다.

// users
{ _id: ObjectId("u1"), name: "Kim" }
// addresses
{ _id: ObjectId("a1"), userId: ObjectId("u1"), city: "Seoul" }

자식 데이터가 수만 건으로 늘어나도 부모 도큐먼트는 커지지 않습니다. 여러 상위 엔티티가 같은 하위 데이터를 가리키는 모델에도 잘 맞습니다.

BSON 도큐먼트 크기

단일 도큐먼트의 최대 크기는 16MB입니다. 배열이 나중에 끝없이 커질 수 있는 데이터라면 처음부터 별도 컬렉션과 인덱스를 고려합니다. 사실 16MB에 닿기 훨씬 전부터 문제가 생깁니다. 도큐먼트가 커질수록 읽을 때마다 전송·역직렬화하는 양이 늘고, 갱신할 때 다시 쓰는 양도 늘어나기 때문입니다.


실전 구현

임베드 vs 레퍼런스, 내가 쓰는 기준

여기서부터는 개인 의견입니다. 정답이라기보다 설계할 때 제가 스스로에게 묻는 질문을 정리한 것이라, 팀 규칙을 만들 때 토론의 출발점으로 쓰면 좋습니다.

저는 참조를 기본값에 가깝게 봅니다. 서비스는 처음에 1:소수라고 생각했던 관계가 몇 달 뒤 1:무한이 되는 경우가 흔하기 때문입니다. 그때 임베드로 넣어 둔 배열을 쪼개려면 데이터 마이그레이션에 다운타임이나 이중 쓰기까지 고민해야 해서, RDB보다 편하다고 말하기 어렵습니다. 반대로 따로 둔 데이터를 나중에 합치는 쪽은 상대적으로 수월합니다.

임베드는 세 가지를 모두 말할 수 있을 때만 고릅니다. 첫째, 읽을 때 거의 항상 같이 가져간다. 둘째, 쓰기도 대부분 부모와 함께 일어난다. 셋째, 자식이 일정 개수 이상 커질 수 없다는 비즈니스 규칙이 있다. 사용자당 주소 몇 개, 주문의 라인 아이템처럼 상한이 사실상 정해진 경우가 여기에 해당합니다.

함께 읽는다는 이유만으로 임베드하면 안 됩니다. 위 이야기처럼 같이 보이는 것과 같이 쓰이는 것이 다르면 자식을 밖으로 빼는 쪽이 부담이 적습니다. 데이터가 계속 늘어나거나, 여러 사용자가 동시에 쓰거나, 관리 콘솔이나 검색처럼 자식만 따로 보는 화면이 생길 수 있다면 참조를 먼저 검토합니다.

패턴 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
}

게시글을 조회하면 댓글까지 한 번에 오고, 댓글 추가나 수정도 단일 도큐먼트에 대한 원자적 갱신으로 처리됩니다. 조회 경로가 _id 하나라 인덱스도 단순합니다. 대신 상한이 지켜지지 않으면 도큐먼트가 계속 커지므로, 댓글 수에 제한이 없다면 참조로 전환해야 합니다.

사용자 프로필 + 주소 목록:

{
  _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 } }
  }
);

임베드한 배열 원소에도 _id를 붙여 두면, 배열 인덱스 번호에 기대지 않고 특정 원소를 안전하게 수정하거나 삭제할 수 있습니다.

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

skip은 건너뛸 도큐먼트를 실제로 훑기 때문에 뒤쪽 페이지로 갈수록 느려집니다. 댓글이 많다면 마지막으로 본 createdAt(과 _id)보다 이전 것을 조회하는 범위 기반 페이지네이션이 낫습니다.

이 구조에서는 댓글이 아무리 늘어도 게시글 도큐먼트 크기가 일정하고, 댓글을 게시글과 무관하게 수정·삭제할 수 있으며, 댓글 조회 패턴에 맞는 인덱스를 따로 둘 수 있습니다. 대신 게시글과 댓글을 가져오려면 쿼리가 두 번 필요하고, 비정규화한 commentCount는 댓글 추가·삭제 때 함께 맞춰 줘야 합니다.

전자상거래 주문 + 상품:

// 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 센서 데이터(5분 간격, 하루 단위 버킷):

{
  _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 }
);

측정값 하나마다 도큐먼트를 만드는 대신 센서·날짜별로 하나만 만들므로 도큐먼트 수와 인덱스 항목이 크게 줄고, 일별 평균·최대·최소 같은 요약값을 버킷에 미리 넣어 두면 집계도 가벼워집니다. 다만 측정 주기가 짧아지면 버킷 하나가 너무 커질 수 있으므로, 쿼리 조건에 count: { $lt: 500 } 같은 상한을 넣어 꽉 찬 버킷 대신 새 버킷이 만들어지게 하는 방식으로 크기를 제한합니다.

MongoDB 5.0 이상이라면 이런 버킷팅을 내부에서 자동으로 해 주는 시계열 컬렉션(timeseries 옵션)도 있으니, 순수한 센서 데이터라면 먼저 검토해 볼 만합니다.

$lookup (조인)

참조 모델에서 가끔 조인이 필요하면 집계 파이프라인의 $lookup을 씁니다. 호출 빈도가 높은 경로라면 애플리케이션에서 두 번 읽거나 캐시를 두는 편을 검토합니다.

사용자 + 최근 주문 5건:

db.users.aggregate([
  { $match: { _id: userId } },
  {
    $lookup: {
      from: "orders",
      let: { uid: "$_id" },
      pipeline: [
        { $match: { $expr: { $eq: ["$userId", "$$uid"] } } },
        { $sort: { createdAt: -1 } },
        { $limit: 5 }
      ],
      as: "recentOrders"
    }
  },
  { $project: { name: 1, email: 1, recentOrders: 1 } }
]);

localField/foreignField 형태의 단순 $lookup은 결과 배열의 순서를 보장하지 않으므로, “최근 N건”이 필요하면 위처럼 파이프라인 안에서 정렬하고 자릅니다. orders.userId(가능하면 { userId: 1, createdAt: -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 }
);

언어가 많거나 번역 팀이 언어별로 따로 작업하고 발행 상태를 관리해야 한다면 참조로 분리합니다.

// 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 });

부분 업데이트 (배열 필터)

arrayFilters를 쓰면 조건에 맞는 배열 원소만 골라서 수정할 수 있습니다. 위치 연산자 $가 첫 번째 일치 원소만 바꾸는 것과 달리, $[식별자]는 조건에 맞는 모든 원소에 적용됩니다.

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") } }]
  }
);

스키마 검증

스키마가 자유롭다고 해서 검증까지 포기할 필요는 없습니다. $jsonSchema 검증기로 필수 필드와 배열 길이 상한을 강제하면, 임베드 배열이 설계한 상한을 넘는 것도 DB 수준에서 막을 수 있습니다.

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"
});

validationLevel: "moderate"는 이미 규칙을 어기고 있는 기존 도큐먼트의 갱신에는 검증을 적용하지 않고, validationAction: "warn"은 위반을 거부하지 않고 로그만 남깁니다. 운영 중인 컬렉션에 검증을 붙일 때는 이렇게 느슨하게 시작해 로그로 위반 건을 확인한 뒤 strict/error로 올리는 편이 안전합니다.

트랜잭션 (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();
}

멀티 도큐먼트 트랜잭션은 레플리카 셋이나 샤드 클러스터에서만 동작하고, 단일 도큐먼트 갱신보다 비용이 큽니다. 트랜잭션 도중 쓰기 충돌이 나면 TransientTransactionError로 실패하므로 재시도 로직도 필요합니다(드라이버의 session.withTransaction()이 이 재시도를 처리해 줍니다). 필요한 곳에는 써야 하지만, 함께 바뀌어야 하는 데이터를 한 도큐먼트에 모을 수 있다면 그쪽이 더 단순합니다.


성능·비교

읽기 성능

읽기만 놓고 보면 임베드가 유리한 경우가 많습니다. 쿼리와 네트워크 왕복이 한 번이기 때문입니다. 대신 도큐먼트 전체가 매번 따라오므로 캐시할 때도 통째로 담아야 하고, 참조 모델은 필요한 조각만 캐싱하기 쉽습니다. 인덱스도 임베드는 중첩 필드(멀티키 인덱스)에 걸게 되고, 참조는 컬렉션마다 따로 설계합니다. 어느 쪽이 항상 이긴다고 보기보다는, 실제 트래픽에서 P95 지연에 무엇이 나타나는지를 보는 것이 맞습니다.

두 방식의 읽기 비용을 직접 비교하려면 아래처럼 같은 데이터를 두 구조로 넣어 두고 재 볼 수 있습니다. 결과는 도큐먼트 크기, 네트워크 거리, 캐시 적중 여부에 따라 크게 달라지므로 자신의 환경에서 측정해야 의미가 있습니다.

// 임베드 방식
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`);

쓰기 성능

쓰기 쪽에서는 임베드가 항상 유리하지 않습니다. 같은 _id에 갱신이 몰리면 WiredTiger는 도큐먼트 단위로 쓰기 충돌을 처리하므로 동시 쓰기가 사실상 직렬화되고, 도큐먼트가 클수록 갱신 한 번에 다시 쓰고 복제해야 하는 양도 커집니다. 원자성도 한 도큐먼트 안이라면 update 한 번으로 해결되지만, 여러 컬렉션에 걸치면 애플리케이션 로직이나 트랜잭션이 필요해집니다. 그래서 “읽기는 임베드가 싸다”는 말에는 반드시 쓰기 패턴을 함께 붙여야 합니다. 저는 이 부분을 빠뜨렸다가 나중에 고생했습니다.

아래는 댓글 1000개를 넣는 두 방식을 비교하는 예입니다. 임베드 쪽은 같은 도큐먼트를 1000번 갱신하고, 참조 쪽은 독립된 도큐먼트 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`);

이 비교는 왕복 횟수(1000번 대 1번)의 차이도 함께 섞여 있으므로 공정한 벤치마크는 아닙니다. 실제로 확인해야 할 것은 여러 클라이언트가 동시에 같은 게시글에 댓글을 달 때 지연이 어떻게 늘어나는지입니다.

도큐먼트 크기

임베드 배열이 얼마나 커졌는지는 셸에서 바로 확인할 수 있습니다.

const doc = await db.posts.findOne({ _id: postId });
console.log(`크기: ${Object.bsonsize(doc)} bytes`);

도큐먼트 크기는 댓글 수에 거의 비례해 늘어납니다. 평균 댓글 하나의 BSON 크기에 예상 최대 댓글 수를 곱해 보면 16MB까지 얼마나 여유가 있는지 대략 가늠할 수 있습니다. 집계 파이프라인의 $bsonSize 연산자(4.4+)로 컬렉션 전체에서 가장 큰 도큐먼트를 찾아 두는 것도 좋습니다.


실무 사례

전자상거래 주문

주문 헤더와 라인 아이템(많아야 수십 개)은 함께 조회되고 개수가 제한적이므로 임베드합니다. 수만 개의 상품 마스터 데이터는 독립적으로 관리되므로 참조로 둡니다. 라인 아이템에는 주문 시점의 상품명과 가격을 스냅샷으로 복사해 둡니다.

// 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")
}

좋아요 추가:

// 같은 사용자가 두 번 좋아요하지 못하도록 유니크 인덱스
db.likes.createIndex({ postId: 1, userId: 1 }, { unique: true });

await db.likes.insertOne({
  postId: postId,
  userId: userId,
  createdAt: new Date()
});
await db.posts.updateOne(
  { _id: postId },
  { $inc: { likeCount: 1 } }
);

두 쓰기는 트랜잭션으로 묶여 있지 않으므로, 첫 번째가 성공하고 두 번째가 실패하면 likeCount가 실제 좋아요 수와 어긋납니다. 좋아요 수처럼 약간의 오차가 허용되는 값이라면 주기적으로 likes 컬렉션을 세어 보정하는 배치로 충분한 경우가 많고, 정확해야 한다면 트랜잭션으로 묶습니다. 유니크 인덱스 때문에 중복 좋아요의 insertOne이 실패하면 카운트 증가도 건너뛰어야 합니다.

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 });

tenantId 조건을 빠뜨린 쿼리 하나가 다른 테넌트의 데이터를 노출하는 사고로 이어지므로, 이 조건은 개별 쿼리에 맡기지 말고 리포지토리 계층이나 ORM 미들웨어에서 강제로 붙이는 편이 안전합니다.

시계열 로그

초당 수백 건씩 들어오는 센서 데이터를 일별로 집계하고, 오래된 데이터는 자동으로 지우는 경우입니다. 앞의 버킷 패턴으로 시간 단위로 묶고, 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 }
);

TTL 삭제는 백그라운드 작업이 주기적으로(기본 60초) 처리하므로 만료 시각에 정확히 지워지지는 않습니다. 버킷 도큐먼트는 day 필드 기준으로 버킷 전체가 한꺼번에 만료됩니다.


트러블슈팅

도큐먼트가 16MB 근처

BSONObjectTooLarge나 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 느림

댓글을 추가할 때마다 응답 시간이 조금씩 늘어나는 증상입니다. WiredTiger는 도큐먼트를 제자리에서 고치지 않고 갱신할 때마다 새 버전을 쓰기 때문에, 도큐먼트가 클수록 작은 변경에도 쓰기·캐시·복제 비용이 커집니다. 배열 필드에 멀티키 인덱스가 걸려 있다면 원소 수만큼 인덱스 항목도 관리해야 합니다.

도큐먼트를 분할해 참조 모델로 옮기는 것이 근본적인 해결이고, 화면에 최근 몇 개만 보여 준다면 최근 N개만 임베드하고 나머지는 참조로 두는 방식(Subset 패턴)도 쓸 수 있습니다.

// 문제: 댓글 1000개 임베드
{
  _id: ObjectId("post1"),
  comments: [ /* 1000개 */ ]
}
// 해결: 최근 10개만 임베드, 나머지는 참조
{
  _id: ObjectId("post1"),
  recentComments: [ /* 최근 10개 */ ],
  commentCount: 1000
}
// comments 컬렉션
{ _id: ObjectId("..."), postId: ObjectId("post1"), content: "..." }

최근 N개 유지는 $push에 $each, $slice: -10을 함께 써서 한 번의 갱신으로 처리할 수 있습니다.

참조 무결성 깨짐

삭제된 상품을 참조하는 주문이 남는 경우입니다. MongoDB에는 외래 키 제약이 없어서 DB가 이런 상태를 막아 주지 않습니다. 삭제 전에 애플리케이션에서 참조 여부를 확인하거나, 실제로 지우지 않고 deletedAt 필드로 논리 삭제하거나, 여러 컬렉션을 함께 바꿔야 한다면 트랜잭션을 씁니다.

// products 컬렉션
{
  _id: ObjectId("prod1"),
  name: "노트북",
  price: 1200000,
  deletedAt: ISODate("2026-03-30T10:00:00Z")
}
// 조회 시 deletedAt 필터
db.products.find({ _id: productId, deletedAt: { $exists: false } });

인덱스를 탔는데도 느림

explain()을 보면 인덱스를 쓰고 있는데도 느린 경우입니다. 자주 쓰는 데이터와 인덱스(워킹 세트)가 WiredTiger 캐시에 다 들어가지 않거나, 프로젝션 없이 큰 도큐먼트 전체를 돌려주고 있거나, 인덱스로 찾은 뒤 도큐먼트를 다시 읽는 비용이 큰 경우가 많습니다. explain("executionStats")에서 totalDocsExamined와 nReturned를 비교하면 어디서 시간이 드는지 보입니다.

필요한 필드만 프로젝션하고, 가능하면 쿼리가 인덱스만으로 끝나는 커버링 인덱스를 만듭니다.

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

$lookup 성능 문제

$lookup을 쓰는 쿼리의 응답 시간이 길어지는 경우입니다. 먼저 조인 대상 컬렉션의 foreignField에 인덱스가 있는지 확인합니다. 인덱스가 없으면 입력 도큐먼트마다 대상 컬렉션을 풀 스캔합니다. 그래도 호출 빈도가 높다면 애플리케이션에서 두 번 조회하고 캐싱하거나, 자주 조인하는 필드를 비정규화합니다.

// 문제: 매번 $lookup
db.posts.aggregate([
  {
    $lookup: {
      from: "users",
      localField: "userId",
      foreignField: "_id",
      as: "author"
    }
  }
]);
// 해결: 자주 쓰는 필드 비정규화
{
  _id: ObjectId("post1"),
  userId: ObjectId("user1"),
  authorName: "Kim",
  content: "..."
}

비정규화한 authorName은 사용자가 이름을 바꿀 때 함께 갱신해야 합니다. 이름 변경이 드물다면 updateMany로 한꺼번에 고치는 것으로 충분하지만, 자주 바뀌는 값이라면 비정규화 대상으로 적합하지 않습니다.


겪고 나서 적어 두는 원칙

스키마 설계는 “NoSQL이니 아무렇게나”가 아니라 읽기·쓰기 패턴과 앞으로의 데이터 크기를 도큐먼트 경계로 정하는 일입니다. 어느 쪽이 이기는지 표로 외우기보다, 우리 서비스에서 댓글이나 로그나 주문 라인이 얼마나 불어날지를 먼저 이야기하는 편이 낫습니다. 제가 겪고 나서 지키는 원칙은 이렇습니다(팀 규칙과 다르면 팀 규칙이 우선입니다).

  1. 같이 읽힌다는 이유만으로 임베드하지 않습니다. 같이 쓰이는지, 누가 쓰는지까지 봅니다.
  2. 상한이 애매하면 참조로 둡니다. 나중에 쪼개는 비용이 훨씬 큽니다.
  3. 16MB가 멀게 느껴질 때쯤이면 이미 성능 문제가 시작된 경우가 많습니다.
  4. 카운트나 스냅샷 같은 비정규화는 편한 만큼 값을 맞춰 주는 코드를 떠안습니다.
  5. 멀티 도큐먼트 트랜잭션이 있어도, 설계로 쿼리 수와 경합 범위를 줄이는 쪽이 보통 더 쌉니다.

부하 테스트로 “임베드한 구조가 P95 지연에 나타나지 않는지”만 확인해 둬도 운영 중에 당황할 일이 크게 줄어듭니다.


자주 묻는 질문 (FAQ)

Q. 처음에는 임베드가 편해 보이는데 참조를 기본으로 두는 이유는 무엇인가요?

처음에는 1:소수라고 생각한 관계가 몇 달 뒤 1:무한으로 늘어나는 경우가 흔하고, 임베드 배열이 커지면 문서 크기 한도와 갱신 비용 문제가 생깁니다. 이때 임베드된 배열을 별도 컬렉션으로 쪼개려면 스키마 마이그레이션과 이중 쓰기가 필요해 비용이 큽니다. 그래서 항상 함께 읽고 함께 쓰며 자식 개수에 상한이 있는 주소나 주문 라인 아이템 같은 경우에만 임베드를 선택하는 편이 안전합니다.


같이 보면 좋은 글