Loading
2022. 6. 30. 15:04 - lazykuna

MongoDB로 효율적인 자료구조 설계 하기

최근 바닥부터 서비스 설계를 하면서 DB 스키마를 짜야 할 일이 생겼는데, 이를 위해 기초부터 공부했던 내용들을 정리해 둡니다.

MongoDB가 내부적으로 데이터를 저장하는 방식

콜렉션과 도큐먼트

도큐먼트가 가장 작은 단위의 dict 타입의 데이터를 들고 있고, 콜렉션은 이들을 저장하기 위한 전략을 정의한 개념이라고 보는 게 제일 타당할 것 같습니다.

  • 이외 document 스펙에 대해서 궁금한 점은 여기 참조

그리고 한가지 유의해야 하는 점은 MongoDB는 철저히 도큐먼트 기반 데이터베이스라는 점입니다. 가장 작은 단위인 도큐먼트를 읽고/쓰는 과정에서 최적의 성능을 보여줍니다. 따라서 해당 작업을 할 경우 최적의 선택이 됩니다.

  • 데이터 분석을 위한 전단계로 mongoDB를 쓰는 것은 좋지 않습니다. 이를테면 대표적인 hadoop key-value pipeline을 사용하려면 미리 도큐먼트 파싱을 해야 하는데, 비용이 만만치 않죠… Column DB가 훨씬 더 이 경우에 적합할 것입니다.
  • (아래에서도 이야기하겠지만) 접근 최소 단위가 도큐먼트이기 때문에, 도큐먼트 접근하면 관련 내용을 한번에 읽어들이게 됩니다. 이를 고려하여 도큐먼트를 설계할 필요가 있습니다.

인덱싱과 샤딩

https://mysterico.tistory.com/7

인덱싱을 함으로서 빠르게 문서를 찾을 수 있습니다.

  • full scan 하지 않고, 인덱스에 들어있는 값만 비교함으로서 훨씬 빠르게 비교할 수 있게 됩니다.
  • 인덱스를 만들면 데이터 갱신시 인덱스 갱신하는 비용이 추가되기 때문에 장단점이 있습니다.

샤딩은 인덱스 기반으로 수행됩니다. 기본적으로는 ObjectId 로도 샤딩됩니다.

  • object id는 아래 구조로 client에서 생성되게 되어 있음 (snowflake의 그것과 유사)

샤딩은 Collection 내의 Document 문서 기준으로 정렬됩니다.

  • 한가지 유의해야 할 점은, 샤딩을 적용할 때 해당 사항이 바로 DB에 반영되지는 않고, 시간이 걸립니다. eventually consistent한 NoSQL의 특징이 고스란히 나타나기 때문에, production에서 작업시 이 점을 염두에 두어야 합니다. #
  • 그리고 키의 쏠림이 있는지, 없는지도 미리 염두에 두어야 합니다. 키가 쏠리게 된다면, 당연히 샤딩이 정상적으로 이루어지지 않습니다.
  • 고급 검색을 사용할 것이라면, 단순 인덱싱 만으로는 안되고 Elasticsearch와 같이 도큐먼트 선 분석 파이프라인을 붙여야 합니다.

내부적으로 파일을 관리하는 방법

내부적으로 데이터를 어떻게 관리하는지에 대한 심도 깊은 내용입니다. 파일시스템의 특성을 제대로 알고 있어야 효율적으로 구성이 가능할 것이라고 생각이 되어 … https://www.mongodb.com/docs/manual/storage/

피해야 하는 스키마 패턴들

참조: https://www.mongodb.com/developer/products/mongodb/schema-design-anti-pattern-summary/

먼저 피해야 하는 데이터 패턴들에 대해서 알아볼 필요가 있습니다. 잘못 사용하는 패턴의 반대가 결국 효율적인 패턴을 알 게 하는 좋은 방법이니까요.

너무 거대한 Document 사용을 지양

https://www.mongodb.com/developer/products/mongodb/schema-design-anti-pattern-massive-arrays/?_ga=2.74728213.684949705.1656429829-1635904275.1656252414

https://www.mongodb.com/docs/atlas/schema-suggestions/avoid-unbounded-arrays/

보통 이러한 경우는 Unbounded array가 존재하는 경우입니다. 즉 Document 안에 지속적으로 갱신되는 array가 존재하는 경우입니다.

