오프셋 기반 페이지네이션, 커서 기반 페이지네이션 이있다.Cursor라는 개념을 사용한다. Cursor란 응답을 준 데이터들줄 마지막 데이터의 식별자 값을 의미한다.
Cursor 기준으로 다음 데이터 부터 요청한 데이터 size만큼 응답을 해주는 방식이다.
쉽게 말하면
그러므로 어떤 페이지를 조회하든 항상 원하는 데이터 개수만 읽게 되어 성능상 이점이 존재한다.
특정한 offset 없이 페이징한다고 해서 No Offset 페이징네이션 이라고도 부른다.
이번 프로젝트에서 정렬을 "좋아순", "댓글순", "최신순" ,"오래된순", "조회순" 으로 설계되었다. 이러한 상황에서 커서기반페이지네이션을 구현하려고 한다. 하지만 약간의 문제가 있다.
"최신순"은 게시물 테이블에 게시물을 저장할때 PK가 증가할수록 최근등록된 게시물이기 때문에 응답한 마지막 게시물의 PK 를 기준으로 다음 게시물을 검색하면(
lt를 사용해서) 되기 때문에 문제가 없다. PK는 유니크 한 값이기 때문이다."댓글순", "좋아순", "조회순" 에 대해서는 단순히 위와 같이 마지막 응답 게시물 PK와 비교해서는 되지 않는다. 그렇기 때문에 응답의 마지막 게시물의 댓글수, 좋아요순, 조회순 을 Cursor로 잡고 해당 Cursor은 유니크 하지 않기 때문에(중복이 있을수있음) 게시물의 PK도 함께 사용하여 중복을 해결하였다.
//무한 스크롤를 위한 파라미터 세팅 함수
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))); 이 해당된다.//모든 유저에 대한 게시물 조회
@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에서 동적쿼리를 이용하는 방식에는BooleanBuiler와Where다중 파라미터 방식이 있다.
- 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절들어갈 조건들을 미리 생성할수 있다.builder을and,or로 원하는 연산자로 생성할수 있다.
- Where 다중 파라미터 사용
~~~~~ .where( LanguageEq(lang), TitleContains(title), builder //NoOffset 사용하기 ) ~~~~~~~일단 필요없는 코드들은 주석처리하였고
.where절 문안에와 같이,를 사용하여 각 조건들을and연산으로 사용할수 있다.