HyeLog

[QueryDSL] No Offset 페이징, @QueryProjection 으로 DTO 반환하기 본문

웹 개발/Spring Boot

[QueryDSL] No Offset 페이징, @QueryProjection 으로 DTO 반환하기

shj718 2023. 7. 17. 17:45

이번 프로젝트에서 최신순 / 가격낮은순 / 가격높은순 으로 카테고리의 전체 상품을 보여주는 기능을 구현하게 되었다.

따라서, 페이징 처리를 해야 했는데, 기존에 자주 쓰이던 offset 과 limit 을 사용한 페이징 방식은 속도가 느려서 서비스가 커짐에 따라 장애를 유발할 수 있음을 알게 되었다.

그래서 해당 방식보다 속도가 빠른 No Offset 페이징을 사용하여 페이징 성능을 개선해보고자 한다. 

 

💫 페이징 종류

일반적으로 전체 아이템을 보여주는 방식에는 2가지가 있다.

첫번째는 이렇게 페이지 번호를 명시해서 해당 페이지의 아이템들만 보여주는 방식이다.

 

두번째는 [상품 더보기]를 누르면 다음 아이템들을 더 보여주는 방식이다.

 

이번 프로젝트에서는 사용할 방식은 두번째 방식이다.

 

💫 QueryDSL 로 페이징 구현하기 (Feat. DTO 반환, No Offset 방식)

참고로, 프로젝트에 QueryDSL 설정이 이미 완료된 상태이다.

 

1. DTO 반환을 위한 @QueryProjection

나는 상품의 정보 뿐만 아니라 상품의 대표이미지까지 불러와서 프론트엔드에게 넘겨줘야 하기 때문에,

쿼리의 결과를 Entity 가 아닌 DTO 로 한번에 받아오고 싶었다.

 

따라서, ProductRepositoryImpl 에서 QueryDSL 의 조회 결과를 바로 DTO 로 반환하기 위해서 생성자에 @QueryProjection 어노테이션을 붙여준다.

    @Builder
    @QueryProjection
    public GetProductResponse(Product product, ProductImage mainProductImage) {
        this.productId = product.getId();
        this.name = product.getName();
        this.detail = product.getDetail();
        this.brand = product.getBrand();
        this.price = product.getPrice();
        this.size = product.getSize();
        this.discountRate = product.getDiscountRate();
        this.productState = product.getProductState();
        this.mainImageFile = RegisterProductResponse.ImageFileInfo.builder()
                .imagePath(mainProductImage.getImagePath())
                .imageKey(mainProductImage.getImageKey())
                .isMainImage(true)
                .build();
    }

 

이후 IntelliJ 의 오른쪽 코끼리 모양을 선택해서 Tasks > other 에서 "compileQuerydsl" 을 실행시켜서 Q[DTO이름] 이 생기도록 만든다. (나의 경우 QGetProductResponse 가 잘 만들어졌다.)

 

 

 

2. Impl 에 구현

✨ 최신순

cursorId 는 [상품 더보기]를 눌렀을 때 다음 상품들을 가져오기 위한 것으로, 1번째 페이지 조회시 null, 2번째 이상 페이지 조회시 직전 페이지의 마지막 product id 값을 받는다. 그럼 다음 상품들을 조회할 때는 그 cursorId 보다 id 가 작은 product 들을 조회하게 된다.

 

이때 중요한 것은 상품의 중복을 방지하기 위해 cursorId 는 반드시 고유한 값(PK)이어야 한다. (PK + 다른 컬럼의 조합도 가능하다.)

 