Document 자체 스펙에도 제약이 있고 (16mib), 인덱싱을 하는 경우라면 FULL SCAN을 타게 되므로 절대 권장하지 않는 기본 중 기본입니다.

너무 많은 Collection 사용을 지양

https://www.mongodb.com/developer/products/mongodb/schema-design-anti-pattern-massive-number-collections/?_ga=2.204694295.684949705.1656429829-1635904275.1656252414

콜렉션은 비슷한 형태의 document들을 담아 두는 개념으로 사용되는데, 왜 이를 과도하게 만드는 것이 성능에 악영향을 끼칠까요?

먼저 collection이 작동하는 방식부터 알아볼 필요가 있습니다. 내부적으로 collection을 관리하는 WiredTiger 내부 엔진에서는 각 콜렉션이 하나의 파일로서 동작하게 되어 있고, 따라서 너무 많은 파일이 열려 있게 되면 성능에 저하가 발생하게 됩니다.

또한, 검색을 여러 collection에 걸쳐서 해야 하는 구조의 설계가 된다면, 이 또한 바람직하지 않습니다. 인덱스와 같은 기능은 하나의 콜렉션에서만 사용할 수 있기 때문에 …

따라서, collection을 만들기보다는 document를 만드는 방향으로 데이터 구조를 짜면 됩니다.

거기에 더해서, document 갯수가 너무 많지 않도록 연관된 데이터를 적당히 묶어 저장해 볼 수 있다면 더 효율이 좋을 것입니다. 예시에서는 여러 timestamp 정보를 시간 단위로 묶어 document로 저장하고 있네요.

  • MongoDB에서는 각 collection에 대한 사용내역 metrics를 제공하고 있어, 이를 참조하여 잘 사용되지 않는 collection을 정리하거나 재구성 해볼 수 있습니다.

인덱스 사용하기

검색하는 키를 사용하여 인덱스를 구성함으로서 검색 속도를 빠르게 향상시킬 수 있습니다. Covered Query를 통해서, 도큐먼트를 모조리 읽어들이는 대신 (full scan), 인덱스만으로 검색을 할 수 있기 때문입니다.

다만 너무 불필요하게 많은 값을 인덱스 사용은 지양해야 합니다. 이 경우 인덱스 갱신에 많은 비용이 들게 되므로 신중하게 선택할 필요가 있습니다.

  • MongoDB 자체에서 index 사용에 대한 metric을 제공하고 있으므로, 이를 이용하여 불필요한 매트릭스를 배제할 수도 있습니다

또한 쿼리에 따라 인덱스를 어떻게 설정할지에 대한 전략도 잘 정해야 합니다. 예를 들어 case-insensitive한 쿼리를 날린다면, 인덱스를 upper case로 설정하거나, case-insensitive 인덱스를 만들수 있어야 합니다.

인덱스를 사용함으로서 자연스럽게 샤딩도 할 수 있기 때문에, mongoDB에서 사용할 쿼리에 알맞는 인덱싱 전략을 짜는 것이 굉장히 중요합니다.

연관성 없는 데이터는 분리, 연관있는 데이터는 같이 보관

https://www.mongodb.com/developer/products/mongodb/schema-design-anti-pattern-bloated-documents/?_ga=2.2450359.684949705.1656429829-1635904275.1656252414

굳이 하나의 document에 모든 데이터를 다 우겨넣을 필요가 없습니다. 그리고 그렇게 하면 캐시로 공간을 많이 낭비하게 되므로, 자주 읽히지 않는 데이터가 있다면 다른 콜렉션으로 분리하여 해당 oid를 reference로 걸어넣으면 충분합니다.

https://www.mongodb.com/developer/products/mongodb/schema-design-anti-pattern-separating-data/?_ga=2.204825239.684949705.1656429829-1635904275.1656252414

그렇다고 같이 접근하는 데이터를 분리하는 것은 오히려 성능에 악영향을 줄 수 있습니다. 분리한 문서들을 일일이 찾아 읽어와야 하기 때문입니다. 이 경우에는 데이터를 하나로 묶어서 관리하도록 해야 합니다.

  • 아래 있는 비정규화(Denormalization) 기법과 같은 맥락입니다.
  • $lookup 사용(= JOIN in RDB)을 최대한 지양하도록 하는 것이 목적입니다.

이를 좀 더 전문적인 용어로 표현하면, 비정규화(Denormalization) 작업입니다. 간단히 말하면 중복된 데이터를 레코드에 삽입하는 것입니다. 이를 통해 데이터 조인에 소모되는 비용을 크게 줄일 수 있습니다.

