MongoDB 공식 블로그에 모델링 관련 글이 있어 번역해봤다. 서문을 짧게 말하면, MongoDB의 경우 관계형DB에서 간단하게 정의된 일대다 관계(One-to-N relationthips)를 모델링할 방법이 매우 다양하다고 한다.
많은 초심자들이 MongoDB에서 일대다 관계를 구현하는 방법이 한가지, 즉 부모 객체 안에 array를 끼워넣는 것(embed)이라고 생각하지만 사실이 아니다. embed할 순 있지만, embed가 필수는 아니다.
MongoDB 스키마 디자인을 할 때, SQL을 사용할 때는 고려햐지 않았던 질문에 대해서 먼저 시작해야 한다.
what is the cardinality of the relationship?
좀더 쉽게 말하면 one-to-few, one-to-many, 아니면 one-to-squillions의 뉘앙스인지 생각해보아야 한다. 하나의 데이터를 참조하는 데이터가 얼마나 많은지에 따라 모델링이 바뀌는 것이다.
아래 이미지는 원글 part1의 첫 댓글이 너무 웃겨서😂
✔️ 고려해야할 요소
✔️ 방법
one-to-few 관계이고, 부모 객체의 문백 밖에서 embedded된 객체에 접근할 필요하지 않을 때
사람들의 주소를 DB에 저장하는 것으로 예를 들어보자. embedding의 좋은 예인데, Person 객체 안에 주소를 array형식으로 넣었을 때 embedding의 장단점을 동시에 가지게 된다.
단점을 추가적으로 예를 들어보면 업무 관리 시스템을 모델링할 때, 사람마다 많은 업무를 가지게 될 것이다. 하지만 기한이 내일까지인 모든 업무를 보여달라는 요청이 들어올 때, 필요 이상으로 쿼리가 어렵게 되버린다.
> db.person.findOne()
{
name: 'Kate Monster',
ssn: '123-456-7890',
addresses : [
{ street: '123 Sesame St', city: 'Anytown', cc: 'USA' },
{ street: '123 Avenue Q', city: 'New York', cc: 'USA' }
]
}
one-to-many 관계이거나 N-side 객체가 독립적이어야 할 때
주문 시스템의 교체 부품(parts)를 예로 들어보자. 대부분의 상품은 교체할 부품(다른 사이즈의 볼트들이나 워셔 등등)이 몇백개까지 될 수 있지만, 몇천개까지는 아닐 확률이 높다. 이런 상황이 참조에 좋은 케이스다. 상품 document에 부품들로 이루어진 array의 ObjectId를 넣게 된다.
> db.parts.findOne()
{
_id : ObjectID('AAAA'),
partno : '123-aff-456',
name : '#4 grommet',
qty: 94,
cost: 0.94,
price: 3.99
각 상품은 자신만의 document를 가지고, 상품을 구성하고 있는 부품의 ObjectId들을 array로 가진다.
> db.products.findOne()
{
name : 'left-handed smoke shifter',
manufacturer : 'Acme Corp',
catalog_number: 1234,
parts : [ // array of references to Part documents
ObjectID('AAAA'), // reference to the #4 grommet above
ObjectID('F17C'), // reference to a different Part
ObjectID('D2AA'),
// etc
]
그러면 특정 상품의 부품을 검색할 때 application-level join을 사용할 수 있게 된다.
// 카탈로그 번호로 확인된 상품 객체를 불러옴
> product = db.products.findOne({catalog_number: 1234});
// 상품과 연결된 모든 파츠를 불러옴
> product_parts = db.parts.find({_id: { $in : product.parts } } ).toArray() ;
이런 스타일의 참조는 embedding에 있어 보완적인 장점과 단점을 가진다.
그리고 이 스키마는 테이블간 join을 할 필요 없이 각 부품들이 여러 상품에서 쓰일 수 있게 하기 때문에, N-to-N 관계가 된다.
하나의 객체에 매우 많은 데이터들이 연결될 때
하나에 엄청나게 많은 데이터들이 연결될 때는 기계들의 이벤트 로깅 시스템(기계에 대해 로그 메시지를 수집하는)을 예로 들 수 있다. 어떤 Host든 16MB 이상의 메시지를 만들어낼 수 있기 때문에 이런 케이스가 전형적인 parent-referencing
의 예시다. 이 상황에서는 Host를 위한 document를 가지고, 로그 메시지 안에 Host의 ObjectId를 저장하게 된다.
> db.hosts.findOne()
{
_id : ObjectID('AAAB'),
name : 'goofy.example.com',
ipaddr : '127.66.66.66'
}
>db.logmsg.findOne()
{
time : ISODate("2014-03-28T09:42:41.382Z"),
message : 'cpu is on fire!',
host: ObjectID('AAAB') // Reference to the Host document
}
만약 Host의 최근 5000개의 메시지를 찾고 싶다면 application-level(아까 했던 것과는 쪼금 다른) join을 쓰면 된다.
// find the parent ‘host’ document
> host = db.hosts.findOne({ipaddr : '127.66.66.66'}); // assumes unique index
// find the most recent 5000 log message documents linked to that host
> last_5k_msg = db.logmsg.find({host: host._id}).sort({time : -1}).limit(5000).toArray()
좀더 fancy해지고 싶다면, 스키마에 두 개의 기술을 결합해서 참조에 두 스타일(one-to-many & manty-to-one) 모두 사용할 수 있다.
one-to-few에서 다뤘던 업무 관리 시스템으로 돌아가보자. 사람에 대한 정보를 가진 Person colletion, 업무 정보를 가진 Task collection이 있고 Person -> Task는 일대다 관계이기 때문에 Person collection은 Task collection을 참조하게 된다.
db.person.findOne()
{
_id: ObjectID("AAF1"),
name: "Kate Monster",
tasks [ // array of references to Task documents
ObjectID("ADF9"),
ObjectID("AE02"),
ObjectID("AE73")
// etc
]
}
반면 만들고 있는 애플리케이션이 업무 리스트를 보여주고, 어떤 사람이 해당 업무와 연관되어있는지를 보여줘야 한다면 Task document 안에 있는 Person에 추가 참조를 넣어줌으로써 최적화할 수 있다.
db.tasks.findOne()
{
_id: ObjectID("ADF9"),
description: "Write lesson plan",
due_date: ISODate("2014-04-01"),
owner: ObjectID("AAF1") // Reference to Person document
}
해당 디자인은 One-to-Many의 장단점을 모두 가지게 된다.
모델링을 다양하게 하는 것보다, 역정규화(Denormalization)하는 방법도 있다. 처음에 비정규화로 해석하려고 했는데 검색하니까 역정규화.... 이름이 이상해
역정규화를 사용하면 어떤 상황에서는 (업데이트를 사용할 때 추가적으로 복잡함이 드는 이면이 있긴 하지만 ) aplication-level join이 필요 없어진다. 사실 이 파트에서 여러 케이스를 다루는데 자바스크립트가 들어가서 일단은 개념 정리정도로 마무리했다.
위에서 예로 들었던 부품 관련 예시를 다시 보면, 부품 이름을 부품들의 array로 역정규화를 할 수 있다. 역정규화 없이 Product document를 보면 다음과 같다.
> db.products.findOne()
{
name : 'left-handed smoke shifter',
manufacturer : 'Acme Corp',
catalog_number: 1234,
parts : [ // array of references to Part documents
ObjectID('AAAA'), // reference to the #4 grommet above
ObjectID('F17C'), // reference to a different Part
ObjectID('D2AA'),
// etc
]
}
역정규화를 하게되면 상품이 갖고 있는 부품의 어떤 정보를 조회하든 application-level join이 필요 없어지게 된다.
> db.products.findOne()
{
name : 'left-handed smoke shifter',
manufacturer : 'Acme Corp',
catalog_number: 1234,
parts : [
{ id : ObjectID('AAAA'), name : '#4 grommet' }, // Part name is denormalized
{ id: ObjectID('F17C'), name : 'fan blade assembly' },
{ id: ObjectID('D2AA'), name : 'power switch' },
// etc
]
}
참조하는 Document의 정보를 쉽게 가져오는 대신 클라이언트 사이드에서의 작업이 조금 필요하다. mongodb 공식이라 언어에 대한 설명은 없고 그냥 코드만 있어서... map function을 쓰는 부분에서 일단 1차 블로깅을 마무리하고, 요거는 검색 이후 내용을 조금더 보강하려고 한다.
// 상품 객체를 자겨옴
> product = db.products.findOne({catalog_number: 1234});
// 부품 Create an array of ObjectID()s containing *just* the part numbers
> part_ids = product.parts.map( function(doc) { return doc.id } );
// Fetch all the Parts that are linked to this Product
> product_parts = db.parts.find({_id: { $in : part_ids } } ).toArray() ;
블로그 시리즈 마지막에, 원글 제목(6-rules-of-thumb-for-mongodb-schema-design)에 있었던 Rules of Thumb가 정리되어 있다.
✔️ Rules of Thumb: Your Guide Through the Rainbow
독립된 객체로 접근할 필요
가 있다면, embed는 피해야 한다.High-cardinality
배열은 embed를 기피할 이유 중 하나다. 배열은 연결되어 있지 않는 이상 증가하면 안 된다. many side에 수천개의 Document가 존재한다면 embed하지 말고, 그거보다 적게 있다면 ObjectId를 참조한 array로 사용하지 않는 것이 좋다.
글 잘 읽고 갑니다 :)