Spring Boot/mongodb

[MongoDB] Spring Data MongoDB lookup

수수한개발자 2024. 1. 31. 15:42
728x90

몽고 디비의 lookup은 RDB에서의 Join과 같은 기능이라 생각하시면 됩니다.

몽고 디비는 유연한 스키마와 변경이 쉬워서 기본적으로 정규화를 하지 않는게 몽고 디비의 특징이라 할 수 있습니다.

이런 특징에 맞게 RDB보다 조인 성능이 느리고  데이터를 중복해서 저장합니다.

그렇기 때문에 조인(lookup)을 하기 보다는 앱 조인이라 하는 Application Layer에서 객체 참조를 통한 조인을 하는 것이 더 효율적이라고 합니다.

 

글 작성 환경은  spring boot 3.2.1, spring- data-mongodb, java 17에서 작성하였습니다.

이번글에서 사용할 restaurants와 orders 컬렉션입니다.

db.restaurants.insertMany( [
   {
      _id: 1,
      name: "American Steak House",
      food: [ "filet", "sirloin" ],
      beverages: [ "beer", "wine" ]
   },
   {
      _id: 2,
      name: "Honest John Pizza",
      food: [ "cheese pizza", "pepperoni pizza" ],
      beverages: [ "soda" ]
   }
] )

db.orders.insertMany( [
   {
      _id: 1,
      item: "filet",
      restaurant_name: "American Steak House"
   },
   {
      _id: 2,
      item: "cheese pizza",
      restaurant_name: "Honest John Pizza",
      drink: "lemonade"
   },
   {
      _id: 3,
      item: "cheese pizza",
      restaurant_name: "Honest John Pizza",
      drink: "soda"
   }
] )

 

 

MongoDB lookup 3가지 방법

 

MongoDB의 lookup에서는 3가지가 있습니다.

  • Equality Match with a Single Join Condition
  • Join Conditions and Subqueries on a Joined Collection
  • Correlated Subqueries Using Concise Syntax

 

Equality Match with a Single Join Condition

"결합된" 컬렉션 문서의 필드와 입력 문서의 필드 간에 동일 일치를 수행하려면 $lookup스테이지의 구문은 다음과 같습니다.

 

SQL

SELECT *, (
   SELECT ARRAY_AGG(*)
   FROM <collection to join>
   WHERE <foreignField> = <collection.localField>
) AS <output array field>
FROM collection;

 

MongoDB

{
   $lookup:
     {
       from: <collection to join>,
       localField: <field from the input documents>,
       foreignField: <field from the documents of the "from" collection>,
       as: <output array field>
     }
}

 

  • from : join할 컬렉션
  • localField : 기준이 되는 부모키의 필드이다.
  • foreignField : 조인하려는 컬렉션의 fk 값이다.
  • as: 조회하여 나온 값을 담을 필드이다.

 

그래서 위의 restaurants 와 order를 lookup하면 다음과 같이 된다.

db.restaurants.aggregate( [
      {
          $lookup: {
              from: "orders",
              localField: "name",
              foreignField: "restaurant_name",
              as: "orders"
          }
      }
   ] )

 

 

Spring

위의 쿼리를 Spring data mongodb 로 바구면 다음과 같이 된다.

public List<Document> aggregateRestaurantsOrdersBasicLookup() {
    Aggregation aggregation = Aggregation.newAggregation(
        Aggregation.lookup("orders", "name", "restaurant_name", "orders")
    );

    return mongoTemplate.aggregate(aggregation, "restaurants", Document.class)
        .getMappedResults();
}

 

 

Join Conditions and Subqueries on a Joined Collection

 

MongoDB에서 상관 하위 쿼리는 pipeline 안에 lookup으로 결합된 컬렉션의 문서 필드를 참조하는 단계입니다. 

상관되지 않은 하위 쿼리는 조인된 필드를 참조하지 않습니다.

 

SQL

SELECT *, <output array field>
FROM collection
WHERE <output array field> IN (
   SELECT <documents as determined from the pipeline>
   FROM <collection to join>
   WHERE <pipeline>
);

 

 

MongoDB

