MongoDB 스키마 디자인: one-to-N 관계

yeeun lee·2020년 8월 29일
4

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의 첫 댓글이 너무 웃겨서😂

1.Three basic ways to model One-to-N

✔️ 고려해야할 요소

  • 개체의 N사이드가 독립적일 필요가 있는지? 즉 부모 객체의 context에서만 필요한지 여부
  • 관계의 중복성(cardinality)이 어느정도인지? (조금인지, 많이인지, 아주 많이인지)

✔️ 방법

  • embedding
  • child-referencing
  • parent-referencing

One-to-Few

one-to-few 관계이고, 부모 객체의 문백 밖에서 embedded된 객체에 접근할 필요하지 않을 때

사람들의 주소를 DB에 저장하는 것으로 예를 들어보자. embedding의 좋은 예인데, Person 객체 안에 주소를 array형식으로 넣었을 때 embedding의 장단점을 동시에 가지게 된다.

  • 장점: 주소를 얻기 위해 별도의 쿼리가 필요 없다.
  • 단점: 주소를 독립된 개체(stand-alone entities)로 접근할 방법이 없다.

단점을 추가적으로 예를 들어보면 업무 관리 시스템을 모델링할 때, 사람마다 많은 업무를 가지게 될 것이다. 하지만 기한이 내일까지인 모든 업무를 보여달라는 요청이 들어올 때, 필요 이상으로 쿼리가 어렵게 되버린다.

> 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

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에 있어 보완적인 장점과 단점을 가진다.

  • 장점: 각 부품은 독립된 document이기 때문에 독립적으로 검색하거나 수정하는 것이 쉽다.
  • 단점: 상품의 부품을 얻기 위해서 2차 쿼리를 해야한다는 점이다. (지금 번역하는 글이 Part1인데, Part2에서 denormalizing에 대해 다루면서 관련된 점을 보완할 수 있는 것 같다. 일단 적어놓긴 했지만 이 생각을 보류하라고 글쓴이가 적어놓았음!)

그리고 이 스키마는 테이블간 join을 할 필요 없이 각 부품들이 여러 상품에서 쓰일 수 있게 하기 때문에, N-to-N 관계가 된다.

One-to-Squillions

하나의 객체에 매우 많은 데이터들이 연결될 때

하나에 엄청나게 많은 데이터들이 연결될 때는 기계들의 이벤트 로깅 시스템(기계에 대해 로그 메시지를 수집하는)을 예로 들 수 있다. 어떤 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()

2. More sophisticated schema designs

Two-way referencing

좀더 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의 장단점을 모두 가지게 된다.

  • 장점: Task document에 owner 참조를 추가함으로써 그 업무를 담당하는 사람이 누구인지 쉽게 찾을 수 있게 된다.
  • 단점: 업무를 재할당할 경우 업데이트를 여러 번해야한다. (Person->Task & Task->Person)

Denormalization

모델링을 다양하게 하는 것보다, 역정규화(Denormalization)하는 방법도 있다. 처음에 비정규화로 해석하려고 했는데 검색하니까 역정규화.... 이름이 이상해

역정규화를 사용하면 어떤 상황에서는 (업데이트를 사용할 때 추가적으로 복잡함이 드는 이면이 있긴 하지만 ) aplication-level join이 필요 없어진다. 사실 이 파트에서 여러 케이스를 다루는데 자바스크립트가 들어가서 일단은 개념 정리정도로 마무리했다.

- from Many->One

위에서 예로 들었던 부품 관련 예시를 다시 보면, 부품 이름을 부품들의 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() ;

3. Review the entire choices

블로그 시리즈 마지막에, 원글 제목(6-rules-of-thumb-for-mongodb-schema-design)에 있었던 Rules of Thumb가 정리되어 있다.

✔️ Rules of Thumb: Your Guide Through the Rainbow

  • embedding은 안 할 이유가 없다면 적용하는 것이 좋다.
  • 독립된 객체로 접근할 필요가 있다면, embed는 피해야 한다.
  • High-cardinality 배열은 embed를 기피할 이유 중 하나다. 배열은 연결되어 있지 않는 이상 증가하면 안 된다. many side에 수천개의 Document가 존재한다면 embed하지 말고, 그거보다 적게 있다면 ObjectId를 참조한 array로 사용하지 않는 것이 좋다.
  • application-level join을 두려워하지 말 것. 정확히 찾고 projection specifier를 사용한다면 관계형DB의 server-side join보다 비용이 덜 든다.
  • 역정규화할 때는 write/read 비율을 고려한다. 만약 쓰기만 하고 업뎃이 거의 없다면 역정규화에 좋지만, 업데이트가 잦을 경우에는 얻게 되는 장점보다 단점이 더 많아지게 된다.
  • MongoDB에서는 데이터 모델링을 어떻게 할지가 애플리케이션에 대한 데이터 접근 패턴을 완전히 결정하게 된다.
profile
이사간 블로그: yenilee.github.io

2개의 댓글

comment-user-thumbnail
2021년 4월 13일

글 잘 읽고 갑니다 :)

답글 달기
comment-user-thumbnail
2021년 5월 10일

양질의 글이 많은거 같아요 검색하다 자주 오게 되네요. 감사합니다~

답글 달기