Database/NoSQL

NoSQL - MongoDB 다양한 데이터 모델링 기법!

JaeHoney 2023. 3. 21. 08:41

1. 너무 큰 배열 문제

아래 카페 서비스의 데이터 모델링을 한 예시가 있다.

db.cafe.insertMany([
    {
        _id: 1,
        name: "IT Community",
        desc: "A Cafe where developer's share information.",
        created_at: ISODate("2018-08-09"),
        last_article: ISODate("2022-06-01T10:56:32.00Z"),
        level: 5,
        members: [
            {
                id: "tom93",
                first_name: "Tom",
                last_name: "Park",
                phone: "000-0000-1234",
                joined_at: ISODate("2018-09-12"),
                job: "DBA"
            },
            {
                id: "asodsa123",
                first_name: "Jenny",
                last_name: "Kim",
                phone: "000-0000-1111",
                joined_at: ISODate("2018-10-02"),
                job: "Frontend Dev"
            },
            {
                id: "candy12",
                first_name: "Zen",
                last_name: "PKo",
                phone: "000-0000-1233",
                joined_at: ISODate("2019-01-12"),
                job: "DA"
            }
        ]

    }    
]);

 

여기서 1개의 카페에 members가 너무 길어질 수 있다는 문제가 있다.

 

해당 경우 데이터가 커질수록 조회, 생성, 수정(members)가 매우 느려질 수 있다. 추가로 MongoDB는 1개 Document당 16MB를 최대로 하기 때문에 에러가 발생할 수 있다.

  • 100,000개의 members를 가진 cafe 기준 13MB정도를 차지한다.
  • 많아야 130,000개 이상의 members에서는 더이상 member를 추가할 수 없게 된다.

만약 1개 카페의 members 수의 제한이 있다면, 배열을 사용해서 한 곳에 몰아서 데이터를 보관하는 것이 좋을 수 있다. 하지만 무제한의 members가 들어갈 수 있다는 요구사항이 있다면 배열을 사용하기 어렵다.

 

그럼 위 부분은 어떻게 해결할 수 있을까?

db.members.insertMany([
    {
        id: "tom93",
        first_name: "Tom",
        last_name: "Park",
        phone: "000-0000-1234",
        job: "DBA",
        join_cafes: [{
            _id: 1,
            name: "IT Community",
            desc: "A Cafe where developer's share information.",
            created_at: ISODate("2018-08-09"),
            last_article: ISODate("2022-06-01T10:56:32.00Z"),
            level: 5,
            joined_at: ISODate("2018-09-12")
        }, ...]
    },
    ...
])

cafe가 members를 가지는 것이 아니라, member가 각각 자신이 속한 cafe를 가지도록 모델링을 할 수 있다. join_cafes의 경우에는 비즈니스 요구사항에 따라 최대 200개~300개 정도만 지원하도록 설계할 수 있기 때문에 충분히 선택이 가능하다.

 

2. 선형적 성능 문제

문제는 cafe의 정보를 수정한다고 했을 때는 아래와 같이 members에도 수정사항을 직접 반영해줘야 한다.

db.members.updateMany(
    {
        "joined_cafes._id": 1
    },
    {
        $set: {
            "joined_cafes.$.last_article": date
        }
    }
)

이때 모든 members의 join_cafes를 탐색하며 하나씩 전부 수정하기 때문에 선형적으로 작업이 필요하게 되는 문제가 발생한다.

Collection 분리

이러한 문제를 해결하기 위해서는 Collection을 분리해야 한다.

db.cafe.insertMany([
    {
        _id: 1,
        name: "IT Community",
        desc: "A Cafe where developer's share information.",
        created_at: ISODate("2018-08-09"),
        last_article: ISODate("2022-06-01T10:56:32.00Z"),
        level: 5
    }    
]);
db.members.insertMany([
    {
        id: "tom93",
        first_name: "Tom",
        last_name: "Park",
        phone: "000-0000-1234",
        job: "DBA",
        join_cafes: [1, 2, ...]
    },
    ...
])

 