{
   $lookup:
      {
         from: <joined collection>,
         let: { <var_1>: <expression>, …, <var_n>: <expression> },
         pipeline: [ <pipeline to run on joined collection> ],
         as: <output array field>
      }
}

 

  • from : join할 컬렉션
  • let : 조인하여 나온 값을 변수로 사용할 수 있습니다.
  • pipeline: 조인된 컬렉션에서 실행할 pipeline 을 지정합니다. pipeline은 결합된 컬렉션에서 결과 문서를 결정합니다. 모든 문서를 반환하려면 pipeline [] 과 같이 사용합니다.
  • as: 조회하여 나온 값을 담을 필드이다.

 

그래서 위의 restaurants 와 order를 lookup하면 다음과 사용할 수 있다.

db.restaurants.aggregate( [
    {
        $lookup: {
            from: "orders",
            let: {
                name_var: "$name",
                beverages_lst: "$beverages",
                food_lst: "$food"
            },
            pipeline: [
                {
                    $match: {
                        $expr: {
                            $and: [
                                {
                                    $eq: [
                                        "$$name_var",
                                        "$restaurant_name"
                                    ]
                                },
                                {
                                    $in: [
                                        "$drink",
                                        "$$beverages_lst"
                                    ]
                                },
                                {
                                    $in: [
                                        "$item",
                                        "$$food_lst"
                                    ]
                                }
                            ]
                        }
                    }
                }
            ],
            as: "orders"
        }
    }
] )

 

 

Spring

public List<Document> joinConditionsandSubqueriesonaJoinedCollection() {
    final Map<String, String> letVariables = Map.of(
        "name_var", "$name",
        "beverages_lst", "$beverages",
        "food_lst", "$food"
    );

    AggregationOperation matchOperation = context -> new Document("$lookup",
        new Document("from", "orders")
            .append("let", new Document(letVariables))
            .append("pipeline", List.of(
                new Document("$match",
                    new Document("$expr",
                        new Document("$and",
                            List.of(
                                new Document("$eq", List.of("$$name_var", "$restaurant_name")),
                                new Document("$in", List.of("$drink", "$$beverages_lst")),
                                new Document("$in", List.of("$item", "$$food_lst"))
                            )
                        )
                    )
                )
            ))
            .append("as", "orders")
    );

    Aggregation aggregation = Aggregation.newAggregation(
        matchOperation
    );

    return mongoTemplate.aggregate(aggregation, "restaurants", Document.class)
        .getMappedResults();
  }

 

 

 

 

Correlated Subqueries Using Concise Syntax

 

MongoDB 5.0부터 상관 하위 쿼리에 간결한 구문을 사용할 수 있습니다. 상호 연관된 하위 쿼리는 결합된 "외부" 컬렉션 과aggregate() 메서드가 실행된 "로컬" 컬렉션 의 문서 필드를 참조합니다.

다음과 같은 새롭고 간결한 구문은 연산자 내부의 외부 및 로컬 필드에 대한 동등 일치 요구 사항을 제거합니다

 

 

SQL

SELECT *, <output array field>
FROM localCollection
WHERE <output array field> IN (
   SELECT <documents as determined from the pipeline>
   FROM <foreignCollection>
   WHERE <foreignCollection.foreignField> = <localCollection.localField>
   AND <pipeline match condition>
);

 

 

MongoDB

{
   $lookup:
      {
         from: <foreign collection>,
         localField: <field from local collection's documents>,
         foreignField: <field from foreign collection's documents>,
         let: { <var_1>: <expression>, …, <var_n>: <expression> },
         pipeline: [ <pipeline to run> ],
         as: <output array field>
      }
}

 

사용법은 지금까지 설명한 내용과 같습니다.

 

restaurants 와 order를 lookup하면 다음과 사용할 수 있다.

 

5.0 버전에서 나온 간편한 문법이다.

