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

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

반응형

들어가기 전에

프로젝트를 진행하면서, 빵집의 상세 페이지에서 메뉴 더보기 클릭 시, 해당 빵집에 존재하는 모든 메뉴를 순차적으로 보여주어야 했습니다. 이때, 아래로 쭉 내렸을 때 메뉴가 더 존재한다면 화면에 더 뿌려주고자 무한 스크롤을 구현하였습니다.

참고 사항

하기 예시는 모두 page index가 1부터 시작한다고 가정하고 진행한 것이기 때문에, 만약 page를 0부터 시작하고자 한다면, 변경이 필요할 수 있습니다.

페이징 기법 종류

진행하기 앞서, 간단하게 페이징 기법 종류에 대해 알아보고자 합니다. 먼저 아래 왼쪽 사진의 경우는, 일반적인 paging 기법입니다. 각 index를 눌러 페이지를 호출하고 해당 페이지에 해당하는 데이터를 뿌려줍니다. 반면, 오른쪽 사진의 경우는 더보기 버튼을 클릭하여 다음 데이터가 있다면 뿌려주는 형식으로, 따로 page번호를 명시하고 있지 않습니다.

일반적인 paging 방식(왼쪽)과 더보기를 통해 다음 데이터를 뿌려주는 slicing 방식(오른쪽)

Page

일반적인 Paging 예시

Paging은 일반적인 페이징 방식입니다. PC 버전에서는 위와 같이 각 페이지의 번호가 쓰여있고, 해당 번호 클릭 시 다음 페이지로 넘어가는 경우가 많습니다. 이런 경우를 Page를 사용해서 구현했다고 볼 수 있습니다. Page를 사용하면, 해당 페이지에 존재하는 데이터 뿐 아니라 총 데이터의 개수 및 총 페이지의 개수를 포함하여 응답을 리턴해줍니다.

  • 이로 인해, QueryDSL을 사용할 경우 fetchResult()를 사용하게 되는데, fetchResult()는 기존 fetch() 내용에 총 데이터 개수 및 페이지 개수를 counting 해주는 것이라고 볼 수 있습니다.
    • 따로 명시하지 않아도 counting을 해주는 것이라, 단순한 쿼리에서는 사용이 가능하지만 조금만 복잡해져도 스스로 counting을 하지 못해 직접 명시적으로 counting 쿼리를 작성해주어야 할 수 있습니다.

[ 잘못된 예 ]

