Here, Edward 👨🏻‍💻

About안녕하세요

MongoDB 스키마 디자인을 위한 6가지 규칙 요약

MongoDB에서의 관계 구성, 비정규화 전략을 6가지 규칙으로 정리.

MongoDB를 개인 프로젝트에서 자주 사용하긴 하는데 항상 쓰던 방식대로만 사용하고 있어서 스키마를 제대로 구성하고 있는지 검색하다가 이 글을 찾게 되었다. MongoDB 블로그에 올라온 포스트인 6 Rules of Thumb for MongoDB Schema Design을 읽고 나서 SQL과 어떻게 다른 전략을 갖고 스키마를 구성해야 하는지 생각하는데 도움이 많이 되었다. 원글은 세 부분으로 나눠 게시되어 있어서 주제를 더 상세하게 다루고 있으므로 이 요약이 불충분하다면 해당 포스트를 확인하자.


SQL에 경험이 있지만 MongoDB가 처음이라면, MongoDB에서 일대다(One-to-N, 왜 N인지는 보면 안다.) 관계를 어떻게 작성할지 자연스레 궁금증을 갖게 된다. 이 글의 주제는 객체 간의 관계를 다루는 방법에 대한 이야기다.

기초

다음 세 가지 방법으로 관계를 작성할 수 있다.

  • One to Few 하나 당 적은 수
  • One to Many 하나 당 여럿
  • One to Squillions 하나 당 무지 많은 수

각각 방법은 장단점을 갖고 있어서 상황에 맞는 방법을 활용해야 하는데 One-to-N에서 N이 어느 정도 규모/농도 되는지 잘 판단해야 한다.

One-to-Few

// person
{
  name: "Edward Kim",
  hometown: "Jeju",
  addresses: [
    { street: 'Samdoil-Dong', city: 'Jeju', cc: 'KOR' },
    { street: 'Albert Rd', city: 'South Melbourne', cc: 'AUS' }
  ]
}

하나 당 적은 수의 관계가 필요하다면 위 같은 방법을 쓸 수 있다. 쿼리 한 번에 모든 정보를 갖을 수 있다는 장점이 있지만, 내포된 엔티티만 독자적으로 불러올 수 없다는 단점도 있다.

One-to-Many

// 편의상 ObjectID는 2-byte로 작성, 실제는 12-byte
// parts
{
  _id: ObjectID('AAAA'),
  partno: '123-aff-456',
  name: 'Awesometel 100Ghz CPU',
  qty: 102,
  cost: 1.21,
  price: 3.99
}

// products
{
  name: 'Weird Computer WC-3020',
  manufacturer: 'Haruair Eng.',
  catalog_number: 1234,
  parts: [
    ObjectID('AAAA'),
    ObjectID('DEFO'),
    ObjectID('EJFW')
  ]
}

부모가 되는 문서에 배열로 자식 문서의 ObjectID를 저장하는 방식으로 구현한다. 이 경우에는 DB 레벨이 아닌 애플리케이션 레벨 join으로 두 문서를 연결해 사용해야 한다.

// category_number를 기준으로 product를 찾음
> product = db.products.findOne({catalog_number: 1234});
// product의 parts 배열에 담긴 모든 parts를 찾음
> product_parts = db.parts.find({_id: { $in : product.parts } } ).toArray() ;

각각의 문서를 독자적으로 다룰 수 있어 쉽게 추가, 갱신 및 삭제가 가능한 장점이 있지만 여러번 호출해야 하는 단점이 있다. join이 애플리케이션 레벨에서 처리되기 때문에 N-to-N도 쉽게 구현할 수 있다.

One-to-Squillions

이벤트 로그와 같이 엄청나게 많은 데이터가 필요한 경우, 단일 문서의 크기는 16MB를 넘지 못하는 제한이 있어서 앞서와 같은 방식으로 접근할 수 없다. 그래서 부모 참조(parent-referencing) 방식을 활용해야 한다.

// host
{
  _id : ObjectID('AAAB'),
  name : 'goofy.example.com',
  ipaddr : '127.66.66.66'
}
// logmsg
{
  time : ISODate("2015-09-02T09:10:09.032Z"),
  message : 'cpu is on fire!',
  host: ObjectID('AAAB')       // Host 문서를 참조
}

