본문 바로가기

MongoDB

[MongoDB] 확장 검색 쿼리 - $lookup과 $graphLookup 스테이지

$lookup과 $graphLookup 스테이지는 Aggregation 쿼리의파이프라인에서 사용할 수 있는 조인 기능이다.

 

$lookup과 $graphLookup 스테이는 FIND 쿼리에서는 사용할 수 없으며, Aggregation에서만 사용할 수 있다.

 

아래 그림과 같이 컬렉션이 샤딩돼 있지 않다면 $lookup과 $grapLookup 스테이지를

 

조인의 용도로 사용하는 데 있어서 어떤 제약 사항도 없다.

 

 

 

하지만 샤딩된 클러스터에서는 조인으로 연결되는 컬렉션이 샤딩되지 않은 경우에만

 

$lookup과 $graphLookup 스테이지를 사용할 수 있다.

 

 

즉 로컬 컬렉션( 조인의 드라이빙 테이블 )은 샤딩 여부와 관계없지만,

 

외래 컬렉션(조인 드리븐 테이블)은 샤딩되지 않은 컬렉션만 사용할 수 있다.

 

그런데 문제는 일반적으로 샤드 클러스트 환경의 MongoDB에서는 샤딩되지 않은 컬렉션은

 

샤드 중에서 특정 샤드( 프라이머리 샤드 )에만 저장된다.

 

 

 

그래서 샤드 1번에서는 order 컬렉션과 products 컬렉션을 $lookup 스테이지로 조인하는 것이 가능하지만,

 

샤드 2번에서는 orders 컬렉션과 products 컬렉셔을 조인하는 것이 불가능하다.

 

그래서 MongoDB에서 $lookup과 $graphLookup 스테이지는 반드시 프라이머리 샤드에서만 실행된다면

 

조인되는 컬렉션을 가진 데이터베이스의 프라이머리 샤드는 다른 샤드에 비해서 훨씬 많은 처리를 수행할 수밖에 없다.

 

만약 $lookup이나 $grapLookup 스테이지를 사용하는

 

Aggregation 쿼리를 사용해야 한다면 프라이머리 샤드의 과부하를 반드시 고려하는 것이 좋다.

 

 

Aggregation 쿼리의 $lookup 스테이지는 아우터 조인을 수행할 컬렉션( from )과

 

그 컬렉션의 조인 필드( foreignField ) 그리고 Aggregation 컬렉션의 조인 필드( localField )가 필요하다.

 

그리고 아우터 조인된 외부 컬렉션의 도큐먼트는 서브 도큐먼트로 포함되는데,

 

이때 포함되는 서브 도큐먼트의 필드 이름( as )도 명시할 수 있다.

 

다음은 간단히 상품( products )과 주문( orders ) 컬력센을 조인하는 예제를 보여주고 있는데,

 

여기에서 Aggregation은 orders 컬렉션에 대해서 실행되고 있기 때문에 orders 컬렉션을 기준으로

 

products 컬렉션이 아우터 조인으로 연결되는 것이다.

 

// 예제 데이터 저장
mongo> db.orders.insertMany(
  [
      { _id : 1, product_id : 1, order_count : 1, order_price : 200 }
    , { _id : 2, product_id : 2, order_count : 1, order_price : 210 }
    , { _id : 3, product_id : 1, order_count : 1, order_price : 220 }
  ]
);
mongo> db.products.insertMany(
  [
      { _id : 1, name : "computer" }
    , { _id : 2, name : "air conditioner" } 
  ]
);

// $lookup Aggregation 쿼리 실행
mongo> db.orders.aggregate(
  {
    $lookup : {
        from : "products"
      , localField : "product_id"
      , foreignField : "_id"
      , as : "product_info"
    }
  }
);

// 조인 결과
{
    "_id" : 1
  , "product_id" : 1
  , "order_count" : 1
  , "order_price" : 200
  , "product_info" : [
      {
          "_id" : 1
        , "name" : "computer"
      }
  ]
}
{
    "_id" : 2
  , "product_id" : 2
  , "order_count" : 1
  , "order_price" : 210
  , "product_info" : [
    {
        "_id" : 2
      , "name" : "air conditioner"
    }
  ]
}
{
    "_id" : 3
  , "product_id" : 1
  , "order_count" : 1
  , "order_price" : 220
  , "product_info" : [
    {
        "_id" : 1
      , "name" : "computer"
    }
  ]
}

 

orders 컬렉션과 products 컬렉션의 조인 예제에서 orders 컬렉션은 조인의 첫 번째 컬렉션( 드라이빙 컬렉션 )이며

 

products 컬렉션은 조인의 두 번째 컬렉션( 드리븐 컬렉션 )이다.

 

mongo> db.products.createIndex( { _id : 1 } );

 

