페이지네이션(Pagination)

구본식·2022년 9월 25일

1. 페이지네이션이란?

  • 전체 데이터중 지정된 수의 데이터만 전달하는 방법
  • 필요한 데이터의 수만 전달하므로 네트워크의 오버헤드를 줄일수 있다.
  • 구현방법에는 오프셋 기반 페이지네이션, 커서 기반 페이지네이션 이있다.

2. 오프셋 기반 페이지네이션

  • 추후 정리~~

3. 커서 기반 페이지네이션

  • Cursor라는 개념을 사용한다. Cursor란 응답을 준 데이터들줄 마지막 데이터의 식별자 값을 의미한다.

  • Cursor 기준으로 다음 데이터 부터 요청한 데이터 size만큼 응답을 해주는 방식이다.

  • 쉽게 말하면

    • 오프셋 기반 방식 : 5천번~5천 10번 데이터를 요청한다면 5천 10번까지 데이터를 모두 읽고 5 천번~5천 10번의 데이터를 응답해준다.
    • 커서 기반 방식 : 5천번~5천 10번 데이터를 요청한다면 5천번 부터 데이터를 읽고 5천 10번 데이터까지 응답해준다 -> 즉 10개만 읽게 된다.

그러므로 어떤 페이지를 조회하든 항상 원하는 데이터 개수만 읽게 되어 성능상 이점이 존재한다.
특정한 offset 없이 페이징한다고 해서 No Offset 페이징네이션 이라고도 부른다.

3.1 Cursor 기반 페이지네이션 구현

이번 프로젝트에서 정렬을 "좋아순", "댓글순", "최신순" ,"오래된순", "조회순" 으로 설계되었다. 이러한 상황에서 커서기반페이지네이션을 구현하려고 한다. 하지만 약간의 문제가 있다.

  • "최신순"은 게시물 테이블에 게시물을 저장할때 PK가 증가할수록 최근등록된 게시물이기 때문에 응답한 마지막 게시물의 PK 를 기준으로 다음 게시물을 검색하면(lt를 사용해서) 되기 때문에 문제가 없다. PK유니크 한 값이기 때문이다.

  • "댓글순", "좋아순", "조회순" 에 대해서는 단순히 위와 같이 마지막 응답 게시물 PK와 비교해서는 되지 않는다. 그렇기 때문에 응답의 마지막 게시물의 댓글수, 좋아요순, 조회순Cursor로 잡고 해당 Cursor은 유니크 하지 않기 때문에(중복이 있을수있음) 게시물의 PK도 함께 사용하여 중복을 해결하였다.

3.1.1 Service

    //무한 스크롤를 위한 파라미터 세팅 함수
    private NoOffsetPage NoOffsetPageNation(String orderKey, Long lastPostId, Integer lastLikeNum, Integer lastReplyNum, Long lastCountView) {

        BooleanBuilder builder = new BooleanBuilder();
        Pageable pageable = null;

        // 페이징, 정렬 기준 세팅하기
        if(StringUtils.hasText(orderKey)) {

            List<Sort.Order> orders = new ArrayList<>();
            //동적쿼리 where 문 생성
            if(orderKey.equals("latest")) { //최신순
                orders.add(Sort.Order.desc("redate"));
                if(lastPostId != null) {
                    builder.and(QPost.post.post_id.lt(lastPostId));
                }
            }else if(orderKey.equals("oldest")){ //오래된순
                orders.add(Sort.Order.asc("redate"));
                if(lastPostId !=null) {
                    builder.and(QPost.post.post_id.gt(lastPostId));
                }
            }
            else if(orderKey.equals("likeNum")) { //좋아요순
                orders.add(Sort.Order.desc(orderKey));
                orders.add(Sort.Order.asc("post_id")); //같은 likeNum에 대해서는 post_id로 오름차순 정렬 기준으로 정의
                if(lastLikeNum!=null && lastPostId!=null) {
                    builder.and(QPost.post.likeNum.eq(lastLikeNum).and(QPost.post.post_id.gt(lastPostId))); //같은 개수를 가진 게시물 처리
                    builder.or(QPost.post.likeNum.lt(lastLikeNum));
                }
            }
            else if(orderKey.equals("replyNum")) { //댓글순
                orders.add(Sort.Order.desc(orderKey));
                orders.add(Sort.Order.asc("post_id")); //같은 replyNum에 대해서는 post_id로 오름차순 정렬 기준으로 정의
                if(lastReplyNum!=null && lastPostId!=null) {
                    builder.and(QPost.post.replyNum.eq(lastReplyNum).and(QPost.post.post_id.gt(lastPostId)));
                    builder.or(QPost.post.replyNum.lt(lastReplyNum));
                }
            }
            else if(orderKey.equals("countView")) { //조회순
                orders.add(Sort.Order.desc(orderKey)); //내림차순 정렬
                orders.add(Sort.Order.asc("post_id")); //같은 likeNum에 대해서는 post_id로 오름차순 정렬 기준으로 정의
                if(lastCountView != null && lastPostId!=null) {
                    builder.and(QPost.post.countView.eq(lastCountView).and(QPost.post.post_id.gt(lastPostId)));
                    builder.or(QPost.post.countView.lt(lastCountView));
                }
            }

            pageable = PageRequest.of(0, 3, Sort.by(orders)); //10개 씩

        }else //구조상 이경우는 없음.
            pageable = PageRequest.of(0,3);

        return new NoOffsetPage(pageable, builder);
    }

