본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 11. 27. · 22 Views
MongoDB 데이터 모델링 완벽 가이드
MongoDB에서 데이터를 효율적으로 설계하는 방법을 다룹니다. 임베딩과 참조 전략부터 다양한 관계 모델링, 스키마 버전 관리까지 실무에서 바로 적용할 수 있는 패턴을 배웁니다.
목차
1. 임베딩 vs 참조 전략
김개발 씨는 첫 MongoDB 프로젝트를 맡게 되었습니다. 기존에 MySQL만 사용하던 그는 테이블을 어떻게 설계해야 할지 막막했습니다.
"외래 키는 어디 있는 거지? 조인은 어떻게 하지?" 선배 박시니어 씨가 웃으며 말했습니다.
"MongoDB에서는 생각의 전환이 필요해요. 임베딩과 참조, 두 가지 전략을 먼저 이해해야 합니다."
임베딩은 관련 데이터를 하나의 문서 안에 중첩하여 저장하는 방식입니다. 반면 참조는 관계형 데이터베이스처럼 다른 문서의 ID를 저장하여 연결하는 방식입니다.
마치 책을 만들 때 부록을 본문에 함께 넣을지, 별도의 책으로 분리할지 결정하는 것과 같습니다. 이 선택에 따라 쿼리 성능과 데이터 일관성이 크게 달라집니다.
다음 코드를 살펴봅시다.
// 임베딩 방식: 주문과 상품 정보를 하나의 문서에 저장
const embeddedOrder = {
_id: ObjectId("order123"),
customerName: "김개발",
orderDate: new Date(),
// 상품 정보를 문서 내부에 임베딩
items: [
{ name: "노트북", price: 1500000, quantity: 1 },
{ name: "마우스", price: 50000, quantity: 2 }
],
totalAmount: 1600000
};
// 참조 방식: 상품 ID만 저장하고 별도 컬렉션 참조
const referencedOrder = {
_id: ObjectId("order456"),
customerName: "박시니어",
orderDate: new Date(),
// 상품 ID만 참조로 저장
itemIds: [ObjectId("product001"), ObjectId("product002")],
totalAmount: 1600000
};
김개발 씨는 입사 첫 해에 MongoDB 프로젝트를 맡게 되었습니다. 그동안 MySQL로만 개발해왔던 그에게 NoSQL은 완전히 새로운 세상이었습니다.
특히 테이블 없이 어떻게 데이터를 구조화해야 할지 도무지 감이 오지 않았습니다. 선배 개발자 박시니어 씨가 화이트보드 앞으로 김개발 씨를 불렀습니다.
"MongoDB를 처음 접하면 다들 혼란스러워해요. 관계형 DB의 사고방식을 버리고, 문서 중심으로 생각해야 합니다." 그렇다면 임베딩과 참조란 정확히 무엇일까요?
쉽게 비유하자면, 임베딩은 마치 종합선물세트와 같습니다. 과자, 음료, 사탕이 모두 하나의 박스에 담겨 있어서 받는 사람이 한 번에 모든 것을 확인할 수 있습니다.
반면 참조는 카탈로그와 같습니다. 카탈로그에는 상품 번호만 적혀 있고, 실제 상품은 창고에서 따로 가져와야 합니다.
MongoDB에서 임베딩을 사용하면 한 번의 쿼리로 모든 관련 데이터를 가져올 수 있습니다. 네트워크 요청이 줄어들고, 데이터 지역성이 높아져 읽기 성능이 크게 향상됩니다.
하지만 문서 크기가 커질 수 있고, 중복 데이터가 발생할 수 있다는 단점이 있습니다. 참조 방식은 관계형 데이터베이스와 비슷합니다.
데이터가 분리되어 있어 독립적으로 업데이트할 수 있고, 중복을 피할 수 있습니다. 하지만 여러 컬렉션을 조회해야 하므로 쿼리가 복잡해지고, $lookup 연산으로 인한 성능 저하가 발생할 수 있습니다.
위의 코드를 살펴보겠습니다. 첫 번째 예제에서는 주문 문서 안에 상품 정보가 배열로 임베딩되어 있습니다.
주문을 조회하면 상품 정보도 함께 가져옵니다. 두 번째 예제에서는 상품 ID만 저장하고, 실제 상품 정보는 별도의 products 컬렉션에서 조회해야 합니다.
실제 현업에서는 어떻게 선택할까요? 쇼핑몰을 예로 들면, 주문 내역에서 상품 정보는 임베딩이 적합합니다.
주문 당시의 가격과 상품명이 변하지 않아야 하고, 주문 조회 시 항상 함께 필요하기 때문입니다. 반면 상품의 현재 재고 정보는 참조가 적합합니다.
실시간으로 변하는 데이터이고, 여러 곳에서 독립적으로 업데이트되기 때문입니다. 하지만 주의할 점도 있습니다.
MongoDB 문서의 최대 크기는 16MB입니다. 무한정 임베딩하다 보면 이 한계에 도달할 수 있습니다.
또한 임베딩된 데이터가 자주 변경되면 전체 문서를 다시 써야 하므로 성능이 저하됩니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 들은 후, 그는 자신의 프로젝트에서 어떤 데이터를 임베딩하고 어떤 데이터를 참조할지 명확해졌습니다. "함께 읽는 데이터는 임베딩, 독립적인 데이터는 참조"라는 원칙을 세웠습니다.
실전 팁
💡 - 데이터를 함께 읽는 빈도가 높다면 임베딩을 선택하세요
- 임베딩된 배열이 무한히 커질 가능성이 있다면 참조로 전환하세요
- 읽기와 쓰기 비율을 분석하여 전략을 결정하세요
2. 1대1 관계 모델링
김개발 씨는 회원 시스템을 설계하고 있었습니다. 사용자 기본 정보와 상세 프로필을 어떻게 저장해야 할지 고민이었습니다.
"MySQL이었으면 두 테이블로 나누고 외래 키로 연결했을 텐데..." 박시니어 씨가 힌트를 주었습니다. "1:1 관계에서는 대부분 임베딩이 답입니다.
하지만 예외도 있어요."
1:1 관계는 하나의 엔티티가 정확히 하나의 다른 엔티티와 연결되는 관계입니다. MongoDB에서는 이런 관계를 주로 서브다큐먼트 임베딩으로 처리합니다.
마치 주민등록증과 운전면허증이 한 사람에게 각각 하나씩만 발급되는 것과 같습니다. 두 정보를 한 지갑에 넣으면 찾기 쉽고, 분실 위험도 줄어듭니다.
다음 코드를 살펴봅시다.
// 1:1 임베딩: 사용자와 프로필을 하나의 문서로
const userWithProfile = {
_id: ObjectId("user001"),
email: "kim@example.com",
password: "hashedPassword",
createdAt: new Date(),
// 프로필 정보를 서브다큐먼트로 임베딩
profile: {
nickname: "김개발",
bio: "주니어 백엔드 개발자입니다",
avatar: "/images/avatars/kim.jpg",
socialLinks: {
github: "https://github.com/kimdev",
linkedin: "https://linkedin.com/in/kimdev"
}
}
};
// 1:1 참조: 민감한 정보를 분리할 때
const userCredentials = {
_id: ObjectId("cred001"),
userId: ObjectId("user001"), // 사용자 참조
paymentToken: "encrypted_token",
billingAddress: "서울시 강남구..."
};
김개발 씨는 회원 관리 기능을 구현하면서 데이터 구조를 고민했습니다. 사용자의 로그인 정보와 프로필 정보를 어떻게 저장해야 효율적일까요?
기존 관계형 데이터베이스 경험으로는 users 테이블과 profiles 테이블을 따로 만들고 JOIN으로 연결했을 것입니다. 박시니어 씨가 조언했습니다.
"MongoDB에서 1:1 관계는 거의 대부분 임베딩으로 해결해요. 두 번 쿼리할 이유가 없거든요." 1:1 관계란 무엇일까요?
쉽게 말해, 하나의 사용자에게 하나의 프로필만 존재하는 관계입니다. 한 사람이 여러 개의 주민등록번호를 가질 수 없는 것처럼, 엔티티 간에 일대일로 매핑되는 관계를 말합니다.
비유하자면, 1:1 임베딩은 마치 여권 안에 사진을 붙여두는 것과 같습니다. 여권을 확인할 때 사진도 함께 보이니 별도로 사진을 찾을 필요가 없습니다.
반면 1:1 참조는 여권 번호만 적어두고, 사진은 별도의 사진첩에 보관하는 것과 같습니다. 왜 1:1에서는 임베딩이 유리할까요?
첫째, 한 번의 쿼리로 모든 정보를 가져올 수 있습니다. 둘째, 데이터 지역성이 높아 디스크 읽기가 효율적입니다.
셋째, 트랜잭션 없이도 원자적 업데이트가 가능합니다. 하나의 문서 업데이트는 MongoDB에서 원자적으로 처리되기 때문입니다.
위 코드의 첫 번째 예제를 보면, 사용자 문서 안에 profile이라는 서브다큐먼트가 포함되어 있습니다. 사용자 정보를 조회하면 프로필도 함께 가져오고, 프로필을 수정해도 사용자 문서 하나만 업데이트하면 됩니다.
그렇다면 1:1에서 참조를 사용하는 경우는 언제일까요? 두 번째 예제처럼 결제 정보나 민감한 데이터를 분리할 때입니다.
보안 정책상 특정 데이터에 대한 접근을 제한해야 하거나, 접근 패턴이 완전히 다를 때 분리합니다. 실무에서 흔히 볼 수 있는 사례를 들어보겠습니다.
게임 서비스에서 사용자 기본 정보는 로그인할 때마다 조회하지만, 상세 통계 정보는 프로필 페이지에서만 필요합니다. 이런 경우 통계 정보를 별도 컬렉션으로 분리하면 로그인 쿼리가 가벼워집니다.
주의할 점도 있습니다. 무조건 임베딩한다고 좋은 것은 아닙니다.
서브다큐먼트가 매우 크거나, 자주 독립적으로 업데이트된다면 분리를 고려해야 합니다. 또한 서브다큐먼트에 대해 별도의 인덱스가 필요한 경우에도 분리가 유리할 수 있습니다.
김개발 씨는 결국 사용자 기본 정보와 프로필을 하나의 문서로 임베딩하고, 결제 정보만 별도 컬렉션으로 분리했습니다. "자주 함께 쓰이는 데이터는 함께, 민감하거나 접근 패턴이 다른 데이터는 분리"라는 원칙을 적용한 것입니다.
실전 팁
💡 - 1:1 관계는 기본적으로 임베딩을 먼저 고려하세요
- 서브다큐먼트 크기가 1MB를 넘어가면 분리를 검토하세요
- 보안 요구사항이 다른 데이터는 별도 컬렉션으로 분리하세요
3. 1대N 관계 모델링
김개발 씨는 블로그 시스템의 게시글과 댓글 관계를 설계하고 있었습니다. "게시글 하나에 댓글이 여러 개 달리니까 1:N 관계인데...
댓글을 임베딩해야 할까, 참조해야 할까?" 박시니어 씨가 핵심 질문을 던졌습니다. "댓글이 최대 몇 개까지 달릴 수 있나요?
그 숫자가 답을 알려줄 거예요."
1:N 관계는 하나의 부모 엔티티가 여러 자식 엔티티를 가지는 관계입니다. MongoDB에서 이 관계를 모델링할 때는 N의 크기가 핵심 판단 기준입니다.
마치 서랍장에 물건을 넣을 때, 물건이 몇 개인지에 따라 작은 서랍에 넣을지, 큰 창고를 따로 마련할지 결정하는 것과 같습니다.
다음 코드를 살펴봅시다.
// 작은 N: 배열 임베딩 (게시글의 태그)
const postWithTags = {
_id: ObjectId("post001"),
title: "MongoDB 입문하기",
content: "MongoDB는 문서 지향 데이터베이스입니다...",
// 태그는 보통 10개 미만이므로 임베딩
tags: ["mongodb", "database", "nosql", "backend"]
};
// 중간 N: 자식 참조 배열 (게시글의 댓글 ID 목록)
const postWithCommentRefs = {
_id: ObjectId("post002"),
title: "MongoDB 심화 가이드",
// 댓글 ID만 참조로 저장 (최대 수백 개 예상)
commentIds: [ObjectId("comment001"), ObjectId("comment002")]
};
// 큰 N: 부모 참조 (댓글에서 게시글 참조)
const comment = {
_id: ObjectId("comment001"),
postId: ObjectId("post002"), // 부모 게시글 참조
author: "김개발",
content: "정말 유용한 글이네요!",
createdAt: new Date()
};
김개발 씨는 블로그 시스템을 설계하면서 1:N 관계의 다양한 경우를 마주했습니다. 게시글과 태그, 게시글과 댓글, 사용자와 게시글 등 모두 1:N이지만 성격이 조금씩 달랐습니다.
박시니어 씨가 화이트보드에 세 가지 패턴을 그렸습니다. "1:N에서 N의 크기에 따라 전략이 완전히 달라져요.
이걸 Small N, Medium N, Large N 패턴이라고 부릅니다." 먼저 Small N 패턴을 살펴봅시다. N이 작고 상한선이 명확할 때 사용합니다.
게시글의 태그가 좋은 예입니다. 태그는 보통 10개를 넘지 않고, 게시글을 조회할 때 항상 함께 필요합니다.
이런 경우 배열로 임베딩하는 것이 최선입니다. 비유하자면, 지갑에 신용카드를 넣는 것과 같습니다.
카드가 3-4장이면 지갑에 넣어 다니는 게 편합니다. 하지만 카드가 100장이라면 별도의 카드 보관함이 필요하겠죠.
Medium N 패턴은 N이 수십에서 수백 개 사이일 때 적합합니다. 이 경우 자식 문서의 ID만 배열로 저장하고, 실제 데이터는 별도 컬렉션에 둡니다.
게시글의 댓글이 이 경우에 해당할 수 있습니다. 댓글 목록을 페이징 처리하거나, 특정 댓글만 조회해야 할 때 유리합니다.
Large N 패턴은 N이 무한히 커질 수 있을 때 사용합니다. 이 경우 부모가 자식을 참조하는 대신, 자식이 부모를 참조합니다.
위 코드의 마지막 예제처럼 댓글 문서에 postId를 저장하는 방식입니다. 인기 게시글에 수만 개의 댓글이 달려도 게시글 문서는 영향받지 않습니다.
코드를 자세히 살펴보겠습니다. 첫 번째 예제에서 tags 배열은 문자열을 직접 포함합니다.
두 번째 예제에서 commentIds 배열은 ObjectId만 포함하고, 실제 댓글 내용은 comments 컬렉션에서 조회해야 합니다. 세 번째 예제에서는 댓글이 자신이 속한 게시글의 ID를 가지고 있습니다.
실무에서 어떤 패턴을 선택해야 할까요? 쇼핑몰의 상품 리뷰를 예로 들어봅시다.
인기 상품은 수천 개의 리뷰가 달릴 수 있으므로 Large N 패턴이 적합합니다. 리뷰 문서에 productId를 저장하고, 상품 조회 시 별도로 리뷰를 페이징하여 가져옵니다.
흔히 하는 실수가 있습니다. 처음에는 댓글이 적어서 임베딩했다가, 서비스가 성장하면서 댓글이 많아져 문서 크기 제한에 걸리는 경우입니다.
따라서 N의 상한선을 예측하고 미리 적절한 패턴을 선택해야 합니다. 김개발 씨는 결국 태그는 임베딩하고, 댓글은 별도 컬렉션으로 분리했습니다.
서비스가 성장해도 문제없는 확장 가능한 설계를 완성한 것입니다.
실전 팁
💡 - N이 100개 미만이고 상한선이 명확하면 임베딩을 고려하세요
- N이 무한히 커질 수 있다면 자식에서 부모를 참조하세요
- 배열 임베딩 시 $push와 $addToSet의 성능 특성을 이해하세요
4. N대M 관계 모델링
김개발 씨는 학생과 수업의 관계를 설계해야 했습니다. 한 학생이 여러 수업을 듣고, 한 수업에 여러 학생이 있습니다.
"이건 N:M 관계인데... 관계형 DB에서는 중간 테이블을 만들었는데 MongoDB에서는 어떻게 하지?" 박시니어 씨가 여러 가지 접근법을 보여주기 시작했습니다.
N:M 관계는 양쪽 엔티티가 서로 여러 개와 연결될 수 있는 관계입니다. MongoDB에서는 양방향 참조 배열 또는 중간 컬렉션을 사용하여 모델링합니다.
마치 소셜 네트워크에서 친구 관계처럼, A가 B의 친구이면서 B도 A의 친구인 상호 연결 관계를 표현하는 것과 같습니다.
다음 코드를 살펴봅시다.
// 양방향 참조: 학생과 수업
const student = {
_id: ObjectId("student001"),
name: "김개발",
// 수강 중인 수업 ID 목록
enrolledCourses: [ObjectId("course001"), ObjectId("course002")]
};
const course = {
_id: ObjectId("course001"),
title: "MongoDB 마스터",
instructor: "박시니어",
// 수강 중인 학생 ID 목록
enrolledStudents: [ObjectId("student001"), ObjectId("student002")]
};
// 중간 컬렉션: 추가 정보가 필요할 때
const enrollment = {
_id: ObjectId("enroll001"),
studentId: ObjectId("student001"),
courseId: ObjectId("course001"),
enrolledAt: new Date(),
grade: "A+",
completedLectures: 15,
certificateIssued: true
};
김개발 씨는 온라인 강의 플랫폼을 개발하면서 복잡한 관계에 직면했습니다. 학생은 여러 수업을 수강할 수 있고, 수업에는 여러 학생이 등록됩니다.
전형적인 다대다 관계입니다. 박시니어 씨가 설명을 시작했습니다.
"N:M 관계에는 두 가지 접근법이 있어요. 첫 번째는 양방향 참조, 두 번째는 중간 컬렉션입니다." 비유로 설명하면, 양방향 참조는 두 사람이 서로의 전화번호를 저장하는 것과 같습니다.
A의 연락처에 B가 있고, B의 연락처에도 A가 있습니다. 중간 컬렉션은 결혼 관계처럼, 두 사람 사이에 혼인 신고서라는 별도의 문서가 존재하는 것과 같습니다.
혼인 신고서에는 결혼 날짜나 혼인 방식 같은 추가 정보가 담깁니다. 양방향 참조 방식부터 살펴봅시다.
위 코드의 첫 번째와 두 번째 예제가 이에 해당합니다. 학생 문서에는 수강 중인 수업 ID 배열이 있고, 수업 문서에는 등록된 학생 ID 배열이 있습니다.
양쪽에서 빠르게 조회할 수 있다는 장점이 있습니다. 그러나 양방향 참조에는 단점도 있습니다.
데이터 일관성을 유지하기가 어렵습니다. 학생이 수업을 취소하면 학생 문서와 수업 문서 모두 업데이트해야 합니다.
하나만 업데이트하고 다른 하나를 놓치면 데이터가 불일치하게 됩니다. 중간 컬렉션 방식은 관계 자체에 추가 정보가 필요할 때 사용합니다.
위 코드의 세 번째 예제에서 enrollment 문서는 학생과 수업의 관계뿐만 아니라 등록일, 성적, 수료 여부 같은 정보도 담고 있습니다. 관계형 데이터베이스의 조인 테이블과 유사합니다.
실무에서는 언제 어떤 방식을 선택해야 할까요? SNS의 팔로우 관계를 예로 들어봅시다.
단순히 누가 누구를 팔로우하는지만 알면 된다면 양방향 참조로 충분합니다. 하지만 팔로우한 날짜, 알림 설정, 친한 친구 여부 같은 정보가 필요하다면 중간 컬렉션이 적합합니다.
쿼리 패턴도 중요한 고려 사항입니다. "이 학생이 수강하는 수업 목록"과 "이 수업을 듣는 학생 목록"이 모두 자주 필요하다면 양방향 참조가 유리합니다.
하지만 한쪽에서만 주로 조회한다면 단방향 참조로도 충분할 수 있습니다. 주의할 점이 있습니다.
양방향 참조에서 배열이 너무 커지면 문제가 생깁니다. 인기 강사의 수업에 수만 명이 등록한다면 enrolledStudents 배열이 매우 커집니다.
이런 경우에는 중간 컬렉션 방식이 더 적합합니다. 김개발 씨는 수강신청 정보에 성적과 진도율이 필요했기 때문에 중간 컬렉션 방식을 선택했습니다.
enrollment 컬렉션을 만들고, studentId와 courseId에 복합 인덱스를 걸어 빠른 조회를 보장했습니다.
실전 팁
💡 - 관계에 추가 속성이 필요하면 중간 컬렉션을 사용하세요
- 양방향 참조 시 업데이트 로직에서 일관성을 반드시 유지하세요
- 배열 크기가 커질 것으로 예상되면 중간 컬렉션을 선택하세요
5. 스키마 버전 관리
김개발 씨의 서비스가 출시 1년을 맞았습니다. 그동안 사용자 스키마가 세 번이나 바뀌었습니다.
처음에는 이름만 있었는데, 나중에 닉네임이 추가되고, 최근에는 프로필 이미지까지 추가되었습니다. 문제는 기존 데이터들이었습니다.
"어떤 문서는 닉네임이 있고 어떤 문서는 없어요. 코드에서 일일이 체크해야 하나요?"
스키마 버전 관리는 MongoDB의 유연한 스키마 특성을 활용하면서도 데이터 일관성을 유지하는 전략입니다. 문서에 버전 필드를 추가하여 스키마 변화를 추적하고, 애플리케이션에서 버전에 따라 적절히 처리합니다.
마치 소프트웨어 버전처럼, 데이터에도 버전을 부여하여 하위 호환성을 유지하는 것입니다.
다음 코드를 살펴봅시다.
// 스키마 버전 필드 추가
const userV1 = {
_id: ObjectId("user001"),
schemaVersion: 1,
name: "김개발"
};
const userV2 = {
_id: ObjectId("user002"),
schemaVersion: 2,
name: "박시니어",
nickname: "senior_dev" // v2에서 추가
};
const userV3 = {
_id: ObjectId("user003"),
schemaVersion: 3,
name: "이주니어",
nickname: "junior",
profile: { // v3에서 추가
avatar: "/images/lee.jpg",
bio: "열정적인 개발자"
}
};
// 마이그레이션 함수
async function migrateUser(user) {
if (user.schemaVersion === 1) {
user.nickname = user.name; // 기본값으로 이름 사용
user.schemaVersion = 2;
}
if (user.schemaVersion === 2) {
user.profile = { avatar: null, bio: "" };
user.schemaVersion = 3;
}
return user;
}
김개발 씨는 서비스가 성장하면서 스키마 변경이 불가피해졌습니다. 새로운 기능을 추가하려면 기존 데이터 구조를 수정해야 했습니다.
관계형 데이터베이스였다면 ALTER TABLE로 한 번에 모든 레코드를 변경했겠지만, MongoDB는 그렇지 않았습니다. 박시니어 씨가 경험담을 들려주었습니다.
"우리 팀도 처음에 이 문제로 고생했어요. 결국 스키마 버전 관리 패턴을 도입해서 해결했습니다." 스키마 버전 관리란 무엇일까요?
쉽게 말해, 각 문서에 '이 문서는 버전 몇이다'라는 정보를 추가하는 것입니다. 마치 소프트웨어에 버전 번호를 붙이는 것처럼, 데이터에도 버전을 부여합니다.
비유하자면, 도서관의 도서 분류 체계가 바뀌는 상황과 같습니다. 새 분류 체계가 도입되어도 기존 책들을 한꺼번에 다시 분류하기 어렵습니다.
대신 각 책에 '구분류' 또는 '신분류' 스티커를 붙여두고, 대출 시 필요하면 신분류로 변환해주는 방식을 사용할 수 있습니다. 위 코드를 살펴보면, 각 사용자 문서에 schemaVersion 필드가 있습니다.
v1은 이름만, v2는 닉네임 추가, v3는 프로필 객체가 추가되었습니다. 데이터베이스에는 세 가지 버전의 문서가 공존할 수 있습니다.
마이그레이션 전략에는 두 가지가 있습니다. 첫 번째는 지연 마이그레이션입니다.
문서를 읽을 때 버전을 확인하고, 최신 버전이 아니면 그때 업데이트합니다. 두 번째는 일괄 마이그레이션입니다.
배치 작업으로 모든 문서를 한꺼번에 최신 버전으로 업데이트합니다. 지연 마이그레이션은 점진적이라 서비스 중단이 없습니다.
하지만 모든 조회 코드에서 버전을 체크해야 하는 부담이 있습니다. 일괄 마이그레이션은 한 번에 정리되지만, 대량의 문서를 업데이트하는 동안 성능 저하가 발생할 수 있습니다.
실무에서는 두 가지를 조합하는 경우가 많습니다. 우선 지연 마이그레이션으로 서비스를 안정적으로 운영하면서, 점차 일괄 마이그레이션으로 구버전 문서를 정리합니다.
주의할 점도 있습니다. 마이그레이션 함수가 복잡해지면 버그가 발생하기 쉽습니다.
각 버전 간 변환 로직을 충분히 테스트해야 합니다. 또한 마이그레이션 중 에러가 발생했을 때 롤백 방안도 준비해두어야 합니다.
김개발 씨는 지연 마이그레이션 방식을 선택했습니다. 사용자가 로그인할 때 schemaVersion을 확인하고, 필요하면 최신 버전으로 업그레이드합니다.
덕분에 서비스 중단 없이 스키마를 점진적으로 개선할 수 있게 되었습니다.
실전 팁
💡 - 새 필드에는 항상 기본값을 정의하여 하위 호환성을 유지하세요
- 마이그레이션 함수는 멱등성을 보장하도록 작성하세요
- 스키마 변경 이력을 문서화하여 팀원들과 공유하세요
6. 안티 패턴 피하기
김개발 씨는 코드 리뷰에서 따끔한 피드백을 받았습니다. "이 스키마 설계, 전형적인 안티 패턴이에요.
나중에 큰 문제가 될 수 있습니다." 박시니어 씨가 과거 프로젝트에서 겪었던 실패 사례들을 공유해주기 시작했습니다. 실수로부터 배우는 것이야말로 가장 효과적인 학습이니까요.
MongoDB 안티 패턴은 겉보기에는 괜찮아 보이지만 장기적으로 성능 저하나 유지보수 문제를 일으키는 설계 방식입니다. 무한 성장 배열, 과도한 정규화, 인덱스 없는 조회, 대용량 문서 등이 대표적입니다.
마치 건물의 기초 공사를 잘못하면 나중에 전체를 허물어야 하는 것처럼, 잘못된 스키마 설계는 서비스 전체에 영향을 미칩니다.
다음 코드를 살펴봅시다.
// 안티 패턴 1: 무한 성장 배열
const badPost = {
_id: ObjectId("post001"),
title: "인기 게시글",
// 위험: 댓글이 수만 개가 되면 문서 크기 초과
comments: [/* 계속 증가... */]
};
// 개선: 댓글을 별도 컬렉션으로 분리
const goodComment = {
_id: ObjectId("comment001"),
postId: ObjectId("post001"),
content: "좋은 글이네요!"
};
// 안티 패턴 2: 과도한 정규화
// 나쁜 예: 모든 것을 분리하여 JOIN 폭발
const badOrder = { customerId: "...", productIds: ["..."], addressId: "..." };
// 개선: 변하지 않는 데이터는 임베딩
const goodOrder = {
_id: ObjectId("order001"),
// 주문 시점의 고객명과 배송지를 임베딩
customerName: "김개발",
shippingAddress: "서울시 강남구...",
items: [{ name: "노트북", price: 1500000 }]
};
// 안티 패턴 3: 인덱스 없는 자주 쓰는 쿼리
// 나쁜 예: 인덱스 없이 날짜로 조회
db.logs.find({ createdAt: { $gte: new Date("2024-01-01") } });
// 개선: 자주 사용하는 필드에 인덱스 추가
db.logs.createIndex({ createdAt: -1 });
김개발 씨는 첫 프로젝트를 성공적으로 런칭했다고 생각했습니다. 하지만 사용자가 늘어나면서 예상치 못한 문제들이 터지기 시작했습니다.
응답 속도가 느려지고, 가끔 에러가 발생했습니다. 원인을 분석해보니 초기 스키마 설계에 문제가 있었습니다.
박시니어 씨가 대표적인 안티 패턴들을 설명해주었습니다. "이런 실수는 누구나 한 번쯤 겪어요.
중요한 건 왜 문제인지 이해하고 피하는 겁니다." 첫 번째 안티 패턴은 무한 성장 배열입니다. 게시글 문서 안에 댓글을 배열로 임베딩하면 처음에는 편리합니다.
하지만 인기 게시글에 댓글이 수천, 수만 개 달리면 어떻게 될까요? MongoDB 문서의 최대 크기는 16MB입니다.
이를 초과하면 더 이상 댓글을 추가할 수 없습니다. 또한 댓글 하나를 추가할 때마다 전체 문서를 다시 써야 하므로 성능도 급격히 저하됩니다.
비유하자면, 일기장에 모든 기록을 적는 것과 같습니다. 처음에는 괜찮지만, 10년치 일기를 한 권에 적으면 책이 너무 두꺼워져 펼치기도 어려워집니다.
적당한 시점에 새 일기장을 시작해야 합니다. 두 번째 안티 패턴은 과도한 정규화입니다.
관계형 데이터베이스에 익숙한 개발자들이 자주 범하는 실수입니다. 모든 데이터를 별도 컬렉션으로 분리하면 $lookup 연산이 많아지고, 이는 성능 저하로 이어집니다.
MongoDB는 JOIN에 최적화되어 있지 않습니다. 함께 조회되는 데이터는 함께 저장하는 것이 원칙입니다.
세 번째 안티 패턴은 인덱스 없는 빈번한 조회입니다. 날짜로 로그를 검색하는 쿼리가 자주 실행되는데 인덱스가 없다면, 매번 전체 컬렉션을 스캔해야 합니다.
데이터가 적을 때는 괜찮지만, 수백만 건이 쌓이면 쿼리 한 번에 수 초가 걸릴 수 있습니다. 네 번째 주의해야 할 패턴은 대용량 문서입니다.
문서 하나에 너무 많은 정보를 담으면 네트워크 전송 시간이 길어지고, 메모리 사용량도 증가합니다. 필요한 필드만 조회하는 프로젝션을 활용하거나, 문서를 적절히 분리해야 합니다.
코드의 개선 예제들을 살펴보겠습니다. 무한 성장 배열은 별도 컬렉션으로 분리했습니다.
과도한 정규화는 주문 시점의 데이터를 임베딩하여 해결했습니다. 인덱스 문제는 createIndex로 해결했습니다.
실무에서 안티 패턴을 예방하려면 어떻게 해야 할까요? 첫째, 설계 단계에서 쿼리 패턴을 먼저 정의합니다.
어떤 데이터를 어떻게 조회할지 알아야 올바른 구조를 설계할 수 있습니다. 둘째, 데이터 증가량을 예측합니다.
배열이 얼마나 커질 수 있는지, 컬렉션에 문서가 얼마나 쌓일지 미리 추정합니다. 셋째, 모니터링을 설정합니다.
느린 쿼리 로그를 분석하여 문제를 조기에 발견합니다. 김개발 씨는 자신의 설계를 다시 검토했습니다.
무한 성장할 수 있는 배열을 찾아 별도 컬렉션으로 분리하고, 자주 사용하는 쿼리에 인덱스를 추가했습니다. "처음부터 완벽할 수는 없지만, 문제를 인식하고 개선하는 게 중요하군요."
실전 팁
💡 - 배열에 $push할 때는 항상 크기 제한을 고려하세요
- explain()으로 쿼리 실행 계획을 확인하고 인덱스 사용 여부를 점검하세요
- 정기적으로 느린 쿼리 로그를 분석하여 안티 패턴을 찾아내세요
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
서비스 메시 완벽 가이드
마이크로서비스 간 통신을 안전하고 효율적으로 관리하는 서비스 메시의 핵심 개념부터 실전 도입까지, 초급 개발자를 위한 완벽한 입문서입니다. Istio와 Linkerd 비교, 사이드카 패턴, 실무 적용 노하우를 담았습니다.
EFK 스택 로깅 완벽 가이드
마이크로서비스 환경에서 로그를 효과적으로 수집하고 분석하는 EFK 스택(Elasticsearch, Fluentd, Kibana)의 핵심 개념과 실전 활용법을 초급 개발자도 쉽게 이해할 수 있도록 정리한 가이드입니다.
Grafana 대시보드 완벽 가이드
실시간 모니터링의 핵심, Grafana 대시보드를 처음부터 끝까지 배워봅니다. Prometheus 연동부터 알람 설정까지, 초급 개발자도 쉽게 따라할 수 있는 실전 가이드입니다.
분산 추적 완벽 가이드
마이크로서비스 환경에서 요청의 전체 흐름을 추적하는 분산 추적 시스템의 핵심 개념을 배웁니다. Trace, Span, Trace ID 전파, 샘플링 전략까지 실무에 필요한 모든 것을 다룹니다.
Spring MongoDB 완벽 가이드
MongoDB와 NoSQL의 핵심 개념부터 Spring Data MongoDB를 활용한 실전 CRUD까지, 초급 개발자도 쉽게 따라할 수 있는 완벽한 가이드입니다. JPA와의 비교를 통해 언제 MongoDB를 사용해야 하는지 명확하게 이해할 수 있습니다.