본문 바로가기
스프링 부트/A-ger

스프링 무한스크롤 페이징 검색 처리 및 동적쿼리 적용하기

by illlilillil 2022. 2. 7.

목적

프로젝트에서 무한 스크롤 기능과 동적 쿼리를 활용해 카테고리별 정렬, 키워드 검색 등을 수행하기 위해

만들었습니다.

 

장점

원래 프로젝트에는 /api/product/views,/api/product/time, /api/product/category로 나뉘어져 각 조회마다 다른 기능을 하도록 되어 있었습니다. 그러다 보니 코드의 양도 몇 배가 늘어버리고, 단순히 검색이라는 키워드 안에 여러가지 api가 섞여버려 본래의 목적을 읽어버린 느낌까지 들었습니다. 페이징과, 동적 쿼리를 적용 시키고 난 후엔 검색용 api가 통합되어 알아보기 쉽게 만들어졌고 프론트엔드에서도 쿼리 파라미터에 추가만 하면 되니 사용법이 쉬워진 것 같습니다. 코드의 양적으로 봐도 훨씬 줄어 클린 코드가 만들어지게 됐습니다.

 

단점은 Url 길이가 길어진다는 점?이에요.

우선 Pageable에 대한 개념을 알아야 합니다.

Pageable은 리스트 전부를 보여주지 않고 페이징 처리를 통해서 사용자가 설정한 사이즈, 정렬 방식 등에 따라 정보를 설정 할 수 있게 해주는 객체입니다. 가령 page=0&size=10&sort=productViewCnt,desc&category=HOUSEHOLDS 라는 파라미터를 주었을때 0번 페이지, 사이즈는 10, 조회수순으로 정렬, 카테고리는 HOUSEHOLDS로 요청해 사용자가 원하는 정렬된 데이터를 쉽게 얻을 수 있습니다.

단순히 쿼리 파라미터를 설정해 원하는 데이터를 만들 수 있기 때문에 쉽게 페이징 처리를 할 수 있습니다.

Pageable의 반환 값은 Page, Slice, List를 제공하는데 Page는 전체 페이지 크기를 제공하고, 전체 페이지를 항상 계산 하는 count 쿼리를 실행하게 됩니다.

Slice는 아래 사진과 같이 first, last 정보만 주어 마지막인지에 대한 정보만 주어집니다. 따로 전체 페이지에 대한 count가 없어 성능적으로 우수하고, 제가 진행하는 프로젝트가 앱에 맞춰져 있고 무한 스크롤을 쉽게 수행할 수 있는 Slice를 선택하게 되었습니다.

 

동적 쿼리를 위해서 QueryDsl을 적용하였습니다. 혹시 설정이 안되셨다면 아래 글을 참고해주세요.

 

스프링 부트 queryDsl 환경 설정하기

사용 환경 스프링부트 : 2.5.9 버전 2.6 이상부터는 에러가 납니다 build.gradle에서 여러가지 추가해야 합니다. 1. dependencies에 추가 //querydsl 추가 implementation 'com.querydsl:querydsl-jpa' implementa..

crazy-horse.tistory.com

Qclass 타입이 모두 만들어져 있다는 가정 하에 진행하겠습니다.

 

전체적인 순서는 이렇습니다.

1. ProductRepositoryCustom 생성

2. ProductRepositoryImpl 생성

3. service에서 요구사항에 맞는 response 반환

4. controller에서 요구사항에 맞게 데이터를 요청 및 응답

 

repository 클래스 안에 아래 코드를 기입해주세요.

public interface ProductRepositoryCustom {
    Slice<ProductThumbResponse> findAllProductPageableOrderByCreatedAtDesc(Category category,String keyword,Pageable pageable);
}

기존 ProductRepository에도 상속에 추가를 해주세요

public interface ProductRepository extends JpaRepository<Product, Long>, ProductRepositoryCustom {
}

메소드 구현을 위해 ProductRepositoryImpl 클래스를 생성해줍니다.

limit로 가져올 엔티티의 개수를 구합니다.

where절에 booleanexpression을 사용해 재사용성을 높이고 코드 가독성을 높였습니다.

booleanexpression을 사용하면 category나 keyword가 null이어도 자동으로 조건절에서 제외되도록 할 수 있습니다.

for(Sort.Order o: pageable.getSort())문으로 쿼리에서 orderBy 대신 처리할 수 있습니다.

hasNext로 마지막 페이지를 구분하고 하나 더 가져온 엔티티를 삭제합니다.