db.restaurants.aggregate( [
    {
        $lookup: {
            from: "orders",
            localField: "name",
            foreignField: "restaurant_name",
            let: {
                beverages_lst: "$beverages",
                food_lst: "$food"
            },
            pipeline: [
                {
                    $match: {
                        $expr: {
                            $and: [
                                {
                                    $in: [
                                        "$drink",
                                        "$$beverages_lst"
                                    ]
                                },
                                {
                                    $in: [
                                        "$item",
                                        "$$food_lst"
                                    ]
                                }
                            ]
                        }
                    }
                }
            ],
            as: "orders"
        }
    }
] )

 

1. lookup으로 조인
2. let으로 조회 값의 beverages를 beverages_lst 변수에 담음, food 값을 food_lst변수에 담음
3. expr는 lookup에서 필드를 비교하기 위해 사용한다.
4. let의 변수는 pipeline에서 $$로 사용할 수 있다.
5. as 결과를 반환 받을 값

 

Spring

public List<Document> correlatedSubqueriesUsingConciseSyntax() {
    final Map<String, String> letVariables = Map.of(
        "food_lst", "$food",
        "beverages_lst", "$beverages"
    );

    AggregationOperation matchOperation = context -> new Document("$lookup",
        new Document("from", "orders")
            .append("localField", "name")
            .append("foreignField", "restaurant_name")
            .append("let", new Document(letVariables))
            .append("pipeline", List.of(
                new Document("$match",
                    new Document("$expr",
                        new Document("$and",
                            List.of(
                                new Document("$in", List.of("$drink", "$$beverages_lst")),
                                new Document("$in", List.of("$item", "$$food_lst"))
                            )
                        )
                    )
                )
            ))
            .append("as", "orders")
    );
    Aggregation aggregation = Aggregation.newAggregation(
        matchOperation
    );


    return mongoTemplate.aggregate(aggregation, "restaurants", Document.class)
        .getMappedResults();
  }

 

 

 

DTO로 반환 받는법

 

Restaurants

@Document(collection = "restaurants")
public class Restaurants {

      @Id
      private Integer id;

      private String name;
      private List<String> food;
      private List<String> beverages;
      // constructor, getter, setter
  }

 

Orders

@Document(collection = "orders")
public class Orders {

      @Id
      private Integer id;

      private String item;

      private String restaurant_name;

      private String drink;

      ...
  }

 

 

Dto

public record RestaurantsOrdersLookupDto(
    @Unwrapped(onEmpty = Unwrapped.OnEmpty.USE_EMPTY)
    Restaurants restaurants,
    List<Orders> orders
) {}

 

 

mongotemplate 부분에서 클래스 타입을 dto로 변경해주면 된다.

하지만 쿼리를 날리면 다음과 같이 객체가 아니라 key : value 형식으로 name = American Steak House로 반환받으므로

Dto 안 restaurants에 매핑이 안된다.

 

 

공식문서에 따르면 기본적으로 객체 매핑은 생성자를 통해 된다고 설명 되어 있다.

하지만 이미 도큐먼트를 정의하였는데 이를 dto로 반환 받으려고 다시 필드를 작성하기보단 다른 방법을 찾다가 

Unwrapping Types라는 걸 찾았다.

 

공식문서에서는 래핑되지 않은 엔티티 속성이 상위 MongoDB 문서로 평면화되는 java 도메인의 모델의 값 개체를 디자인하는데 사용된다고 한다.

 

예제를 보면 다음과 같다.

 

class User {
    @Id
    String userId;
    @Unwrapped(onEmpty = USE_NULL) 
    UserName name;
}

class UserName {
    String firstname;
    String lastname;
}

{
  "_id" : "1da2ba06-3ba7",
  "firstname" : "Emma",
  "lastname" : "Frost"
}

 

이름 속성을 로드할 때 이름과 성이 모두 null이거나 존재하지 않는 경우 해당 값은 null로 설정됩니다. onEmpty=USE_EMPTY를 사용하면 속성에 null 값이 있을 수 있는 빈 UserName이 생성됩니다.

 

@Unwrapped(onEmpty = USE_NULL) 와 @Unwrapped(onEmpty = USE_EMPTY) 보단 

@Unwrapped.Empty 나 Unwrapped.Nullable에는 null 허용을 검사하는 api가 있어서 사용하기를 권장하고 있습니다.

 

 

 

ref

728x90