다음과 같이 Join한다.

// 부모 host 문서를 검색
> host = db.hosts.findOne({ipaddr : '127.66.66.66'});  // 유일한 index로 가정
// 최근 5000개의 로그를 부모 host의 ObjectID를 이용해 검색
> last_5k_msg = db.logmsg.find({host: host._id}).sort({time : -1}).limit(5000).toArray()

숙련

앞서 살펴본 기초 방법과 함께, 양방향 참조와 비정규화를 활용해 더 세련된 스키마 디자인을 만들 수 있다.

양방향 참조 Two-Way Referencing

// person
{
  _id: ObjectID("AAF1"),
  name: "Koala",
  tasks [ // task 문서 참조
    ObjectID("ADF9"), 
    ObjectID("AE02"),
    ObjectID("AE73") 
  ]
}

// tasks
{
  _id: ObjectID("ADF9"), 
  description: "Practice Jiu-jitsu",
  due_date:  ISODate("2015-10-01"),
  owner: ObjectID("AAF1") // person 문서 참조
}

One to Many 관계에서 반대 문서를 찾을 수 있게 양쪽에 참조를 넣었다. Person에서도 task에서도 쉽게 다른 문서를 찾을 수 있는 장점이 있지만 문서를 삭제하는데 있어서는 쿼리를 두 번 보내야 하는 단점이 있다. 이 스키마 디자인에서는 단일로 atomic한 업데이트를 할 수 없다는 뜻이다. atomic 업데이트를 보장해야 한다면 이 패턴은 적합하지 않다.

Many-to-One 관계 비정규화

앞서 Many-to-One에서 필수적으로 2번 이상 쿼리를 해야 하는 형태를 벗어나기 위해, 다음과 같이 비정규화를 할 수 있다.

// products - before
{
  name: 'Weird Computer WC-3020',
  manufacturer: 'Haruair Eng.',
  catalog_number: 1234,
  parts: [
    ObjectID('AAAA'),
    ObjectID('DEFO'),
    ObjectID('EJFW')
  ]
}

// products - after
{
  name: 'Weird Computer WC-3020',
  manufacturer: 'Haruair Eng.',
  catalog_number: 1234,
  parts: [
    { id: ObjectID('AAAA'), name: 'Awesometel 100Ghz CPU' }, // 부품 이름 비정규화
    { id: ObjectID('DEFO'), name: 'AwesomeSize 100TB SSD' },
    { id: ObjectID('EJFW'), name: 'Magical Mouse' }
  ]
}

애플리케이션 레벨에서 다음과 같이 사용할 수 있다.

// product 문서 찾기
> product = db.products.findOne({catalog_number: 1234});  
// ObjectID() 배열에서 map() 함수를 활용해 part id 배열을 만듬
> part_ids = product.parts.map( function(doc) { return doc.id } );
// 이 product에 연결된 모든 part를 불러옴
> product_parts = db.parts.find({_id: { $in : part_ids } } ).toArray();

비정규화로 매번 데이터를 불러오는 비용을 줄이는 장점이 있다. 하지만 part의 name을 갱신할 때는 모든 product의 문서에 포함된 이름도 변경해야 하는 단점이 있다. 그래서 비정규화는 업데이트가 적고, 읽는 비율이 높을 때 유리하다. 업데이트가 잦은 데이터에는 부적합하다.

One-to-Many 관계 비정규화

// parts - before
{
  _id: ObjectID('AAAA'),
  partno: '123-aff-456',
  name: 'Awesometel 100Ghz CPU',
  qty: 102,
  cost: 1.21,
  price: 3.99
}

// parts - after
{
  _id: ObjectID('AAAA'),
  partno: '123-aff-456',
  name: 'Awesometel 100Ghz CPU',
  product_name: 'Weird Computer WC-3020', // 상품 문서 비정규화
  product_catalog_number: 1234,           // 얘도 비정규화
  qty: 102,
  cost: 1.21,
  price: 3.99
}

