MongoDB 실무

강동욱·2026년 4월 25일

임베딩 (Embedding)

임베딩은 관련 데이터를 하나의 문서 안에 중첩해서 저장하고 RDB의 정규화와 정반대로 의도적으로 데이터를 합쳐서 JOIN을 없애는 방법이다.

기본 예시 — 블로그 포스트 + 댓글

{
  "_id": "post_001",
  "title": "MongoDB 임베딩 완전 정복",
  "content": "...",
  "author": {
    "name": "김개발",
    "avatar": "https://..."
  },
  "tags": ["mongodb", "nosql", "backend"],
  "comments": [
    { "user": "이코딩", "text": "잘 봤습니다!", "date": "2024-01-16" },
    { "user": "박클라우드", "text": "도움 됐어요", "date": "2024-01-17" }
  ],
  "stats": { "views": 1240, "likes": 87 }
}

임베딩을 써야 할 때

상황예시
항상 같이 읽는 데이터포스트와 댓글, 주문과 주문 상품 목록
1:1 또는 1:N (N이 작고 고정적)사용자와 프로필, 설문과 선택지

주의 — 16MB 제한

MongoDB는 문서당 16MB 제한이 있어 댓글이 수천 개 이상 달릴 수 있거나 계속 쌓이는 구조라면 임베딩하면 레퍼런스로 분리해야한다.

레퍼런스 (Referencing)

레퍼런스는 RDB의 외래 키(FK)와 비슷한 개념이다 다른 컬렉션의 문서 _id를 저장해서 연결하는 방식이다. 단, DB 레벨에서 강제하지 않고 앱 코드가 책임지는 게 차이점이다.

기본 예시 — 유저와 활동 로그 분리

// users 컬렉션
{ "_id": "user_123", "name": "Alice", "email": "alice@example.com" }

// activityLogs 컬렉션 — userId로 참조
{
  "_id": "log_001",
  "userId": "user_123",         // 레퍼런스
  "action": "login",
  "ip": "192.168.1.1",
  "timestamp": "2024-01-16T09:00:00Z"
}

레퍼런스를 써야 할 때

상황예시
데이터가 독립적으로 업데이트됨사용자 주소, 상품 가격
무한 증가하는 서브 데이터활동 로그, 알림, 채팅 메시지
M:N 관계학생-수강 과목, 게시글-태그
여러 컬렉션에서 공유하는 데이터카테고리, 브랜드 정보

레퍼런스의 함정 — N+1 문제

N+1 문제는 주로 일대다 또는 다대다 관계에서 발생하는 성능 문제이다.

만약 일반적인 방식으로 사용자와 관련된 모든 게시물을 가져오려고 하면 다음과 같은 접근 방식을 사용할 수 있다.

모든 사용자를 가져오고,
각 사용자에 대해 해당 사용자의 게시물을 가져온다

const users = await User.find();

for (const user of users) {
  const posts = await Post.find({ userId: user._id });
  user.posts = posts;
}

예를 들어, 사용자 100명이 있다고 가정하면 다음과 같이 실행된다.

- 사용자 목록을 가져오는 쿼리 한 번 실행
- 각 사용자에 대해 게시물을 가져오는 쿼리를 100번 실행

따라서 전체적으로는 101개의 쿼리가 실행되는 것이며, 이는 데이터베이스에 부하를 주고, 응답 시간을 늘릴 수 있는 성능 문제를 야기할 수 있다.

해결방법은 아래와 같다

  1. $lookup (MongoDB 네이티브 조인)
const posts = await Post.aggregate([
  {
    $lookup: {
      from: "users",
      localField: "userId",
      foreignField: "_id",
      as: "author"
    }
  },
  {
    $unwind: "$author"   // 배열 → 단일 객체로 풀기
  }
])
  1. populate (Mongoose)
    Mongoose를 쓴다면 populate가 가장 간편한 방법이다. 내부적으로는 $in 쿼리로 배치 조회를 한다.
// 스키마에 ref 설정
const PostSchema = new Schema({
  userId: { type: ObjectId, ref: "User" }
})

// populate — 쿼리 2번으로 해결 (posts 1번 + users 배치 1번)
const posts = await Post.find({}).populate("userId", "name avatar")

$in으로 묶어서 한 번에 가져오기 때문에 N+1은 아니지만, $lookup보다는 쿼리가 1번 더 나간다.

lookup보다 쿼리가 1번 더 나가지만, MongoDB 설계 철학상 조인이 필요한 상황 자체가 예외적인 케이스여야 하기 때문에 성능 차이가 크게 문제되는 경우가 드물다.

lookup은 집계 파이프라인에서 복잡한 조건이 붙을 때만 꺼내는 게 맞다.

