ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [MongoDB] 배열 필드의 lookup 시 도큐먼트 순서
    Database 2021. 11. 23. 23:50
    반응형

    MongoDB는 relation의 개념이 없는 대표적인 NoSQL 데이터베이스이다. 하지만 MongoDB에서 RDB의 join처럼 서로 다른 collection을 참조할 수 있는 방법이 있는데, 바로 aggregation의 $lookup을 사용하는 것이다.

    $lookup 사용법

    $lookup의 기본적인 동작을 확인하기 위해서 다음과 같은 테스트 데이터를 준비한다.

    db.tasks.insert([
        { "_id": 1, "name": "task1", "required_time": 8 },
        { "_id": 2, "name": "task2", "required_time": 2 },
        { "_id": 3, "name": "task3", "required_time": 6 },
        { "_id": 4, "name": "task4", "required_time": 4 },
        { "_id": 5, "name": "task5", "required_time": 3 }
    ])
    db.users.insert([
        { "_id": 1, "name": "Kim", "tasks": [3, 1, 5] },
        { "_id": 2, "name": "Lee", "tasks": [2, 4, 1] }
    ])

    5개의 task가 존재하고 2명의 사용자가 각자 task의 _id들의 배열을 가지고 있다.

    그냥 find() 함수로는 users 콜렉션에서 user 도큐먼트를 조회할 때 task 콜렉션까지 조회할 수 없다. 다음처럼 aggregation의 $lookup을 이용하면 task 도큐먼트를 RDB에서 join을 하듯이 조회할 수 있다.

    db.users.aggregate([
        {
            $lookup: {
                from: "tasks",
                localField: "tasks",
                foreignField: "_id",
                as: "tasks",
             },
        },
    ])

    결과는 다음과 같다.

    {
        "_id" : 1,
        "name" : "Kim",
        "tasks" : [
            { "_id" : 1, "name" : "task1", "required_time" : 8 },
            { "_id" : 3, "name" : "task3", "required_time" : 6 },
            { "_id" : 5, "name" : "task5", "required_time" : 3 }
        ]
    }
    {
        "_id" : 2,
        "name" : "Lee",
        "tasks" : [
            { "_id" : 1, "name" : "task1", "required_time" : 8 },
            { "_id" : 2, "name" : "task2", "required_time" : 2 },
            { "_id" : 4, "name" : "task4", "required_time" : 4 }
        ]
    }

    lookup은 필드의 값과 일치하는 도큐먼트를 join해주고, 필드의 값이 배열이라면 배열의 요소들을 전부 join 해주기 때문에 정말 편리한 쿼리이다.

    $lookup 의 동작

    그런데 위의 결과를 보면 주목할 것이 있다. user "Kim"의 tasks는 3, 1, 5의 순서로 저장이 되어있는데, 조회 결과의 task 도큐먼트는 1, 3, 5의 순서로 tasks 필드의 배열에 담겨져 있다.

    이와 관련해서 구글링을 해본 결과 배열의 lookup은 배열에 담겨져있던 순서를 보장하지 않는다. 그리고 예제의 lookup쿼리는 내부적으로, 참조할 콜렉션을 순서대로 스캔하면서 user.tasks 배열에 $in 연산을 통해 aggregation을 실행한다고 한다.

    따라서 예제의 쿼리는 다음 쿼리와 같은 동작을 한다.

    db.users.aggregate([
      {
        $lookup: {
          from: 'tasks',
          let: { user_tasks: '$tasks' },
          pipeline: [
            {
              $match: {
                $expr: { $in: ['$_id', '$$user_tasks'] },
              },
            }
          ],
          as: 'tasks',
        }
      }
    ])

    user.tasks배열에 들어있는 값은 task 콜렉션의 _id이기 때문에, 배열의 원소로 task 콜렉션에 인덱스 스캔을 할 줄 알았지만 실상은 그렇지가 않았다. task 콜렉션을 전체 스캔하기 때문에 도큐먼트가 저장된 순서대로 tasks 배열이 만들어진 것이다.

    이 동작에 대해 확인을 더 해보기 위해 기존 task 콜렉션의 데이터를 지우고 다음 순서로 데이터를 넣은 다음에 같은 쿼리를 날려보았다.

    db.tasks.insert([
      { "_id": 2, "name": "task2", "required_time": 2 },
      { "_id": 5, "name": "task5", "required_time": 3 },
      { "_id": 1, "name": "task1", "required_time": 8 },
      { "_id": 3, "name": "task3", "required_time": 6 },
      { "_id": 4, "name": "task4", "required_time": 4 },
    ])

    결과가 다음과 같이 나왔다.

    {
        "_id" : 1,
        "name" : "Kim",
        "tasks" : [
            { "_id" : 5, "name" : "task5", "required_time" : 3 },
            { "_id" : 1, "name" : "task1", "required_time" : 8 },
            { "_id" : 3, "name" : "task3", "required_time" : 6 }
        ]
    }
    {
        "_id" : 2,
        "name" : "Lee",
        "tasks" : [
            { "_id" : 2, "name" : "task2", "required_time" : 2 },
            { "_id" : 1, "name" : "task1", "required_time" : 8 },
            { "_id" : 4, "name" : "task4", "required_time" : 4 }
        ]
    }

    도큐먼트가 저장되어있는 순서대로 결과가 나오는 것을 확인할 수 있다.

    배열의 순서를 보장하려면?

    그렇다면 user.tasks 배열에 저장된 순서대로 lookup을 하려면 어떻게 해야할까? 다음처럼 lookup의 pipeline에 단계를 추가하면 배열에 저장되어있는 순서대로 lookup 결과를 가져올 수 있다.

    db.users.aggregate([
      {
        $lookup: {
          from: 'tasks',
          let: { user_tasks: '$tasks' },
          pipeline: [
            {
              $match: {
                $expr: { $in: ['$_id', '$$user_tasks'] },
              },
            },
            {
              $addFields: {
                _order: { $indexOfArray: ['$$user_tasks', '$_id'] },
              },
            },
            {
              $sort: { _order: 1 },
            },
            {
              $project: { _order: 0 },
            }
          ],
          as: 'tasks'
        }
      }
    ])

    _order라는 임시 필드를 _id에 해당하는 user.tasks 배열의 인덱스 값으로 생성하고, 생성된 _order 필드로 정렬한 후 _order필드를 없애주었다. 위 쿼리를 실행하면 다음과 같이 배열의 순서대로 조회 결과를 가져올 수 있다.

    {
        "_id" : 1,
        "name" : "Kim",
        "tasks" : [
            { "_id" : 3, "name" : "task3", "required_time" : 6 },
            { "_id" : 1, "name" : "task1", "required_time" : 8 },
            { "_id" : 5, "name" : "task5", "required_time" : 3 }
        ]
    }
    {
        "_id" : 2,
        "name" : "Lee",
        "tasks" : [
            { "_id" : 2, "name" : "task2", "required_time" : 2 },
            { "_id" : 4, "name" : "task4", "required_time" : 4 },
            { "_id" : 1, "name" : "task1", "required_time" : 8 }
        ]
    }

    Conclusion

    위 쿼리들은 기본적으로 두개의 콜렉션에 작업을 수행하는 lookup을 사용하기 때문에 성능 상 좋은 쿼리는 아니다. mongoDB는 콜렉션 간에 관계에 최적화된 데이터베이스가 아닐 뿐더러, 위와 같은 데이터베이스 설계는 형식이 정해지지 않은 도큐먼트 베이스 데이터베이스라는 mongoDB의 이점을 살리지 못하는 설계이다.

    mongoDB는 공식문서에서 lookup을 지양하는 방식으로 설계를 하라고 권장한다(링크). 그 방법으로, 임베디드 도큐먼트를 사용하거나 배열에 도큐먼트를 직접 저장하는 방식을 제안하고 있다.

    배열에 lookup을 사용하면서 동작에 대한 고민을 하다가 mongoDB를 데이터베이스로 선택할 때 얻을 수 있는 장점과 단점, 그리고 데이터 모델을 설계할 때 좀 더 신중해야 겠다고 다시 한 번 생각하는 계기가 되었다.

    References

    반응형

    'Database' 카테고리의 다른 글

    [PG] 쿼리 실행 계획 분석하기 - Table Scan  (6) 2021.11.17

    댓글

Designed by Tistory.