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


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

기초

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

각각 방법은 장단점을 갖고 있어서 상황에 맞는 방법을 활용해야 하는데 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가 데이터베이스 스키마를 작성할 때 애플리케이션에서 필요로 하는 모든 요구를 만족할 수 있도록 기능을 제공하고 있다는 점이다. 애플리케이션에 필요로 하는 데이터를 필요한 구조에 맞게 불러올 수 있어 쉽게 활용할 수 있다.

더 읽을 거리

색상을 바꿔요

눈에 편한 색상을 골라보세요 :)

Darkreader 플러그인으로 선택한 색상이 제대로 표시되지 않을 수 있습니다.