물론 products 컬렉션의 _id 필드는 프라이머리 키이므로 이미 인덱스가 생성돼 있었을 것이다.

 

만약 _id를 ObjectId로 자동 생성되게 하고, product_id 라는 필드에 별도로 저장했다면 이 경우에는

 

products 컬렉션의 product_id 필드에 인덱스를 생성해 두는 것이 좋다.

 

mongo> db.products.createIndex( {  product_id : 1 } );

 

만약 드리븐 컬렉션의 조인 조건이 되는 필드에 적절히 사용할 수 있는 인덱스가 없다면

 

드라이빙 컬렉션에서 읽은 도큐먼트 수만큼 드리븐 컬렉션을 풀 스캔해야 한다.

 

이때 드리븐 컬렉션의 도큐먼트 건수가 많다면 쿼리를 처리하기 위해서 상당히 많은 시간과 컴퓨터 자원을 사용하게 될것이다.

 

 

만약 조인으로 연결되는 필드가 배열 타입이라면 $unwind 스테이지를 이용해서 배열을 단일 값 필드로 변환한 다음

 

$lookup 스테이지를 이용해서 조인해야 한다.

 

mongo> db.orders.aggregate(
  [
    {
      $unwind : "$product_id"
    }
    , {
      $lookup : {
          from : "products"
        , localField : "product_id"
        , foreignField : "_id"
        , as : "product_info"
      }
    }
  ]
);

// 조인 결과
{
    "_id" : 1
  , "product_id" : 1
  , "order_count" : 1
  , "order_price" : 200
  , "product_info" : [
      {
          "_id" : 1
        , "name" : "computer"
      }
  ]
}
{
    "_id" : 2
  , "product_id" : 2
  , "order_count" : 1
  , "order_price" : 210
  , "product_info" : [
    {
        "_id" : 2
      , "name" : "air conditioner"
    }
  ]
}
{
    "_id" : 3
  , "product_id" : 1
  , "order_count" : 1
  , "order_price" : 220
  , "product_info" : [
    {
        "_id" : 1
      , "name" : "computer"
    }
  ]
}

 

$graphLookup 스테이지는 MongoDB 3.4 버전부터 지원되는데,

 

주로 RDBMS에서는 재귀 쿼리( CONNECT BY .. START WITH 또는 CTE ) 형태의 셀프 조인( Self-Join )을 처리할 수 있는 기능이다.

 

$graphLookup 기능도 $lookup과 동일하게 Aggregation 쿼리에서만 사용할 수 있으며,

 

아우터 조인으로 연결되는 항공 노선 정보를 샤딩돼 있으면 실행할 수 없다.

 

 

다음 예제는 공항에서 다른 공항으로 연결되는 항공 노선 정보를 가진 컬렉션( airports )과

 

여행자( travelers )의 출발지( nearestAirport ) 정보를 $graphLookup 스테이지를 이용해서 조인한 것이다.

 

$graphLookup 스테이지에서는 조인을 수행할 외래 컬렉션( from )과 재귀 쿼리를 수행할 조건

 

( connectFromField와 connectToFiled )을 설정한다.

 

이때 connectFromField와 connectToField는 외래 컬렉션( from )에 정의된 필드를 입력해야 한다.

 

그리고 재귀 쿼리의 최초 시작 필드( startWith )는 Aggregation이 실행되는 컬렉션에 정의된 필드를 설정한다.

 

 

// 예제 데이터 저장
mongo> db.airports.insertMany(
  [
      { "_id" : 0, "airport" : "JFK", "connects" : [ "BOS", "ORD" ] }
    , { "_id" : 1, "airport" : "BOS", "connects" : [ "JFK", "PWM" ] }
    , { "_id" : 2, "airport" : "ORD", "connects" : [ "JFK" ] }
    , { "_id" : 3, "airport" : "PWM", "connects" : [ "BOS", "LHR" ] }
    , { "_id" : 4, "airport" : "LHR", "connects" : [ "PWM" ] }
  ]
);

mongo> db.travelers.insertMany(
  [
      { "_id" : 1, "name" : "Dev", "nearestAirport" : "JFK" }
    , { "_id" : 2, "name" : "Eliot", "nearestAirport" : "JFK" }
  ]
);

// $lookup Aggregation 쿼리 실행
mongo> db.travelers.aggregate(
  {
    $graphLookup : {
        from : "airports"
      , startWith : "$nearestAirport"
      , connectFromField : "connects"
      , connectToField : "airport"
      , maxDepth : 2
      , depthField : "numConnections"
      , as : "destinations"
    }
  }
);