예를 들면: https://www.mongodb.com/developer/products/mongodb/schema-design-anti-pattern-massive-arrays/?_ga=2.74728213.684949705.1656429829-1635904275.1656252414

단점으로서는 데이터 갱신 시 비용이 훨씬 커진다는 것인데, 따라서 조인된 데이터의 읽기/쓰기 비율을 고려하여 비정규화를 할 것인지 여부를 판단해야 합니다.

적절한 Collection 타입의 활용

이번에는 collection 타입을 활용하여 더 효율적으로 db를 활용할 수 있음을 알아봅니다.

Time-series 데이터

https://www.mongodb.com/docs/manual/core/timeseries-collections/

일반적인 collection 대비하여 timeseries collection은 쿼리의 효율성과 디스크 사용량을 감소시킬 수 있다고 합니다.

  • 지속적으로 시간 데이터들이 추가되어도 성능이 감소하지 않는 것을 목적으로 설계되었기 때문입니다. 이를 위해 시간 기반으로 파티셔닝을 하도록 내부 설계가 되어있다고 합니다.
  • 동시에 업데이트 및 삭제 등 제약이 있는 요소가 있으니, 잘 알아보고 사용할 필요가 있습니다.
  • 물론 mongoDB 이외에도… 특화된 TimeSeries DB가 많으니 도큐먼트 이외 용도라면 다른 것들도 선택지에 넣어 볼 수 있습니다.
  • https://mangkyu.tistory.com/188

mongodb에서는 2개의 field가 매 document마다 있는 것을 상정하고 있습니다.

  • timestamp field
  • metadata field

나름 document의 업데이트 및 삭제가 가능합니다. (metafield 한정)

TTL(Time-to-live)이 존재하는 데이터

https://www.mongodb.com/docs/manual/core/timeseries/timeseries-automatic-removal/

timeseries collection에서는 어떤 데이터의 유효기간을 설정할 수 있습니다. 특정 데이터가 지정된 기간보다 더 오래 지나면 자동으로 지우도록 하는 것입니다.

콜렉션 생성시에 expireAfterSeconds 값을 지정하여 설정 가능합니다.

오래된 데이터에 대해서 별도 저렴한 저장소를 사용하여 접근하게 한다거나, 혹ㅇ,ㄴ 데이터 파이프라인을 구성한다던가 할때 나름 유용하지 않을까 싶습니다.

로그 전용 컬렉션

Capped collection

높은 throughput이 필요한 로그 DB에 적합한 콜렉션 형태입니다. 삽입된 순서대로 데이터를 조회할 수 있는데, 특이사항은 할당된 용량을 다 쓰면 처음부터 다시 덮어쓰기를 수행한다는 것입니다 (= circular buffer) 데이터를 영구히 저장할 필요가 없는 로그 저장 용도에 아주 적합합니다.

특이사항은, Delete가 허용되지 않으며, Update 또한 document size를 변화시키는 경우 허용되지 않습니다. 즉 이미 생성된 document는 재할당이 불가능한 구조입니다.

Materialized View

두 데이터를 조인하고 이를 지속적으로 빠르게 접근해야 하는 상황이 있다면, 해당 데이터를 매번 계산해내는 것보다 계산한 값을 저장하여 이를 지속적으로 이용하고 싶을 것입니다. 이러한 상황에서 적합합니다.

Mongo에서는 MVIEW에서 계산된 값은 디스크에 저장되고, 업데이트 함수를 직접 호출하여 업데이트가 이루어집니다.

  • Computed 패턴과 같은 용도입니다.

Subset 패턴

어떤 영화 리뷰 사이트가 있다고 가정할 때, 모든 리뷰를 가져오는 것은 굉장히 데이터베이스에 부담이 큰 행위입니다. 보통은 상위 몇개의 리뷰를 보는 게 대부분이기 때문입니다. 그래서, 상위 몇 개 리뷰와 나머지 리뷰들을 분리한 document를 만들어서 관리하면 훨씬 효율이 좋습니다. 이를 Subset 패턴이라고 합니다.

  • 아니면 오래된 데이터들은 아예 저렴한 S3와 같은 Data lake에 때려박아서 필요할 때 천천히 접근할 수 있도록 하는 것도 나쁘지 않을지도 ..

이미지 출처: https://kciter.so/posts/about-mongodb

데이터 모델링

