임베딩은 관련 데이터를 하나의 문서 안에 중첩해서 저장하고 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 제한이 있어 댓글이 수천 개 이상 달릴 수 있거나 계속 쌓이는 구조라면 임베딩하면 레퍼런스로 분리해야한다.
레퍼런스는 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개의 쿼리가 실행되는 것이며, 이는 데이터베이스에 부하를 주고, 응답 시간을 늘릴 수 있는 성능 문제를 야기할 수 있다.
해결방법은 아래와 같다
const posts = await Post.aggregate([
{
$lookup: {
from: "users",
localField: "userId",
foreignField: "_id",
as: "author"
}
},
{
$unwind: "$author" // 배열 → 단일 객체로 풀기
}
])
// 스키마에 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 // 댓글 수만 저장, 댓글 본문은 별도 컬렉션
}
MongoDB도 조인이 가능하다. 근데 중요한 건 MongoDB에서 조인은 "어쩔 수 없을 때 쓰는 것" 이지 기본 패턴이 아니다.
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 |
|---|---|---|
| 읽기 속도 | 빠름 (단일 쿼리) | 느림 (조인 비용) |
| 쓰기 복잡도 | 단순 | 분산 처리 가능 |
| 데이터 최신성 | 동기화 필요 | 항상 최신 |
| 적합한 규모 | 소규모 중첩 데이터 | 독립 컬렉션, 대용량 |
| 항목 | RDB | MongoDB |
|---|---|---|
| FK 제약 | DB 레벨에서 강제 | 없음 (앱 레벨 처리) |
| CASCADE 삭제 | 자동 | 수동 구현 필요 |
| 트랜잭션 | 기본 지원 | v4.0부터 지원 (성능 비용↑) |
| 스키마 검증 | 엄격 | 선택적 (validator 설정 필요) |
// 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 })
})
// 상품 가격을 임베딩했을 때
{ orderId: "order001", product: { name: "맥북", price: 2000000 } }
// 나중에 상품 가격 변경
db.products.updateOne({ _id: "macbook" }, { $set: { price: 1800000 } })
// orders에 박혀있는 가격은 여전히 2000000 → 불일치!
해결법 — 자주 바뀌는 데이터는 레퍼런스로 분리하거나, 스냅샷 패턴 의도적으로 사용
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부터 지원되지만 단일 도큐먼트 연산 대비 성능 비용이 존재한다.