Database

Mongo DB 스키마 디자인해보기

date
Mar 31, 2023
thumbnail
images.jpg
slug
mongo-db-schema-design
author
status
Public
tags
MongoDB
summary
몽고 DB를 처음 다루면서 스키마 디자인에 대해 간략하게 공부한 내용을 정리했습니다.
type
Post
category
Database
📌
One-to-Few, One-to-Many, One-to-Bajillions, Populate, Mongo Schema Design, SQL Relationships Overview
 

MongoDB 관계(Relationships)


Mongo에서 데이터 간에 갖는 관계에 대한 내용이다. 몇 가지 흔한 관계를 모델링 하는 방법과 어떤 패턴을 사용할 수 있는지에 대해 알아보고, 하나의 대형 모델에 저장하는가 다양한 집합으로 쪼갤 것인가 등에 대해서 고민해보자.
 
예전에 전공 시간에 배운 관계형 데이터베이스에서 사용하는 데이터베이스 관계에 대해서 기억이 잘 안나서 구글링했다. 1:1 관계, 1:다 관계, 다대다 관계.
 

일대다 관계

사용자가 있을 때 흔히 볼 수 있는 관계다. 사용자를 우리가 만든 무언가와 관련지을 때 사용된다. 사진을 올리고, 댓글이나 게시물을 올리고 리뷰, 블로그 등을 한다. 댓글을 포함해서 이런 것들을 일반적으로 소유자나 만든 사람이 한 명이다. 즉, 일대다 관계는 웹상에서 사용자가 있을때 매우 흔한 형태이다.
HOW MANY에 따라 FEW, MANY, BAJILLIONS로 나누어서 생각해보자. 세 관계 모두 일대다(ONE-TO-MANY) 관계를 여러 방식으로 구현한 것이다.
 

1. ONE-TO-FEW

“몇 가지” 정도의 FEW. 부모 도큐먼트 내에 자식 도큐먼트가 직접적으로 임베드되어 있다는 것이 특징이다.
const userSchema = new mongoose.Schema({ first: String, last: String, addresses: [ { street: String, city: String, state: String, country: String, } ] }) const User = mongoose.model('User', userSchema); const makeUser = async() => { const u = new User({ first: '길동', last: '홍' }); u.addresses.push({ street: '123 Sesame St.', city: 'New York', state: 'NY', country: 'USA' }); const res = await u.save(); console.log(res); } makeUser();
유저의 주소가 여러개 있을 수 있다는 가정 하에 유저 스키마를 간단하게 정의하고, 새 유저를 만드는 코드를 작성한 뒤 실행해본다.
 
결과
connection success { first: '길동', last: '홍', _id: new ObjectId("642977cac62719e67212417a"), addresses: [ { street: '123 Sesame St.', city: 'New York', state: 'NY', country: 'USA', _id: new ObjectId("642977cac62719e67212417b") } ], __v: 0 }
  • addresses는 여러 값을 가질 수 있도록 배열로 생성했다.
  • addresses에 id가 있어야 한다고 정의하지 않았는데도 고유의 _id 필드가 생겼다.
  • _id: { id: false }를 입력하면 id를 생성하지 않는다.
    • 🚨 : TypeError 발생한다.
 
유저의 주소를 추가하는 함수를 작성하고 실행해보자.
 
const addAddress = async (id) => { const u = await User.findById(id); u.addresses.push({ street: '99 3rd St.', city: 'New York', state: 'NY', country: 'USA' }) const res = await u.save(); console.log(res); } addAddress('// 홍길동의 _id');
 
결과
connection success { _id: new ObjectId("642977cac62719e67212417a"), first: '길동', last: '홍', addresses: [ { street: '123 Sesame St.', city: 'New York', state: 'NY', country: 'USA', _id: new ObjectId("642977cac62719e67212417b") }, { street: '99 3rd St.', city: 'New York', state: 'NY', country: 'USA', _id: new ObjectId("64297b2856234fa881935fa1") } ], __v: 1 }
  • 홍길동은 이제 2개의 주소를 가지게 된다.
  • 사용자가 두 가지나 그 이상의 여러 항목을 가질 때 해당 관계를 저장하기 가장 좋은 방법이다.
 