아래 예시의 경우, groupBy를 사용하고 있어 fetchResults()에 의해 counting을 할 수 없는 쿼리입니다. 이 경우 아래 캡쳐 사진과 같이 antlr.MismatchedTokenException: expecting CLOSE, found ',' 에러가 발생하기 때문에 명시적으로 counting하는 형식으로 변경해주어야 합니다.

    public Page<BakeryMenuResponse> findBakeryMenuPageableByBakeryId(Long bakeryId, Pageable pageable) {
        QueryResults<BakeryMenuResponse> bakeryMenuResponseQueryResults = jpaQueryFactory
                .select(Projections.fields(BakeryMenuResponse.class,
                        menuReviews.menus.id.as("menuId"),
                        ...
                        menuReviews.rating.avg().as("avgRating")))
                .from(menuReviews)
                .where(menuReviews.bakeries.id.eq(bakeryId))
                .groupBy(menuReviews.menus.id, menuReviews.menus.breadCategories.id, menuReviews.menus.breadCategories.name, menuReviews.menus.name, menuReviews.menus.price, menuReviews.menus.imgPath)
                .orderBy(menuReviews.id.count().desc(), menuReviews.rating.avg().desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetchResults();

        List<BakeryMenuResponse> content = new ArrayList<>();
         for (BakeryMenuResponse eachBakeryMenuResponse: bakeryMenuResponseQueryResults.getResults()) {
            content.add(new BakeryMenuResponse(eachBakeryMenuResponse));

        }

        return new PageImpl<>(content, pageable, bakeryMenuResponseQueryResults.getTotal());
    }

antlr.MismatchedTokenException: expecting CLOSE, found ','

[ 잘된 예 ]

groupBy를 사용한 경우 아래와 같이 명시적으로 counting이 필요합니다. 즉, 먼저 fetch()를 통해 전체 결과를 뽑아낸 후 그 데이터들 중 필요한 부분만 응답으로 보내는 형식을 사용할 수 있습니다.

  • 이 경우, 데이터가 많이 존재한다면 불필요한 데이터를 많이 가져오게 되어 성능상 좋지 않습니다. 하기 방법이 아닌 방안이 있는지는 좀 더 고민이 필요합니다.
    public Page<BakeryMenuResponse> findBakeryMenuPageableByBakeryId(Long bakeryId, Pageable pageable) {
        // offset과 limit을 설정하지 않고 모든 결과를 list에 담음
        List<BakeryMenuResponse> bakeryMenuResponseList = jpaQueryFactory
                .select(Projections.fields(BakeryMenuResponse.class,
                        menuReviews.menus.id.as("menuId"),
                        ...
                        menuReviews.rating.avg().as("avgRating")))
                .from(menuReviews)
                .where(menuReviews.bakeries.id.eq(bakeryId))
                .groupBy(menuReviews.menus.id, menuReviews.menus.breadCategories.id, menuReviews.menus.breadCategories.name, menuReviews.menus.name, menuReviews.menus.price, menuReviews.menus.imgPath)
                .orderBy(menuReviews.id.count().desc(), menuReviews.rating.avg().desc())
                .fetch();

        List<BakeryMenuResponse> content = new ArrayList<>();

        // list의 size를 얻어 totalElements와 totalPages에 사용
        Integer total = bakeryMenuResponseList.size();
        // list의 offset부터 limit까지 가져와야 하기 때문에 하기와 같이 구현
        Integer limit = pageable.getPageSize() * (pageable.getPageNumber() + 1);

        // 기존에 list로 가져온 응답 중, 필요한 offset ~ limit까지의 데이터만 담음
        for (int i = pageable.getPageNumber(); i < limit && i < total; i ++) {
            content.add(bakeryMenuResponseList.get(i));
        }

        return new PageImpl<>(content, pageable, total);
    }

하기 postman을 통해 응답을 확인해보면, totalPagestotalElements를 확인할 수 있습니다. 현재 1번 page를 호출했고, limit은 1로 두었기 때문에 content는 1개가 나왔으며, bakery 1번에 해당하는 메뉴는 4개만 존재하여 totalPages = 메뉴개수/1, totalElements = 메뉴개수로 나온 것을 확인할 수 있습니다.

paging 결과 예시

Slice

Slice 사용 예시(무한스크롤/더보기)

Slice 방식의 경우, Page와 차이가 나는 부분은 total page 개수를 가져오지 않는다는 것입니다. 그저, 다음 데이터가 있는지 알기위해 처음에 데이터를 들고올 때, limit + 1까지 들고오고 해당 데이터가 있다면 다음 데이터가 있다는 사실만 전달해줍니다.

해당 내용은 하기 소스 코드의 주석을 통해 확인할 수 있습니다.

    public Slice<BakeryMenuResponse> findBakeryMenuPageableByBakeryId(Long bakeryId, Pageable pageable) {
        List<BakeryMenuResponse> bakeryMenuResponseList = jpaQueryFactory
                .select(Projections.fields(BakeryMenuResponse.class,
                        menuReviews.menus.id.as("menuId"),
                        ...
                        menuReviews.rating.avg().as("avgRating")))
                .from(menuReviews)
                .where(menuReviews.bakeries.id.eq(bakeryId))
                .groupBy(menuReviews.menus.id, menuReviews.menus.breadCategories.id, menuReviews.menus.breadCategories.name, menuReviews.menus.name, menuReviews.menus.price, menuReviews.menus.imgPath)
                .orderBy(menuReviews.id.count().desc(), menuReviews.rating.avg().desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize() + 1) // limit보다 데이터를 1개 더 들고와서, 해당 데이터가 있다면 hasNext 변수에 true를 넣어 알림
                .fetch();

        List<BakeryMenuResponse> content = new ArrayList<>();
        for (BakeryMenuResponse eachBakeryMenuResponse: bakeryMenuResponseList) {
            content.add(new BakeryMenuResponse(eachBakeryMenuResponse));

        }

        boolean hasNext = false;
        if (content.size() > pageable.getPageSize()) {
            content.remove(pageable.getPageSize());
            hasNext = true;
        }
        return new SliceImpl<>(content, pageable, hasNext);
    }

앞서 Paging 방식을 통해 postman 호출을 했을 때와 달리, firstlast가 존재하여 hasNexttrue이면 lastfalse로 뜨는 것을 확인해볼 수 있습니다.

마치며

필자의 경우, 앱 어플리케이션 프로젝트인터라 무한 스크롤 기능이 필요하여 Slice를 사용했습니다.

참고 자료

반응형