IoT 데이터 모델링

  • IoT 데이터들은 주변 사물, 기기의 센서에서 수집되는 데이터들을 의미합니다. 보통 순차적으로 쌓이며 timestamp를 포함하고 있습니다.
  • 이 경우 데이터를 저장할 때, 시간별로 레코드를 일일이 하나씩 추가하기보다는 “분당 발생한 이벤트들의 묶음"으로 document를 구성할 수 있습니다. https://www.mongodb.com/docs/manual/tutorial/model-iot-data/
    • 대부분의 경우 아주 세부적인 시간 정보(분, 초)까지는 보지 않기 때문
// temperatures collection

{
  "_id": 1,
  "sensor_id": 12345,
  "timestamp": ISODate("2019-01-31T10:00:00.000Z"),
  "temperature": 40
}
{
  "_id": 2,
  "sensor_id": 12345,
  "timestamp": ISODate("2019-01-31T10:01:00.000Z"),
  "temperature": 40
}
{
  "_id": 3,
  "sensor_id": 12345,
  "timestamp": ISODate("2019-01-31T10:02:00.000Z"),
  "temperature": 41
}
...
{
  "_id": 1,
  "sensor_id": 12345,
  "start_date": ISODate("2019-01-31T10:00:00.000Z"),
  "end_date": ISODate("2019-01-31T10:59:59.000Z"),
  "measurements": [
    {
      "timestamp": ISODate("2019-01-31T10:00:00.000Z"),
      "temperature": 40
    },
    {
      "timestamp": ISODate("2019-01-31T10:01:00.000Z"),
      "temperature": 40
    },
    ...
    {
      "timestamp": ISODate("2019-01-31T10:42:00.000Z"),
      "temperature": 42
    }
  ],
  "transaction_count": 42,
  "sum_temperature": 1783
}

위 예시의 경우 분 단위로 데이터들을 묶어 관리한 예시입니다.

Schema versioning

https://www.mongodb.com/docs/manual/tutorial/model-data-for-schema-versioning/

스키마는 변화할 수 있기 때문에, 이에 대한 backward compatibility 또는 migration에 대한 대처가 필요합니다. 그리고, 스키마가 어떤 상태인지 알 방법이 있어야 합니다. 그게 바로 schema versioning이 되는데, mongo의 경우 collection의 document에 버전 정보를 남겨놓을 수 있습니다. 이를 이용하여 적절하게 대처를 할 수 있을 것입니다.

Atomic operation

비록 NoSQL이 정합성에 취약한 장점이 있긴 하지만, 그럼에도 불구하고 이를 보완할 수 있는 많은 도구들을 제공해주고 있습니다. 예를 들면 batch transaction 및 atomic operation이 그렇습니다.

db.books.updateOne (
   { _id: 123456789, available: { $gt: 0 } },
   {
     $inc: { available: -1 },
     $push: { checkout: { by: "abc", date: new Date() } }
   }
)

위와 같이 하나의 updateOne 명령으로 조건에 맞는 레코드의 여러 필드를 한번에 업데이트 할 수가 있습니다. 이로서 정합성 문제를 해결할 수 있습니다.

  • multi-document atomic operation은 비싼 편이니 유의해서 사용할 필요가 있습니다. DB 과부하의 요인이 될 수 있을지도…

실제 예시: Tree 구성하기

주: 이 문단은 MongoDB와는 관련이 없는, 순수 설계와 관련된 예제이니 관심 없으시면 넘기셔도 됩니다.

Tree의 경우 child와 parent traversal을 하는 것을 상정해야 하므로, 일종의 link가 필요합니다. 하지만 link traversal은 비용이 꽤 들기 때문에, 이를 고려하면 아래 전략으로 tree를 구성해 볼 수 있습니다.

  • Material하게 구성하기: child 데이터들을 모조리 각 node 안에 부어버립니다.
    leaf 노드에 수정이 발생하면 부모 노드들을 모조리 다 수정해야 하므로 Write cost가 비쌉니다.
  • reference 형태로 구성하기: 데이터 중복이 없으나 traversal 비용이 클 수 있습니다.
    reference도 전략이 2가지가 있습니다: parent / child reference.
  • Binary tree 형태: left/right 번호를 매겨서 정보를 넣을 수 있습니다. 번호가 변경될 경우 업데이트 비용이 클 수 있습니다.

등, 상황을 고려하여 적당한 형태의 자료구조를 선정할 수 있습니다.

참조한 글