여기에는 한계가 있고, 주소 등을 사용자에게 직접 임베드하는 것보다 관계를 분리하거나 정보를 별도 문서에 분리하고 다른 방법으로 연결하는 것이 더 나을 때에 대해서 알아보자.
 

2. ONE-TO-MANY

{ name: 'Tommy Cash', savedAddresses: [ { street: '라가코 15번길 1', city: '어쩌구', country: '화성' }, { street: '라가코 32번길 4', city: '어쩌구', country: '화성' }, ] }
위와 같은 one-to-few 관계처럼 하위 문서(document)나 데이터를 부모 문서의 안에 직접적으로 내장되어 있는 구조는 내장된 정보 집합의 크기가 꽤나 작을때만 잘 작동한다. 엄밀히 따지면 그 안에 수천 개의 주소를 넣을 수는 있지만 보통 좋은 방법은 아니다. ONE-TO-MANY 모델링 방식을 알아보자.
 
부모 문서 안에 정보를 직접적으로 임베드하는 것이 아니라 참조하는 레퍼런스를 다른 곳에 정의된 문서에 임베드하거나 저장하는 방식이다. 보통은 객체 ID를 쓰며 다른 ID를 사용할 수도 있다. 이 방식은 SQL 방식과 가장 유사한 방식이다.
 