여기서 봐야할 부분은 builder안에 들어가는 쿼리문이다.

  • 최근순(오래된 순)
    : 최근 순인 경우에는 단순하게 응답의 마지막 게시물 PK 보다 작은 거부터 검색하면 된다.

  • 좋아요순, 댓글순, 조회순
    : 최근순과 다르게 Cursor유니크하지 않기 때문에 마지막 응답의 게시물의 좋아요개수게시물PK를 사용해서 처리한다.

    • 마지막 게시물의 좋아요수와 그다음 게시물의 좋아요 수가 다르다면 Or(QPost.post.likeNum.lt(searchForm.getLastLikeNum()) 해당 쿼리문만으로 찾게 될것이다.
    • ❗마지막 게시물의 좋아요수와 다음 게시물의 좋아요 수가 같은 상황(중복이 발생하는 상황)에서는 같은 좋아요수를 가진 게시물중에서는 응답한 마지막 게시물 다음 게시물이 검색 대상이 되어야한다.!!! 이러한 기능을 처리하기 위한 부분은 builder.and(QPost.post.likeNum.eq(lastLikeNum).and(QPost.post.post_id.gt(lastPostId))); 이 해당된다.

3.1.2 Repository

//모든 유저에 대한 게시물 조회
    @Override
    public Slice<Post> findPostsByAllUser(String lang, String title, Pageable pageable, BooleanBuilder builder) {
        JPAQuery<Post> query = this.query.select(QPost.post)
                .from(QPost.post)
                .join(QPost.post.user, QUser.user)
                .fetchJoin() //"패치조인"으로 성능 최적화(user 쿼리문은 안나감-프록시 초기화할때)
                .where(
                        LanguageEq(lang),
                        TitleContains(title),
                        builder //NoOffset 사용하기
                );
        /**
         * 작성자 닉네임을 항상 가져와야 되기 때문에 "fetchJoin" 으로 "N+1" 문제 해결
         */

        //동적 정렬
        for(Sort.Order o : pageable.getSort()) {
            PathBuilder pathBuilder = new PathBuilder(QPost.post.getType(), QPost.post.getMetadata());
            query.orderBy(new OrderSpecifier(o.isAscending() ? Order.ASC: Order.DESC, pathBuilder.get(o.getProperty())));
        }

        List<Post> posts = query
                .limit(pageable.getPageSize() + 1)
                .fetch();
  
        return checkEndPage(pageable, posts);
    }
    
    private Slice<Post> checkEndPage(Pageable pageable, List<Post> results) {
        boolean hasNext = false;

        if(results.size() > pageable.getPageSize()) { //다음 게시물이 있는 경우
            hasNext = true;
            results.remove(pageable.getPageSize()); //한개더 가져왔으니 더 가져온 데이터 삭제
        }
        return new SliceImpl<>(results, pageable, hasNext);
    }
  • 첫 페이지를 요청하게 되다면(좋아요순,최신순,댓글순등 상관없이) 이전에 응답한 게시물이 없기 때문에 게시물의 Id,좋아요수,댓글수가 없기 때문에 정렬기준으로 지정한 개수의 게시물들이 전달될 것이다. 또한 다음 게시물이 존재하기 때문에 존재 여부 또한 포함하여 응답해준다.

  • 중간 페이지는 "좋아요 순"에 대해서는 마지막 게시물 Id 값을 사용해서 No Offset방식으로 해당 게시물 Id 다음 게시물부터 지정한 개수들을 응답해준다.
    "댓글순", "좋아요순"에 대해서는 각 개수를 사용해서 중복값에 대해서는 게시물 Id로 해결하여 마지막 게시물 다음 게시물부터 지정한 개수를 응답해주게 된다.
    또한 다음 게시물이 존재하기 때문에 존재 여부 또한 포함하여 응답해준다.

  • 마지막 페이지는 다음 존재하는 게시물이 없기 때문에 존재하지 않다는 것 또한 응답에 포함시킨다.

💡참고
QueryDSL 에서 동적쿼리를 이용하는 방식에는 BooleanBuilerWhere 다중 파라미터 방식이 있다.

  • BooleanBuilder 사용
BooleanBuilder builder = new BooleanBuilder();
     if(searchForm.getOrderKey().equals("redate")) {
         if(searchForm.getLastPostId() != null) {
             builder.and(QPost.post.post_id.lt(searchForm.getLastPostId()));
      }
}

builder.and(QPost.post.post_id.lt(searchForm.getLastPostId())) 처럼 Where절 들어갈 조건들을 미리 생성할수 있다. builderand, or로 원하는 연산자로 생성할수 있다.

  • Where 다중 파라미터 사용
	~~~~~
    .where(
         LanguageEq(lang),
         TitleContains(title),
         builder //NoOffset 사용하기
      )
   ~~~~~~~

일단 필요없는 코드들은 주석처리하였고 .where절 문안에와 같이 ,를 사용하여 각 조건들을 and연산으로 사용할수 있다.

profile
백엔드 개발자를 꿈꾸며 기록중💻

0개의 댓글