-
Notifications
You must be signed in to change notification settings - Fork 3
상품 리스트 조회 성능 개선기 (커서)
안녕하세요? 최지원입니다. 이번에 상품 리스트를 조회하면서 발생하는 여러 이슈들을 개선하면서 만든 글입니다.
상품 리스트 조회 방식은 크게 2가지가 존재합니다.
- offset 방식
- cursor 방식
이 방식은 토미의 강의를 잘 들었을거라 생각합니다. 결론적으로 설명하면 제대로 된 인덱스를 걸어둔 상태에서는 cursor 방식이 빠릅니다. 개발자라면 직접 찾아서 알아갈 수 있다고 생각해요~
저희 팀은 이번 프로젝트에서 cursor 방식을 도입하였습니다.
cursor 방식을 도입하려면 동적 쿼리가 필요합니다. 동적 쿼리를 편하게 만들기 위해 Querydsl의 도움을 받아봅시다.
Querydsl 코드는 아래와 같이 나옵니다.
@Override
public List<ProductSpecification> searchInStockProducts(final InStockProductSearchCondition condition) {
return jpaQueryFactory.select(
Projections.constructor(ProductSpecification.class,
product,
productSales.sale.value)
)
.from(product)
.leftJoin(productSales).on(product.id.eq(productSales.productId).and(saleNow(condition.getSaleDate()))).fetchJoin()
.where(
containsName(condition.getProductKeyword()),
sale(condition.getLastProductSale(), condition.getLastProductId()),
product.status.eq(ProductStatus.IN_STOCK))
.orderBy(productSales.sale.value.desc().nullsLast(), product.id.asc())
.limit(condition.getPageSize())
.fetch();
}
private BooleanExpression containsName(final String productKeyword) {
if (StringUtils.hasText(productKeyword)) {
return product.name.name.containsIgnoreCase(productKeyword);
}
return null;
}
private BooleanExpression saleNow(final LocalDate localDate) {
if (localDate == null) {
return null;
}
return productSales.saleDate.eq(localDate);
}
private BooleanExpression sale(final Long sale, final Long productId) {
if (productId == null) {
return null;
}
if (sale == null) {
return product.id.gt(productId);
}
return productSales.sale.value.lt(sale).or(productSales.sale.value.eq(sale).and(product.id.gt(productId)));
}
select
product0_.id as col_0_0_,
productsal1_.sale as sale,
product0_.id as id1_6_,
product0_.created_at as created_2_6_,
product0_.updated_at as updated_3_6_,
product0_.image_url as image_ur4_6_,
product0_.name as name5_6_,
product0_.price as price6_6_,
product0_.quantity as quantity7_6_,
product0_.status as status8_6_
from
products product0_
left join
product_sales productsal1_
on (
product0_.id=productsal1_.product_id
)
where
(
lower(product0_.name) like '%test%' escape '!'
)
and (
productsal1_.sale_date = '2023-08-27'
or productsal1_.sale_date is null
)
and (
productsal1_.sale < 276
or (productsal1_.sale = 276
and product0_.id > 83341)
)
order by
case
when productsal1_.sale is null then 1
else 0
end,
productsal1_.sale desc,
product0_.id asc limit 20;
이런 쿼리가 나가게 됩니다. 이를 바탕으로 성능을 분석해봅시다.
인덱스가 없을 때, 있을 때 두 경우의 실행 계획을 확인해봅시다.
인덱스가 없는 기준입니다.
커서 조건을 바탕으로 인덱스를 생성하면 아래처럼 실행계획이 바뀝니다.
대략 row를 1/5 만큼 찾게되네요
제가 우연히 찾은 글인데 이런 방식으로 해결이 가능하다고 하네요~
현재는 일일 판매량을 기준으로 정렬하고 있는 상황입니다.
만약에 일일 판매량이 정렬 조건이 아닌 최신순, 이름순, 가격순 등 다른 정렬 조건이 들어오면 쿼리를 어떻게 구성해야 할까요?
- 쿼리를 따로 판다.
- 정렬 조건에 맞춰 요청에서 어떤 정렬 조건을 가지는지 물어본다.
1번의 해결 방법이 가장 깔끔하긴 하지만 쿼리를 그 만큼 많이 만들어야하는 단점이 존재합니다.
대신에 1번 방법을 활용하면 상황에 맞게 쿼리를 완벽하게 최적화할 수 있는 장점이 존재합니다.
2번의 해결 방법을 활용해서 정렬 조건이 여러개일 때 어떻게 해야할지 알아봅시다.
우선 요청에 마지막 데이터가 뭔지 넣어줘야합니다. 그러면 이런 형태로 나오겠네요
{
"productKeyword":"검색어",
"pageSize":5,
"saleDate":"2023-08-06", -> 이 형식 맞나요?? 일단 이해할거라 믿어요
"lastProduct":{
"id":123,
"createdAt":"2022-08-06",
"name":"어떤상품1",
"price":15000
}
"searchType":"name"
}
무슨 조건으로 정렬할지 searchType으로 받아주고, 커서 기반으로 정렬하기 위해 마지막 상품에 대한 정보를 감싸야 합니다.
그 다음 querydsl에서 이 조건에 맞춰서 정렬과 커서 조건을 만들어주면 됩니다.
이 때 switch 문을 사용할 수 있습니다.
private Predicate sameLevelCursorFilter(PostRequest request, String searchType) {
if(request == null || searchType == null) {
return null;
}
switch (searchType) {
case "NAME":
return ExpressionUtils.or(product.name.비교로직(), product.id.lt(request.getId()));
case "PRICE":
return ExpressionUtils.or(product.price.비교로직(), product.id.lt(request.getId()));
default:
return null;
}
}
이런식으로 커서 조건을 만들 수 있습니다. 마찬가지로 정렬 조건도 이런 방식으로 넣어주시면 됩니다.
[DB] QueryDSL을 활용하여 SNS 피드 만들기(1) - 동적 쿼리로 Pagination 피드를 만들어보자.