ObjectId는 하나의 document 내에서 유일함이 보장되는 12 byte binary data다. 또한 MongoDB 자체 에서 자동으로 넣어주는 고유값이기에, ObjectId를 통해 다른 컬렉션에 있는 데이터를 참조할 수 있다. 즉, 특정 collection에서 populate 메소드를 이용하면 ObjectId를 기반으로 다른 collection의 정보들을 함께 담아서 출력할 수 있다. [출처: https://velog.io/@nomadhash/MongoDB-Mongoose의-populate를-이용한-타-collection-참조 ]
 
example
{ farmName: '풀 벨리 농장', location: '경남 김해', produce: [ ObjectID('123123123123', ObjectID('123123123124', ObjectID('123123123125', ] }
 
농장 문서와 상품 문서를 만들고 농장 문서에서 상품 문서를 참조하도록 만들어보자.
 
const productSchema = new mongoose.Schema({ name: String, price: Number, season: { type: String, enum: ["Spring", "Summer", "Fall", "Winter"], }, }); const Product = mongoose.model("Product", productSchema); const makeProduct = async () => { await Product.insertMany([ { name: "개쩌는 멜론", price: 4.99, season: "Summer" }, { name: "엄청 단 수박", price: 14.99, season: "Summer" }, { name: "아스파라거스", price: 3.99, season: "Spring" }, ]); }; makeProduct();
test> db.products.find() [ { _id: ObjectId("64297f7f9bc1e98926d51631"), name: '개쩌는 멜론', price: 4.99, season: 'Summer', __v: 0 }, { _id: ObjectId("64297f7f9bc1e98926d51632"), name: '엄청 단 수박', price: 14.99, season: 'Summer', __v: 0 }, { _id: ObjectId("64297f7f9bc1e98926d51633"), name: '아스파라거스', price: 3.99, season: 'Spring', __v: 0 } ]
 
그 다음 농장 스키마를 정의해보자.
 
const { Schema } = mongoose; const farmSchema = new Schema({ name: String, city: String, products: [{ type: Schema.Types.ObjectId, ref: 'Product' }] })
  • products에 들어갈 값은 ‘Product’ 모델을 참조한다는 것을 명시해야 한다.
  • products에 들어갈 값은 객체ID (MongoDB에서 제공하는 원자값) 임을 명시해야 한다.
 
const Farm = mongoose.model("Farm", farmSchema); const makeFarm = async() => { const farm = new Farm({ name: '풀 벨리 농장', location: '경남 김해' }); const melon = await Product.findOne({name: '개쩌는 멜론'}); farm.products.push(melon); await farm.save(); console.log(farm); } makeFarm();
test> db.farms.find() [ { _id: ObjectId("6429830f5ca969f1dc987172"), name: '풀 벨리 농장', products: [ ObjectId("64297f7f9bc1e98926d51631") ], __v: 0 } ]
 
다른 방법으로도 프로덕트를 추가해보자.
 
const addProduct = async () => { const farm = await Farm.findOne({ name: '풀 벨리 농장' }); const watermelon = await Product.findOne({ name: '엄청 단 수박'}); farm.products.push(watermelon); farm.save(); } addProduct();
test> db.farms.find() [ { _id: ObjectId("6429830f5ca969f1dc987172"), name: '풀 벨리 농장', products: [ ObjectId("64297f7f9bc1e98926d51631"), ObjectId("64297f7f9bc1e98926d51632") ], __v: 1 } ]
 
 

Populate (채워넣기)

find 메서드를 사용하다보면 가끔 몇가지 정보만 필요하고 나머지는 필요하지 않을 때가 있다. 특정 농장에 대해 어떤 다른 정보가 있든지 간에 앱의 어떤 페이지에서는 농장의 이름만 필요하고, 다른 페이지에서는 상품 정보만 필요한 경우가 있다. 이 때 Mongoose에게 해당 작업을 시킬 수 있다.
 
example
Story. findOne({ title: 'Casino Royale' }). populate('author'). exec(function(err, story) { if (err)returnhandleError(err); console.log('The author is %s', story.author.name); // prints "The author is Ian Fleming" });
  • mongoose 공식 문서에 있는 Population 레퍼런스
  • populate() : 하나의 도큐먼트가 다른 도큐먼트의 객체Id를 사용하고 있을 때, 해당 ObjectId를 실제 객체로 치환하여(채워넣어) 준다.
 
테스트
Farm.findOne({ name: '풀 벨리 농장'}) .populate('products') .then(farm => console.log(farm));
 
결과
{ _id: new ObjectId("6429830f5ca969f1dc987172"), name: '풀 벨리 농장', products: [ { _id: new ObjectId("64297f7f9bc1e98926d51631"), name: '개쩌는 멜론', price: 4.99, season: 'Summer', __v: 0 }, { _id: new ObjectId("64297f7f9bc1e98926d51632"), name: '엄청 단 수박', price: 14.99, season: 'Summer', __v: 0 } ], __v: 1 }
  • find()로는 products 필드에 ObejctId 값만 나왔는데, populate를 사용하면 해당 객체Id를 실제 데이터 값으로 바꾸어 준다.
  • populate()의 내부는 모델의 이름이 아니라 필드의 이름을 적어줘야 한다는 것을 기억하자.
 

3. ONE-TO-BAJILLIONS

자식 문서나 항목이 아주 많은 경우(수백 수천가지) 그 많은 데이터를 하나의 데이터와 연결하려고 할 때 쓸 수 있는 일대다 관계 작성 방법 중 하나이다. 트위터의 트윗을 예시로 알아보자.
 
📌
트위터를 10년 넘게 사용한 유저의 경우 아마 수만 개의 트윗을 작성했을 것이다. 그러나 현실에서는 한 번에 백개 이하 정도의 트윗만 한 페이지에 렌더링하면 충분하다. 한 사용자의 타임라인에 그 수만 개의 모든 트윗이 떠야 할 필요는 없다. 데이터의 양이 아주 많을 때에는 부모 도큐먼트에 자식 도큐먼트를 레퍼런스로 저장하는 것보다 ↔ 자식 도큐먼트에 부모 도큐먼트를 레퍼런스로 저장하는 것이 더 효율적일 때가 많다는 것이 핵심이다.
 
자식 도큐먼트(트윗)에 부모 도큐먼트 레퍼런스(UserId)를 저장하는 것이다.
 
const User = mongoose.model('User', userSchema); const Tweet = mongoose.model('Tweet', tweetSchema); const makeTweets = async () => { // const user = new User({ username: 'chickenfan99', age: 61 }); const user = await User.findOne({ username: 'chickenfan99' }) // const t1 = new Tweet({ text: 'I love my chicken', likes: 0 }); const t2 = new Tweet({ text: 'BBQ is the best', likes: 390 }); t2.user = user; user.save(); t2.save(); }
  • 간단한 유저 스키마와 트윗 스키마를 만든 뒤 한 유저가 두 종류의 트윗을 작성하는 코드
  • chickenfan99라는 유저는 2개의 트윗을 작성했다.
 
const findTweets = async () => { const t = await Tweet.findOne({}).populate('user', 'username'); console.log(t); } findTweets();
  • populate의 두 번째 인수에는 레퍼런스된 도큐먼트에서 필요한 필드만 지정할 수 있다.
    • { _id: new ObjectId("64298d084bf348a699599f68"), text: 'I love my chicken', likes: 0, user: [ { _id: new ObjectId("64298d084bf348a699599f67"), username: 'chickenfan99' } ], __v: 0 }
 
 

Mongo 스키마 디자인


지금까지는 하나의 모델을 가지고 Express에 전체 CRUD를 구현해왔다. 서로 연결된 두 개의 모델을 어떻게 Express로 가져다가 통합하는지, 다양한 관계를 가지고 있는 모델들의 연결을 어떻게 ㅜ성해야 하는지를 알아보아야 한다.
하지만 그전에 지금까지 배운 내용을 MongoDB의 공식 블로그에 있는 두 개의 게시글을 통해 정리하고 넘어가고자 한다.
 

요약

📌
MongoDB에서의 관계 구성, 비정규화 전략을 6가지 규칙으로 정리
  1. 피할 수 없는 이유가 없다면 정보를 도큐먼트 내에 포함(임베드, 내장)할 것
  1. 객체에 직접 접근할 필요가 있다면, 문서에 포함시키지 않을 이유가 된다. (1번과 연관)
  1. 제한없이 지나치게 커질 수 잇는 배열은 항상 피할 것.
      • 데이터가 크다면 one-to-many로, 더 크다면 one-to-squillions(bajillions)으로
      • 배열의 밀도가 높아진다면 문서에 포함(임베드)시키지 않아야 한다.
  1. 애플리케이션 레벨의 JOIN을 두려워하지 말 것.
      • index를 잘 지정했다면 관계 데이터베이스의 join과 비교해도 큰 차이가 없음
  1. 비정규화는 읽기-쓰기 비율을 고려할 것.
      • 읽기를 위해 join을 하는 비용이 각각의 분산된 데이터를 찾아 갱신하는 비용보다 비싸다면 비정규화를 고려해야 함
  1. MongoDB에서 어떻게 데이터를 모델링 할 것인가는 각각의 앱 데이터 액세스 패턴에 달려있다. 어떻게 읽어서 보여주고, 어떻게 데이터를 갱신할 것인가.
      • 데이터를 구조화할 때는 앱이 쿼리하고 업데이트하는 방식에 맞춰라.
 

정규화 vs 비정규화

정규화(normalize) 데이터베이스의 데이터 중복을 최소화하도록 설계하는 과정이고,
비정규화(denormalize)란 읽는 시간(join 연산 비용)을 최적화하도록 설계하는 과정이다.
 
시스템 성능 향상, 개발 및 운영 편의성 등을 위해 정규화된 데이터 모델을 통합, 중복, 분리하는 과정으로 의도적으로 정규화 원칙을 위배하는 행위이다. 데이터 조회(조인 비용 감소) 속도가 빨라지고, 살펴볼 테이블이 줄어들기에 조회 쿼리가 간단해진다. 따라서 에러 발생률도 떨어질 가능성이 있다. 단점으로는 데이터 갱신이나 삽입에 대한 비용과 코드 작성이 어려워질 수 있으며 데이터 간의 일관성이 깨어질 수 있다. 데이터를 중복하여 저장하므로 더 많은 저장 공간이 필요해진다.
 
MongoDB와 같은 NoSQL은 매우 유연하다는 것이 최대 장점이지만, 데이터베이스 스키마 작성 패턴에 대한 지식이 부족할 때는 오히려 독이 될 수 있다는 것을 기억하자.
 

참고