이 경우 문제는 member와 cafe를 함께 조회할 때 RDB와 마찬가지로 아래와 같은 Join(Lookup)이 필요하다.

db.cafe.aggregate([
    {
        $lookup: }
            from: "members",
            localField: "_id",
            foreignField: "joined_cafes",
            as: "members",
            pipeline: [
                {
                    $match: {
                        job: "DBA"
                    }
                },
                {
                    $project: {
                        _id: 0,
                        id: 1,
                        job: 1
                    }
                }
            ]

        }
    },
    {
        $project: {
            name: 1,
            desc: 1,
            created_at: 1,
            joinedMemberJob: {
                $first: "$members.job"
            },
            cnt: {
                $sizt: "$members"
            }
        }
    }
])

1:1 관계의 경우 문제가 안될 수도 있지만, 1:n의 관계의 경우 데이터가 많아지면 성능상 문제가 충분히 발생할 수 있다.

 

실행계획을 확인해보면 lookup에 많은 시간이 걸렸다.

  • (테스트 케이스가 커지면 더 많은 시간이 소요된다.)

joined_cafes에 index를 추가해봤지만 악영향만 끼치는 것을 확인하였다.

 

이를 어떻게 해결할까?

Extended Reference Pattern

Extended Reference Pattern은 변경되지 않을 필드를 Document에 내장시키는 방법이다.

db.cafe.insertMany([
    {
        _id: 1,
        name: "IT Community",
        desc: "A Cafe where developer's share information.",
        created_at: ISODate("2018-08-09"),
        last_article: ISODate("2022-06-01T10:56:32.00Z"),
        level: 5
    }    
]);

cafe에서 변경되지 않는 필드는 어떤 게 있을까? 요구사항에 따라 다르지만 아래와 같이 정의할 수 있다.

  • (변경되지 않음) _id, name, desc, created_at, level
  • (자주 변경됨) last_article

members가 cafe의 해당 변하지 않는 정보를 가진다면 Join을 하지 않아도 될 수 있다.

db.members.insertMany([
    {
        id: "tom93",
        first_name: "Tom",
        last_name: "Park",
        phone: "000-0000-1234",
        job: "DBA",
        join_cafes: [{
            _id: 1,
            name: "IT Community",
            desc: "A Cafe where developer's share information.",
            created_at: ISODate("2018-08-09")
        }, ...]
    },
    ...
])

이제 조회문을 아래와 같이 수행할 수 있다.

db.members.aggregate([
    {
        $match: {
            job: "DBA"
        }
    },
    {
        $unwind: "$joined_cafes"
    },
    {
        $group: {
            _id: "$joined_cafe._id",
            joined_cafes: {
                $first: "$joined_cafes"
            },
            joinedMemberJob: {
                $first: "$job"
            },
            cnt: {
                $sum:" 1
            }
        }
    },
    {
        $project: {
            _id: 0,
            name: "$joined_cafes.name",
            desc: "$joined_cafes.desc",
            created_at: "$joined_cafes.created_at",
            joinedMemberJob: 1,
            cnt: 1
        }
    }

])

members의 job필드에 index만 추가해주니 310ms -> 155ms로 쿼리 실행 시간을 줄일 수 있었다.

3. 너무 많은 인덱스

이번에는 온라인 게임에서 사용자의 행동 로그에 대해서 DB에 저장한다고 가정해보자. 로그인을 할 때 로그를 쌓아뒀다가, 로그아웃을 할 때 1개의 로그 셋으로 저장한다.

log = {
    loginTime: new Date(),
    visits: [],
    sails: [],
    trades: [],
    battles: [],
    quests: [],
    fishings: [],
    gambles: [],
    castings: [],
    farmings: []
}

그러면 다음과 같은 로그를 쌓을 수 있다.

log.visits.push({
    location: "London",
    time: new Date()
})

log.trades.push({
    item: "Musket",
    qty: 50,
    price: 1800
})

log.quests.push({
    name: "Cave Investigation",
    reward: 50000
})

이 경우에는 필드가 다양해짐에 따라 인덱스를 무수히 많이 생성해야 한다.

 

