HyeLog
[QueryDSL] No Offset 페이징, @QueryProjection 으로 DTO 반환하기 본문
이번 프로젝트에서 최신순 / 가격낮은순 / 가격높은순 으로 카테고리의 전체 상품을 보여주는 기능을 구현하게 되었다.
따라서, 페이징 처리를 해야 했는데, 기존에 자주 쓰이던 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;
}
✨ 가격 높은순
가격에 따라 정렬하는 경우, cursorId 와 cursorPrice 가 필요하다.
/**
* 가격높은순으로 전체 또는 특정 카테고리의 상품 조회
* 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
커서 기반 페이지네이션 적용기
무한 스크롤 구현을 요구받아서 처리하려고 찾아본 결과 커서 기반 페이지네이션이라는 키워드가 있어서 찾아 공부해봤다. 1. 페이지네이션(Pagination) 이란? 전체 데이터에서 지정된 개수만 데이
giron.tistory.com
https://jojoldu.tistory.com/528
1. 페이징 성능 개선하기 - No Offset 사용하기
일반적인 웹 서비스에서 페이징은 아주 흔하게 사용되는 기능입니다. 그래서 웹 백엔드 개발자분들은 기본적인 구현 방법을 다들 필수로 익히시는데요. 다만, 그렇게 기초적인 페이징 구현 방
jojoldu.tistory.com
'웹 개발 > Spring Boot' 카테고리의 다른 글
[Spring Boot] 네이버 SENS 로 문자(SMS) 전송하기 (0) | 2023.07.28 |
---|---|
[Spring Boot + Amazon S3] - 이미지 업로드, 조회 방법 (0) | 2023.07.06 |
[Spring Data JPA] N+1 문제, Fetch Join 및 Entity Graph 로 해결 (0) | 2023.07.05 |
[Spring Data JPA] SQL의 IN - 컬렉션 파라미터 바인딩 (0) | 2023.07.04 |
[Spring Data JPA] @Query 조회 기능 (0) | 2023.07.04 |