몽고 디비의 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에 매핑이 안된다.
![[MongoDB] Spring Data MongoDB lookup - DTO로 반환 받는법 - 모든 영역 [MongoDB] Spring Data MongoDB lookup - DTO로 반환 받는법 - 모든 영역](https://blog.kakaocdn.net/dn/bsLLX3/btsEcqfvFEr/Eh5hAXIdVGXGcRSjb3fqW1/img.png)
공식문서에 따르면 기본적으로 객체 매핑은 생성자를 통해 된다고 설명 되어 있다.
하지만 이미 도큐먼트를 정의하였는데 이를 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
댓글