아래와 같이 필드를 통합하면 인덱스를 줄일 수 있다는 장점이 있다.

actions: [
    { action: "visit", value: "London", time: date },
    { action: "trade", value: "Musket", type: "buy", qty: 50, price: 1800 },
    { action: "quest", value: "Cave Investigation", reward: 50000, status: "In Progress" },
]

가령 아래의 Index만 생성하더라도 최소한의 성능을 보장할 수 있게 된다.


db.logs.createIndex({"actions.action": 1, "actions.value": 1})

 

추가로 해당 log를 종류 별로 기존의 document에 push하는 기존의 방식과 다르게 통합 log 형태로 하나씩 보관하게 되므로 16MB를 넣을 리도 없게 된다.

4. 필수(Required) 필드와 선택(Optional) 필드

아래는 상품 판매 서비스의 재고를 MongoDB에 저장한 예시이다.

[{
  name: "Cherry Coke 6-pack",
  manufacturer: "Coca-Cola",
  brand: "Coke",
  sub_brand: "Cherry Coke",
  price: 5.99,
  color: "red",
  size: "12 ounces",
  container: "can",
  sweetener : "sugar"
},
{
  name: "z-flip5",
  manufacturer: "Samgsung",
  brand: "Galaxy",
  price: 99.99,
  color: "green",
  display_size: "170.3 mm",
  camera: "10.0 MP",
  memory: "8G"
}]

 

해당 경우 인덱스 설계가 어렵고 조회 쿼리의 조건을 파악하기 어렵다.

 

공통/선택적 필드 여부에 따라 아래와 같이 관리할 수 있다.

[{
  name: "Cherry Coke 6-pack",
  manufacturer: "Coca-Cola",
  brand: "Coke",
  sub_brand: "Cherry Coke",
  price: 5.99,
  spaces: [
    {key:  "color", value:  "red"},
    {key:  "size", value:  12, unit: "ounces"},
    {key:  "container", value:  "can"},
    {key:  "sweetener", value:  "sugar"}
  ]
},
{
  name: "z-flip5",
  manufacturer: "Samgsung",
  brand: "Galaxy",
  price: 99.99,
  spaces: [
    {key:  "color", value:  "green"},
    {key:  "display_size", value:  170.3, unit: "mm"},
    {key:  "camera", value:  10.0, unit: "MP"},
    {key:  "memory", value:  8, unit: "G"},
  ]
}]

5. documents가 너무 많이 생기는 문제

특정 공간에서 분마다 온도 데이터를 DB에 삽입해야 하는 요구사항이 있다고 가정하자.

{
  sensor_id: 1,
  timestamp: time,
  temparature: temp
}

문제는 각각의 센서마다 1분마다 document가 저장되어야 하는 문제가 있다.

  • (센서 하나당) 60 per hour, 1440 per day가 발생
  • 센서가 20개라면 10,000,000 per year가 발생

Document가 너무 많아지면 Storage가 많이 낭비될 수 있고, DB서버의 부하가 커질 수 있다. (쿼리를 많이 요청해야 한다.)

{
  sensor_id: 1,
  start_date: date,
  end_date: date,
  measurements: [
    {
      timestamp: time,
      temparature: temparature
    }, ...
  ]
}

위에서는 시간별로 Document를 생성하고 각 분의 결과는 배열로 보관한다. 해당 데이터를 1시간마다 삽입한다면 Document의 수를 줄일 수 있다. 결과적으로 Storage를 절약할 수 있고, 요청 쿼리도 최소화할 수 있고, 조회 성능도 빨라진다는 이점이 있다.

 

하지만 요구사항을 잘 고려해야 한다.

 

만약 온도가 25도 이상의 데이터만 추출하고 싶다거나, 10분 단위의 데이터만 추출한다고 하면 해당 아키텍처는 성능상 그리 좋지 못하다.

Time Series

MongoDB 5.0부터는 Time Series라고 하는 Collection 타입을 제공한다.