앞과 반대로 비정규화를 하는 방법인데 이름 변경 시 Many-to-One에 비해 수정해야 하는 범위가 더 넓은 단점이 있다. 앞에서 처리한 비정규식과 같이 업데이트/읽기 비율을 고려해서 이 방식이 적절한 패턴일 때 도입해야 한다.

One-to-Squillions 관계 비정규화

Squillions를 비정규화한 결과는 다음과 같다.

// logmsg - before
{
  time : ISODate("2015-09-02T09:10:09.032Z"),
  message : 'cpu is on fire!',
  host: ObjectID('AAAB')
}

// logmsg - after
{
  time : ISODate("2015-09-02T09:10:09.032Z"),
  message : 'cpu is on fire!',
  host: ObjectID('AAAB'),
  ipaddr : '127.66.66.66'
}

> last_5k_msg = db.logmsg.find({ipaddr : '127.66.66.66'}).sort({time : -1}).limit(5000).toArray()

사실, 이 경우에는 둘을 합쳐도 된다.

{
    time : ISODate("2015-09-02T09:10:09.032Z"),
    message : 'cpu is on fire!',
    ipaddr : '127.66.66.66',
    hostname : 'goofy.example.com'
}

코드에서는 이렇게 된다.

// 모니터링 시스템에서 로그 메시지를 받음.
logmsg = get_log_msg();
log_message_here = logmsg.msg;
log_ip = logmsg.ipaddr;

// 현재 타임 스탬프를 얻음
now = new Date();
// 업데이트를 위한 host의 _id를 찾음
host_doc = db.hosts.findOne({ ipaddr: log_ip },{ _id: 1 });  // 전체 문서를 반환하지 말 것
host_id = host_doc._id;

// 로그 메시지, 부모 참조, many의 비정규화된 데이터를 넣음
db.logmsg.save({time : now, message : log_message_here, ipaddr : log_ip, host : host_id ) });
// `one`에서 비정규화된 데이터를 push함
db.hosts.update( {_id: host_id }, {
    $push : {
      logmsgs : {
        $each:  [ { time : now, message : log_message_here } ],
        $sort:  { time : 1 },  // 시간 순 정렬
        $slice: -1000          // 마지막 1000개만 뽑기
      }
    }
  });

정리

6가지 원칙

장미빛 MongoDB를 위한 6가지 원칙은 다음과 같다.

  1. 피할 수 없는 이유가 없다면 문서에 포함할 것.
  2. 객체에 직접 접근할 필요가 있다면 문서에 포함하지 않아야 함.
  3. 배열이 지나치게 커져서는 안됨. 데이터가 크다면 one-to-many로, 더 크다면 one-to-squillions로. 배열의 밀도가 높아진다면 문서에 포함하지 않아야 함.
  4. 애플리케이션 레벨 join을 두려워 말 것. index를 잘 지정했다면 관계 데이터베이스의 join과 비교해도 큰 차이가 없음.
  5. 비정규화는 읽기/쓰기 비율을 고려할 것. 읽기를 위해 join을 하는 비용이 각각의 분산된 데이터를 찾아 갱신하는 비용보다 비싸다면 비정규화를 고려해야 함.
  6. MongoDB에서 어떻게 데이터를 모델링 할 것인가는 각각의 애플리케이션 데이터 접근 패턴에 달려있음. 어떻게 읽어서 보여주고, 어떻게 데이터를 갱신한 것인가.

생산성과 유연성

이 모든 내용의 요점은 MongoDB가 데이터베이스 스키마를 작성할 때 애플리케이션에서 필요로 하는 모든 요구를 만족할 수 있도록 기능을 제공하고 있다는 점이다. 애플리케이션에 필요로 하는 데이터를 필요한 구조에 맞게 불러올 수 있어 쉽게 활용할 수 있다.

더 읽을 거리

이 글은 https://www.haruair.com/blog/3055 에서 옮겨온 글입니다.
Posted by
김용균
사소한 이야기를 많이 나누고 싶어하는 해커. 티끌 같은 기술들이 세상을 바꾼다고 믿습니다.
목록으로
© 2011-2018 Edward Kim Some Rights Reserved.?