{ "_id" : 1, "name" : "Dev", "nearestAirport" : "JFK", "destinations" : [ { "_id" : 0, "airport" : "JFK", "connects" : [ "BOS", "ORD" ], "numConnections" : NumberLong(0) }, { "_id" : 1, "airport" : "BOS", "connects" : [ "JFK", "PWM" ], "numConnections" : NumberLong(1) }, { "_id" : 2, "airport" : "ORD", "connects" : [ "JFK" ], "numConnections" : NumberLong(1) }, { "_id" : 3, "airport" : "PWM", "connects" : [ "BOS", "LHR" ], "numConnections" : NumberLong(2) } ] }
{ "_id" : 2, "name" : "Eliot", "nearestAirport" : "JFK", "destinations" : [ { "_id" : 0, "airport" : "JFK", "connects" : [ "BOS", "ORD" ], "numConnections" : NumberLong(0) }, { "_id" : 1, "airport" : "BOS", "connects" : [ "JFK", "PWM" ], "numConnections" : NumberLong(1) }, { "_id" : 2, "airport" : "ORD", "connects" : [ "JFK" ], "numConnections" : NumberLong(1) }, { "_id" : 3, "airport" : "PWM", "connects" : [ "BOS", "LHR" ], "numConnections" : NumberLong(2) } ] }

그리고 이 예제에서는 재귀 조인을 실행하면, "JFK" "BOS" "JFK" "BOS" .. 과 같이 무한 반복될 수도 있다.

 

그래서 maxDepth 옵션을 2로 설정해서, 재귀 조인이 최대 2번만 실행되게 제한을 둔 것이다.

 

예제의 재귀 조인이 실행된느 과정을 간단히 정리해 보면 다음과 같다.

 

① traveler 컬렉션의 도튜먼트 조회

② ①의 travelers 도큐먼트에 있는 nearestAirport 필드와 ariports 컬렉션의 airport 필드를 조인해서 일치하는
    airports 도큐먼트 조회

③ ②의 airports 도큐먼트에 있는 connects 필드와 airport 필드를 조인해서 일치하는 airports 도큐먼트 조회
    ( airport 도큐먼트의 connects 필드가 배열이라면 배열의 엘리먼트 수만큼 조인 실행 )

④ ③의 airports 도큐먼트에 있는 connects 필드와 apirports 컬렉션의 airport 필드를 조인해서 일치하는
    airports 도큐먼트 조회( airport 도큐먼트의 connects 필드가 배열이라면 배열의 엘리먼트 수만큼 조인 실행 )

⑤ maxDepth가 2이므로 완료

 

travelers 컬렉션과 airports 컬렉션의 조인 예제에서 travelers 컬렉션은

 

조인의 첫 번째 컬렉션( 드라이빙 컬렉션 )이며 airports 컬렉션은 조인의 두 번째 컬렉션( 드리븐 컬렉션 )이 된다.

 

그런데 $graphLookup은 재귀 쿼리이므로 airports 컬렉션은 그 다음 조인에서는 드라이빙 컬렉션임과 동시에 드리븐 컬렉션이 되는 것이다.

 

하지만 매번 조인해서 airports 컬렉션의 도큐먼트를 검색하는 기준 필드는 connectToField( airport 필드 )이므로

 

빠른 조인 처리를 위해서 airports 컬렉션의 airport 필드에는 인덱스를 생성해주는 것이 좋다.

 

mongo> db.airports.createIndex( { airport : 1 } );

 

이번 예제에서는 Aggregation을 실행하는 컬렉션( startWith 필드의 컬렉션 )과

 

재귀 쿼리가 실행되는 컬렉션이 서로 다른 경우이지만, 실제 다음과 같이 사원의 정보에 저장된 상사(팀장)의 정보를 이용해서

 

단일 컬렉션에 대한 재귀 쿼리를 실행할 수도 있다.

 

mongo> db.employees.insertMany(
  [
      { _id : 1, name : "Lara" }
    , { _id : 2, name : "Elito", reportsTo : "Lara" }
    , { _id : 3, name : "Ron", reportsTo : "Eliot" }
    , { _id : 4, name : "Andrew", reportsTo : "Eliot" }
  ]
);

mongo> db.employees.aggregate(
  [
    {
      $graphLookup : {
          form : "employees"
        , startWith : "$reportsTo"
        , connectFromField : "reportsTo"
        , connectToField : "name"
        , as : "reportingHierarchy"
        , depthField : "reportingLevel"
      }
    }
  ]
);

 

$grphLookup 스테이지의 결과는 임시로 메모리에 저장되는데,

 

이 메모리 공간은 Aggregation 쿼리의 제한 사항인 100MB가 똑같이 적용된다.

 

그래서 만약 재귀 쿼리로 대용량의 결과를 처리하고자 한다면 조인의 결과를 임시로 디스크에 저장할 수 있게

 

"allowDiskUse : true" 옵션을 활성화해야 한다.