db.createCollection(
  "sensors",
  {
    timeseries: {
      timeField: "timestamp",
      metaField: "metadata",
      granularity: "minutes",
    }
  }
)

{
  timestamp: time,
  metadata: {
    sensor_id: 1,
    temparature: temp
  } 

}

TimeSeries 컬렉션은 시계열 데이터에 한해서 위와 같이 데이터를 묶는 기능을 제공한다.

  • (스토리지가 정말 많이 절약된다. 바로 위에서 1시간마다 삽입하는 방식보다도 더 많이 절약된다.)

추가로 PrimaryKey를 활용해서 우수한 검색속도를 제공하므로 꼭 고려해보길 권장한다.

6. WorkingSet이 너무 커지는 문제 (Subset Pattern)

배달 서비스의 가게 데이터와 리뷰 데이터를 MongoDB에 저장한다고 가정해보자.

db.shops.insertOne({
  _id: 1,
  name: "Jerry Stake house",
  desc: "Greatest Steak House Ever.",
  phone: "000-0000-1234",
  reviews: [
    {
      review_id: 1,
      user: "James",
      review: "So Good!!",
      date: new Date(),
      rating: 10
    }, ...
  ]
})

장사가 잘되는 가게의 경우 리뷰가 1000개 이상정도라고 가정했을 때 16MB면 충분하다고 판단할 수 있다.

 

문제는 처리량 저하로 인해 사용자 응답이 느려지면서 사용자 경험(UX)이 좋지 못하게 될 수 있다는 점이다. 대용량 서비스에서 DB 최적화는 매우 중요하다.

 

위에서는 WorkingSet으로 가게 및 리뷰 데이터 전체를 사용하기 때문에 캐시 공간이 부족하게 된다.

  • 조회 절차: 음식점 선택 - find() - 캐시 탐색 - (캐시 갱신) - 응답

여기서는 WorkingSet을 줄여서 캐시가 부족하지 않도록 해야 한다.

 

일반적으로 사용자는 리뷰 페이지 전체를 한번에 다 읽을 필요가 없다. 최근 10개 정도만 보여주고, 추가적인 데이터는 필요 시에만 조회하도록 구성할 수 있다.

  • 가게는 상위 10개정도의 리뷰만 저장
  • 리뷰 전체는 리뷰 컬렉션에서 저장

해당 처리방식을 Subset Pattern이라고 한다.

 

이제 데이터의 document 크기(16MB)를 신경쓸 필요가 없어지고, 캐시 메모리가 절약되어 사용자 응답은 빨라지게 된다. 결과적으로는 사용자 경험(UX)을 개선할 수 있다.

7. 회귀 탐색 및 Tree 구조 모델링

MongoDB가 지원하는 graphLookup을 사용하면 회귀 탐색이나 Depth가 있는 트리 구조에서 유용하게 사용할 수 있다.

회귀 서치

회귀 서치의 경우 결재선이나 답글의 경우 사용할 수 있다. 아래는 결재선을 데이터 모델링한 것이다.

db.employees.insertMany([
  {
    _id: 1,
    name: "Eliot",
    position: "CEO"
  },
  {
    _id: 2,
    name: "Ron",
    position: "Team Lead",
    reportsTo: "Eliot"
  },
  {
    _id: 3,
    name: "Tom",
    position: "Team Member",
    reportsTo: "Ron"
  }
])

이제 graphLookup을 사용해서 조회해보자.

db.employees.aggregate([
  {
    $graphLookup: {
      from: "employees",
      startWith: "$reportsTo",
      connectFromField: "reportsTo",
      connectToField: "name",
      depthField: "depth",
      as: "reportingHierarchy"
    }
  }
])

아래는 쿼리의 결과이다.

각 유저 정보가 해당 유저의 결재선 정보까지 포함하고 있다. 이를 통해 가독성이 높고 처리 속도가 빠른 쿼리를 쉽게 구성할 수 있다.

 

graphLookup은 식품, 생활, 가전 등 트리 구조의 카테고리가 있는 쇼핑몰에서도 매우 유용하니 꼭 사용해보길 권장한다.

참고