//QClass Import 해주세요
import static com.ireland.ager.product.entity.QProduct.product;

@Repository
@RequiredArgsConstructor
public class ProductRepositoryImpl implements ProductRepositoryCustom {
    private final JPAQueryFactory queryFactory;

    @Override
    public Slice<ProductThumbResponse> findAllProductPageableOrderByCreatedAtDesc(Category category,String keyword ,Pageable pageable) {
        JPAQuery<Product> productQuery= queryFactory
                .selectFrom(product)
                .where(keywordContains(keyword),categoryEq(category))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize()+1); //limit보다 한 개 더 들고온다.
        for(Sort.Order o: pageable.getSort()) {
            PathBuilder pathBuilder = new PathBuilder(product.getType(),product.getMetadata());
            productQuery.orderBy(new OrderSpecifier(o.isAscending()? Order.ASC: Order.DESC,pathBuilder.get(o.getProperty())));
        }
        List<ProductThumbResponse> content=new ArrayList<>(ProductThumbResponse.toProductListResponse(productQuery.fetch()));
        boolean hasNext =false;
        //마지막 페이지는 사이즈가 항상 작다.
        if(content.size() > pageable.getPageSize()) {
            content.remove(pageable.getPageSize());
            hasNext=true;
        }
        return new SliceImpl<>(content,pageable,hasNext);
    }
    //동적 쿼리를 위한 BooleanExpression
    private BooleanExpression categoryEq(Category category) {
        return ObjectUtils.isEmpty(category) ? null : product.category.eq(category);
    }
    private BooleanExpression keywordContains(String keyword) {
        return ObjectUtils.isEmpty(keyword) ? null : product.productName.contains(keyword);
    }
}

Service 단에선 단순 데이터  response와 request만 담당하고 있습니다.

public Slice<ProductThumbResponse> findProductAllByCreatedAtDesc(Category category, String keyword, Pageable pageable) {
    return productRepository.findAllProductPageableOrderByCreatedAtDesc(category,keyword,pageable);
}

Controller 단에서는 category와 keyword를 required=false로 설정해 굳이 파라미터로 들어오지 않아도 원하는 데이터를 받아올 수 있게 해야 합니다. Pageable에는 page(페이지 순서), size(페이지 당 사이즈), limit로 이뤄져 있어 쿼리 파라미터만 날려도 Pageable 객체에서 받을 수 있습니다.

@GetMapping("/api/product/search")
public ResponseEntity<SliceResult<ProductThumbResponse>> searchAllProducts(
        @RequestParam(value = "category",required = false)Category category
        ,@RequestParam(value = "keyword",required = false) String keyword
        ,Pageable pageable) {
    return new ResponseEntity<>(responseService.getSliceResult(
            productService.findProductAllByCreatedAtDesc(category,keyword,pageable)), HttpStatus.OK);
}

 

마지막으로 포스트맨에서 데이터 요청을 해보겠습니다.

첫번째 페이지를 요청했고, 두 개의 데이터를 불러오도록 하였습니다. sort로 조회수순으로 정렬하고, 최신순으로 정렬하도록 요청했습니다. 또한 카테고리 조건도 추가하였고, keyword를 contains(like "%keyword%")를 주어 title에 포함된다면 검색이 되도록 하였습니다.

아래는 요청 받은 응답 Json입니다. 첫 페이지이므로 first에는 true가 나오며, 마지막 페이지가 아니기 때문에 false가 나오게 됩니다. 마지막 페이지라면 true로 변경되어 응답받게 될 것입니다.

{
    "success": true,
    "code": 0,
    "msg": "성공하였습니다.",
    "data": {
        "content": [
            {
                .data.data.
            },
            {
                .data.data.
            }
        ],
        "pageable": {
            "sort": {
                "empty": false,
                "sorted": true,
                "unsorted": false
            },
            "offset": 0,
            "pageNumber": 0,
            "pageSize": 2,
            "paged": true,
            "unpaged": false
        },
        "sort": {
            "empty": false,
            "sorted": true,
            "unsorted": false
        },
        "size": 2,
        "number": 0,
        "last": false,
        "first": true,
        "numberOfElements": 2,
        "empty": false
    }
}

 

참고 문헌

 

Pageable을 이용한 Pagination을 처리하는 다양한 방법

Spring Data JPA에서 Pageable 를 활용한 Pagination 의 개념과 방법을 알아본다.

tecoble.techcourse.co.kr

 

댓글