실무에서 자주 쓰는 하이브리드 패턴

전부 임베딩도, 전부 레퍼런스도 아니다. 자주 읽는 필드는 임베딩하고, 무거운 데이터는 레퍼런스로 분리하는 게 실전 패턴이다.

// posts 컬렉션 — 하이브리드
{
  "_id": "post_001",
  "title": "MongoDB 실전 가이드",
  "author": {
    "_id": "user_123",
    "name": "김개발",         // 자주 표시되는 필드만 임베딩
    "avatar": "https://..."   // 프로필 전체 내용은 users 컬렉션 참조
  },
  "commentCount": 42          // 댓글 수만 저장, 댓글 본문은 별도 컬렉션
}

조인 — $lookup

MongoDB도 조인이 가능하다. 근데 중요한 건 MongoDB에서 조인은 "어쩔 수 없을 때 쓰는 것" 이지 기본 패턴이 아니다.

$lookup 기본 사용법

db.orders.aggregate([
  { $match: { userId: "user_123" } },

  // products 컬렉션과 조인
  {
    $lookup: {
      from: "products",
      localField: "items.productId",
      foreignField: "_id",
      as: "productDetails"
    }
  },

  // 필요한 필드만 추출
  {
    $project: {
      orderId: 1,
      totalAmount: 1,
      "productDetails.name": 1,
      "productDetails.price": 1
    }
  }
])

$lookup vs 임베딩 성능 비교

항목임베딩$lookup
읽기 속도빠름 (단일 쿼리)느림 (조인 비용)
쓰기 복잡도단순분산 처리 가능
데이터 최신성동기화 필요항상 최신
적합한 규모소규모 중첩 데이터독립 컬렉션, 대용량

데이터 정합성 문제와 대응

RDB vs MongoDB 정합성 비교

항목RDBMongoDB
FK 제약DB 레벨에서 강제없음 (앱 레벨 처리)
CASCADE 삭제자동수동 구현 필요
트랜잭션기본 지원v4.0부터 지원 (성능 비용↑)
스키마 검증엄격선택적 (validator 설정 필요)

문제 1 — 고아 문서 (Orphan Document)

// user 삭제
db.users.deleteOne({ _id: "user_123" })

// user_123의 posts는 그대로 남아있음 — 버그
db.posts.find({ userId: "user_123" })  // 여전히 존재

해결법 — Mongoose 미들웨어로 CASCADE 직접 구현

UserSchema.pre('deleteOne', { document: true }, async function() {
  const userId = this._id
  await Post.deleteMany({ userId })
  await Comment.deleteMany({ userId })
  await ActivityLog.deleteMany({ userId })
})

문제 2 — 임베딩 데이터 불일치

// 상품 가격을 임베딩했을 때
{ orderId: "order001", product: { name: "맥북", price: 2000000 } }

// 나중에 상품 가격 변경
db.products.updateOne({ _id: "macbook" }, { $set: { price: 1800000 } })

// orders에 박혀있는 가격은 여전히 2000000 → 불일치!

해결법 — 자주 바뀌는 데이터는 레퍼런스로 분리하거나, 스냅샷 패턴 의도적으로 사용

문제 3 — 스키마 검증 강제

db.createCollection("users", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["name", "email", "createdAt"],
      properties: {
        email: {
          bsonType: "string",
          pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"
        },
        age: { bsonType: "int", minimum: 0, maximum: 150 }
      }
    }
  },
  validationAction: "error"  // 위반 시 insert 자체를 거부
})

결제, 송금, 회계처럼 정합성이 절대적으로 중요한 도메인은 여전히 RDB 사용해야한다. MongoDB의 멀티 도큐먼트 트랜잭션은 v4.0부터 지원되지만 단일 도큐먼트 연산 대비 성능 비용이 존재한다.

결론 — 언제 뭘 써야 할까?

MongoDB가 맞는 상황

  • 문서마다 구조가 다른 데이터 (상품 카탈로그, IoT 센서)
  • 스키마가 자주 바뀌는 초기 스타트업
  • 트래픽과 데이터가 폭발적으로 늘어날 가능성
  • 대화 히스토리, 활동 로그 같은 비정형 데이터
  • 코드 객체 구조 그대로 저장하고 싶을 때
  • AI/ML 벡터 데이터, LLM 대화 히스토리 저장

RDB가 맞는 상황

  • 결제, 송금, 회계처럼 정합성이 생명인 도메인
  • 복잡한 관계가 많고 JOIN이 많은 구조
  • 팀 전체가 SQL에 익숙한 경우
  • 레거시 시스템과 연동이 필요한 경우
profile
차근차근 개발자

0개의 댓글