티스토리 뷰
목표사항
상품검색
mysql like 처럼 상품명 전부 입력하지 않는다하더라도
일부만 입력한 일부를 입력하면
일부라도 갖고있다면 검색결과에 나오도록 구현하는 것을 목표로했다.
국가별 검색/ 카테고리 검색
국가와 카테고리 검색은 옵션이다.
최신순
- 상품을 등록한 날짜가 최신일수록 가장 먼저 나오도록 구현하는 것을 목표로 했다.
- 꼭 query에 넣지 않아도 최신순 검색을 기본으로 하고 있다.
주문날짜순
- 상품의 주문마감일이 현재와 가까울 수록 가장 먼저나오도록 구현하는 것을 목표로 했다.
- 단, 주문마감일은 지나지 않은 데이터만을 나타내도록 했다.
typeORM은 검색이 쉬우나, 몽고디비에서는 생각보다 쉽지 않았다.
검색하려는 상품명이 null 일 수 있고
마찬가지로 주문날짜순, 국가별검색, 카테고리 검색조건이 없어도 모두 null일 수있다.
그때 내 판단은 검색조건이 Null 인지 아닌지 체킹해야되는지를 따져야되는 $cond(if, then, else) 를 넣어서 검색해보자고 판단했다.
그러나 검색조건에 맞지 않은 데이터 전부를 응답한.. 내가 생각하는 방향성과 다른 결과가 나왔다..😔
고민사항
- populate 시킨 이후에 populate 된 데이터를 가지고 정렬을 할 수 있을까?
- populate를 시킨 이후에 populate 된 데이터를 가지고 검색을 할 수 없을까?
1) populate 이후에 populate 된 데이터를 가지고 정렬하기 (populate after sort)
const result = await this.marketModel
.find()
.populate('product',
['_id', 'name', 'price', 'createdAt' ],
null,
{ sort: {
createdAt: search.createdAt, // 등록일(createdAt)을 기준으로 정렬 (값 존재시: 내림차순)
closeDate: search.closeDate // 주문마감일(closeDate)을 기준으로 정렬 (값 존재시: 오름차순)
});
[참고]
[발생에러]
populate() 는 참조키 필드로 다른 다큐먼트 불러올수 있어도 참조필드를 통해 불러온 다큐먼트의 데이터를 이용할 수 없다.
- 1차 시도
const result = await this.marketModel
.find({ product.name: search.name }) // <- 오류 발생 (eslint 오류)
.populate('product', ['_id', 'name', 'price'])
- 2차 시도
const result = await this.marketModel
.find({ product: { name: { $eq: search.name }}}) // <- 오류 발생
.populate('product', ['_id', 'name', 'price'])
[Nest] 64364 - 2022. 11. 15. 오후 7:17:29 ERROR [ExceptionsHandler] Cast to ObjectId failed for value "{ name: { '$eq': 'black padding' } }" (type Object) at path "product" for model "Market" CastError: Cast to ObjectId failed for value "{ name: { '$eq': 'black padding' } }" (type Object) at path "product" for model "Market"
2) populate 이후 검색조건이 맞는 데이터를 추출
- 그러나 검색조건이 맞지 않은 데이터의 경우에는 상품정보가 null 이 나왔다.
- sql의 join 과 다르게 검색조건에 맞지않아도, product 영역만 null일뿐 모든 데이터가 검색결과에 나와버렸다.
내가 의도한 방향과 다른 방향의 결과였다 😢
const results = await this.marketModel
.find()
.populate({
path: 'product',
select: [ '_id', 'name', 'price', 'createdAt', 'closeDate' ],
match: {
name: { $eq: search.name }, // 상품명 검색
sort: {
createdAt: search.createdAt,
closeDate: search.closeDate
}
}
})
상품명과 페이지만 쿼리에 포함하여 아래 와 같이 요청한 결과가 아래 사진 이다.
[GET] /api/markets?page=1&name=black%20padding
- 요청데이터
{ page = 1, name = 'black padding' }
- 응답데이터
상품명이 black padding 이 아닌 데이터는 위의 사진 주황색 박스처럼 product 가 null 로 표기되어있다.
내가 원한 방향은 상품명이 black padding인 데이터만 응답데이터 로 나타내야하는거다.
그래서 populate를 사용하지 않고 다른 방법을 모색하기로 했다.
mongoose에서 join과 같은 역할이 populate 라고는 하지만
populate가 아닌 다른 방법도 있다.
몽고디비의 꽃 aggregate 로 해보기로 했다.
aggregate
구글링 결과 aggregate로도 참조 키가 있다면 콜렉션을 조인할 수 있다.
아래 코드는 2개의 콜렉션을 join 한 결과이다.
const result = await this.marketModel.aggregate([
{
$lookup: {
from: 'users', // join할 콜렉션이름
localField: 'seller', // 현재콜렉션의 외래키
foreignField: '_id', // join할 콜렉션의 primary key
as: 'seller', // join 이후 user 데이터를 나타내는 key값
},
},
]);
그런데 여기서는 검색필터없이 전체 데이터가 뿌려지는 결과이다.
필자가 목표하는 바는 '검색 상품명' , '검색 카테고리', '검색국가' 조건에 맞는 데이터를 뿌려야한다.
검색하려는 '상품명', '카테고리', '국가' 는 선택적인 옵션이다.
- 선택적으로 검색하기 (very hard!!!)
[시도1]
3개의 콜렉션을 aggregate 후 검색조건에 맞는 데이터만 추출되도록 $cond 를 추가했으나 linter error(코드문법에러)가 떴다.
async searchMarket(search: SearchMarket) {
const query = this.marketModel.aggregate([
{
$lookup: {
from: 'users', // user 콜렉션과 join
localField: 'seller',
foreignField: '_id',
as: 'seller',
},
},
{
$lookup: {
from: 'products', // product 데이터과 join
localField: 'product',
foreignField: '_id',
as: 'product',
},
},
{
$project: {
product: 1, // product 정보를 모두 구한다.
seller: {
// 판매자의 _id, name, sellerNickname 컬럼만 추출한다. (일부컬럼 추출)
_id: 1,
name: 1,
sellerNickname: 1,
},
},
},
{
// 상품명 파라미터가 null 이 아니라면 검색 가능
// liter error 발생지점
// $cond 영역에서 다음과 같은 'PipelineStage 형식에 할당할 수 없습니다.' 라는 에러가 나온다.
$cond: {
if: { 'search.name': { $ne: null } },
then: {
$match: {
'product.name': { $regex: '.*' + search.name + '.*' },
},
},
else: null,
},
},
]);
return await query
.limit(PER_PAGE) // 한 페이지당 20개
.skip(PER_PAGE * (search.page - 1))
.exec();
}
[시도2] $match 안에 $cond 를 넣어서 선택적으로 나타내려고 했으나... unknown top level operator 에러가 발생했다.
const query = this.marketModel.aggregate([
{
$lookup: {
from: 'users', // user 콜렉션과 join
localField: 'seller',
foreignField: '_id',
as: 'seller',
},
},
{
$lookup: {
from: 'products', // product 데이터과 join
localField: 'product',
foreignField: '_id',
as: 'product',
},
},
{
$project: {
product: 1, // product 정보를 모두 구한다.
seller: {
// 판매자의 _id, name, sellerNickname 컬럼만 추출한다. (일부컬럼 추출)
_id: 1,
name: 1,
sellerNickname: 1,
},
},
},
{
$match: {
// 조건1(필수): 상품은 삭제되지 않은 상태이다.(ok)
'product.deletedAt': null,
$or: [
// 조건2(선택): 상품명(search.name) 가 null 이 아니라면 검색 가능
{
$cond: {
if: { $exists: { 'search.name': { $not: null } } },
then: {
// search.name like 검색
'product.name': { $regex: '.*' + search.name + '.*' },
},
else: {},
},
},
// 조건3(선택): 상품 카테고리(search.category)가 null이 아니라면 검색 조건 추가
// 조건4(선택): 상품 구매국가(search.buyCountry)가 null 이 아니라면 검색조건 추가
],
},
},
]);
return await query.sort({'product.createdAt': -1}).exec()
[에러메시지]
ERROR [ExceptionsHandler] unknown top level operator: $cond.
If you have a field name that starts with a '$' symbol, consider using $getField or $setField.
아래 stackoverflow 질문자 글도, $match 블록안에 $cond를 넣어서 조건에 따라 검색을 시도하다가 에러발생하는 필자와 같은 상황을 겪었다. 물론 $expr 도 사용해서 시도해봤지만 에러가 발생했다.
$cond 옵션은 $project 옵션 블록에서는 가능하지만
하지만 $match 옵션 블록 내에서는 $cond를 이용하여 선택적인 검색을 할 수 없다고 한다.
- 필자가 사용하는 몽고디비(Mongodb atlas)의 버젼은 5.0.13 이다.
this.marketModel.aggregate([
$match: {
'필드명': {
$cond: {
if: { 조건 },
then: { 조건이 true 일때 },
else: { 조건이 false 일때 }
}
}
}
])
this.marketModel.aggregate([
$match: {
$expr:{
'필드명': {
$cond: {
if: { 조건 },
then: { 조건이 true 일때 },
else: { 조건이 false 일때 }
}
}
}
}
]);
공식다큐먼트 예제에서도 $cond는 aggregate 연산할때 $project 내부블록에서 사용할 수 있다.
$project는 $aggregate 이후 나타내고 싶은 컬럼을 숫자로 지정하여 결과로 나타내게 해주는 옵션이다.
예를들면 aggregate연산이후에 이름(name) 컬럼에 대한 데이터가 꼭 필요하다면
name: 1 로 지정하면
결과데이터에는 name필드가 포함되어있다. 자세한 예시는 아래 공식다큐먼트의 예제를 보고 이해하면된다.
aggregate 연산순서
몽고디비의 aggregate 연산을 수행할 때 처리 단계가 존재한다.
collection > $project > $match > $group > $sort > $skip > $limit > $unwind > $out
[참고]
해결코드
aggregate를 이용하여, 2개이상의 콜렉션을 join시켜서 조건검색에 맞는 데이터 조회를 마쳤다!
특히 선택적으로 검색하는 과정이 어려웠다. 해결방안을 찾아보고 적용하다가 에러가 계속발생하고 또 해결방안찾다가...
결국 생각한건 if문을 추가하여 초기값(null)을 변경하여 쿼리문에 적용하는 방식으로 했다.
(+ 더 좋은 쿼리문이 있다면 댓글로 공유부탁드립니다!! 🙇🏻♀️)
- searchNameQuery(상품명 검색), searchCategoryQuery(상품 카테고리 검색), searchBuyCountryQuery(상품 구매국가검색) 값을 null 로 초기화 했다.
- 만일 상품명 검색이 존재한다면, searchNameQuery 값을 null 이 아닌 다른 값으로 변경했다.
- sortCloseDateQuery(주문마감일 조건)이 존재한다면, searchNameQuery와 비슷한 방식으로 해결했다.
async searchMarket(search: SearchMarket) {
// (선택) 상품명(search.name) 가 null 이 아니라면 검색 쿼리 추가
let searchNameQuery = null;
if (search.name) {
searchNameQuery = {
'product.name': { $regex: '.*' + search.name + '.*' },
};
}
// (선택) 상품 카테고리(search.category)가 null이 아니라면 검색 쿼리 추가
let searchCategoryQuery = null;
if (search.category) {
searchCategoryQuery = {
'product.category': { $in: [search.category] },
};
}
// (선택) 상품 구매국가(search.buyCountry)가 null 이 아니라면 검색조건 추가
let searchBuyCountryQuery = null;
if (search.buyCountry) {
searchBuyCountryQuery = {
'product.buyCountry': { $in: [search.buyCountry] },
};
}
const query = this.marketModel.aggregate([
{
$lookup: {
from: 'users', // user 콜렉션과 join
localField: 'seller',
foreignField: '_id',
as: 'seller',
},
},
{
$lookup: {
from: 'products', // product 데이터과 join
localField: 'product',
foreignField: '_id',
as: 'product',
},
},
{
$project: {
product: 1, // product 데이터를 갖는다.
seller: {
// 판매자의 _id, name, sellerNickname 컬럼만 추출한다. (일부컬럼 추출)
_id: 1,
name: 1,
sellerNickname: 1,
},
},
},
{
$match: {
// 상품은 삭제되지 않은 상태 (product.deletedAt !== null)
'product.deletedAt': null,
},
},
{
$match: {
// 상품명 검색 쿼리
...searchNameQuery,
// 상품 카테고리 검색 쿼리
...searchCategoryQuery,
// 상품 구매국가 검색쿼리
...searchBuyCountryQuery,
},
},
]);
// 페이지네이션 추가
query
.limit(PER_PAGE) // 한 페이지당 20개
.skip(PER_PAGE * (search.page - 1));
// 날짜 sort 옵션 추가
let sortCloseDateQuery = null;
if (search.closeDate) {
sortCloseDateQuery = { 'product.closeDate': 1 };
}
return await query
.sort({ 'product.createdAt': -1, ...sortCloseDateQuery }) // 주문마감일 조건쿼리
.exec();
}
[참고]
- aggregate를 이용하여 2개 콜렉션을 join 하기
- aggregate 를 이용하여 3개 콜렉션을 join 하기
- aggregate 이후 원하는 조건의 데이터를 추가하도록, 조건옵션 추가하기
'Backend > 꾸준히 TIL' 카테고리의 다른 글
계층형 아키텍쳐(Layered Architectures) (0) | 2023.01.08 |
---|---|
[NestJS + Axios] Axios를 활용하여 현재 날씨데이터를 가져오기 (1) | 2022.11.23 |
[NestJS+MongoDB+Mongoose] MongoDB 외래키 나타내기 (0) | 2022.11.20 |
[NestJS] command not found: nest (0) | 2022.11.17 |
[MongoDB+Mongoose] MongoDB 옵션 활용하여 쿼리문 작성하기 (0) | 2022.11.17 |
- Total
- Today
- Yesterday
- 스마트폰중독
- MongoDB
- 디지털디톡스
- MySQL
- gem
- 개발용어
- node.js
- 클린아키텍쳐
- jest
- 한달어스
- vscode
- OS
- Nest.js
- Mongoose
- 미완
- 갓생살자
- TypeScript
- Jekyll
- RDBMS
- 나도 할 수 있다
- IT용어
- git
- 한달독서
- nestjs
- nestjs jest
- 습관개선
- TDD
- 참고
- typeORM
- 바이트디그리
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |