Skip to content

상품 리스트 조회 성능 개선기 (커서)

Jiwon Choi edited this page Sep 5, 2023 · 2 revisions

안녕하세요? 최지원입니다. 이번에 상품 리스트를 조회하면서 발생하는 여러 이슈들을 개선하면서 만든 글입니다.

offset vs cursor

상품 리스트 조회 방식은 크게 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;

이런 쿼리가 나가게 됩니다. 이를 바탕으로 성능을 분석해봅시다.

no index vs yes index

인덱스가 없을 때, 있을 때 두 경우의 실행 계획을 확인해봅시다.

인덱스가 없는 기준입니다.

Untitled2

커서 조건을 바탕으로 인덱스를 생성하면 아래처럼 실행계획이 바뀝니다.

대략 row를 1/5 만큼 찾게되네요

Untitled3

만약에 OFFSET 방식을 써야 한다면??

제가 우연히 찾은 글인데 이런 방식으로 해결이 가능하다고 하네요~

Untitled4

만약 정렬 조건이 여러개라면??

현재는 일일 판매량을 기준으로 정렬하고 있는 상황입니다.

만약에 일일 판매량이 정렬 조건이 아닌 최신순, 이름순, 가격순 등 다른 정렬 조건이 들어오면 쿼리를 어떻게 구성해야 할까요?

  1. 쿼리를 따로 판다.
  2. 정렬 조건에 맞춰 요청에서 어떤 정렬 조건을 가지는지 물어본다.

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 페이지네이션을 최적화하는 여러 방법들

[DB] QueryDSL을 활용하여 SNS 피드 만들기(1) - 동적 쿼리로 Pagination 피드를 만들어보자.