Spring Boot/mongodb

[MongoDB] Spring Data MongoDB lookup

수수한개발자 2024. 1. 31.
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

댓글