pageSize 는 한 페이지에 가져올 상품 개수이다.

    /**
     * 최신순으로 전체 또는 특정 카테고리의 상품 조회
     * No Offset Pagination (페이징 성능 향상)
     */
    public List<GetProductResponse> findPageByProductRegistrationDate(Long cursorId, Long categoryId, int pageSize) {

        return jpaQueryFactory
                .select(new QGetProductResponse(
                        product,
                        productImage))
                .from(product)
                .leftJoin(productImage)
                .on(productImage.product.id.eq(product.id))
                .leftJoin(productCategory)
                .on(productCategory.product.id.eq(product.id))
                .where(ltProductId(cursorId),
                        filterByCategory(categoryId),
                        categoryIdEq(categoryId),
                        product.productState.eq(ProductState.SELLING),
                        productImage.isMainImage.eq(true))
                .orderBy(product.id.desc())
                .limit(pageSize)
                .fetch()
                .stream()
                .distinct()
                .collect(Collectors.toList());
    }
    
    private BooleanExpression ltProductId(Long cursorId) { // 첫 페이지 조회와 두번째 이상 페이지 조회를 구분하기 위함
        return cursorId != null ? product.id.lt(cursorId) : null;
    }

    private BooleanExpression categoryIdEq(Long categoryId) { // 카테고리가 없는 경우 전체 상품 조회
        return categoryId != null ? productCategory.category.id.eq(categoryId) : null;
    }
    
    private BooleanExpression filterByCategory(Long categoryId) {
        return categoryId == null ? productCategory.id.in(maxProductCategoryIdSubQuery) : null;
    }

 

✨ 가격 높은순

가격에 따라 정렬하는 경우, cursorIdcursorPrice 가 필요하다.

    /**
     * 가격높은순으로 전체 또는 특정 카테고리의 상품 조회
     * No Offset Pagination (페이징 성능 향상)
     */
    public List<GetProductResponse> findPageByProductPriceDesc(Long cursorId, Long cursorPrice, Long categoryId,
                                                               int pageSize) {

        return jpaQueryFactory
                .select(new QGetProductResponse(
                        product,
                        productImage))
                .from(product)
                .leftJoin(productImage)
                .on(productImage.product.id.eq(product.id))
                .leftJoin(productCategory)
                .on(productCategory.product.id.eq(product.id))
                .where(cursorIdAndCursorPriceDesc(cursorId, cursorPrice),
                        filterByCategory(categoryId),
                        categoryIdEq(categoryId),
                        product.productState.eq(ProductState.SELLING),
                        productImage.isMainImage.eq(true))
                .orderBy(product.price.desc(), product.id.desc())
                .limit(pageSize)
                .fetch()
                .stream()
                .distinct()
                .collect(Collectors.toList());
    }
    
    private BooleanExpression cursorIdAndCursorPriceDesc(Long cursorId, Long cursorPrice) { // 첫 페이지 조회와 두번째 이상 페이지 조회를 구분하기 위함
        if(cursorId == null || cursorPrice == null) {
            return null;
        }

        return product.price.eq(cursorPrice)
                .and(product.id.lt(cursorId))
                .or(product.price.lt(cursorPrice));
    }

 

 

 

 

 

참고:

https://earth-95.tistory.com/116

 

[Querydsl, pageable] slice를 이용한 무한 스크롤

들어가기 전에 프로젝트를 진행하면서, 빵집의 상세 페이지에서 메뉴 더보기 클릭 시, 해당 빵집에 존재하는 모든 메뉴를 순차적으로 보여주어야 했습니다. 이때, 아래로 쭉 내렸을 때 메뉴가

earth-95.tistory.com

https://giron.tistory.com/131

 

커서 기반 페이지네이션 적용기

무한 스크롤 구현을 요구받아서 처리하려고 찾아본 결과 커서 기반 페이지네이션이라는 키워드가 있어서 찾아 공부해봤다. 1. 페이지네이션(Pagination) 이란? 전체 데이터에서 지정된 개수만 데이

giron.tistory.com

https://jojoldu.tistory.com/528

 

1. 페이징 성능 개선하기 - No Offset 사용하기

일반적인 웹 서비스에서 페이징은 아주 흔하게 사용되는 기능입니다. 그래서 웹 백엔드 개발자분들은 기본적인 구현 방법을 다들 필수로 익히시는데요. 다만, 그렇게 기초적인 페이징 구현 방

jojoldu.tistory.com