새소식

DB

[DB] QueryDSL을 활용하여 SNS 피드 만들기(2) - 더 정확한 페이징 쿼리 만들기 offset based, cursor based

  • -

전편 보러가기 - QueryDSL을 활용하여 SNS 피드 만들기(1) - 동적 쿼리로 pagination 구현하기

 

이번에는 전편에 이어서, 페이징을 구현하기 위한 쿼리를 작성해보겠습니다.

 

서비스 특성상 특별하게 '피드에 정렬 옵션이 있다' 라는 조건 때문에 조금 복잡합니다.

그리고 이런 이유 때문에 cursor based 를 사용해도 정확도가 조금 떨어지며, 조금이나마 이 문제를 해결해봅시다.

 

Offset Based Pagination

JPA 에서는 offset paging을 pageable이라는 객체를 사용하여 편리하게 지원합니다.

또한 Spring Data JPA 에서는 인터페이스 만으로도 페이징을 사용할 수 있습니다.

 

QueryDSL과 함게 사용하려면  다음과 같이 코드를 작성합니다.

.offset(pageable.getOffset())
.limit(pageable.getPageSize())

 

 

 그러나 offset paging에는 다음과 같은 문제점이 있습니다.

  • 조회 되었던 게시글의 삭제로 인한 데이터 누락
  • 새로운 게시글이 올라오면 데이터 중복

 

Cursor Based Pagination

커서 페이징은 오프셋 페이징의 문제점을 어느정도 해결합니다.

 

 

private JPQLQuery<FeedResultPostDto> getBaseQuery(SearchFeedConditionDto searchFeedConditionDto, Post cursorPost, User me) {
	return queryFactory
        .select(
        		~
                ~
                ~
                ))
        .from(post)
        .where(
                ~
                ~
                cursorPagination(cursorPost, searchFeedConditionDto.getSortStrategy()),
                sameLevelCursorFilter(cursorPost, searchFeedConditionDto.getSortStrategy())
        )
        .limit(searchFeedConditionDto.getSize());
}

쿼리를 위와 같이 작성합니다.

  • 몇 개의 content를 가져올지 size를 limit에 넣어 줍니다.
  • cursorPagination : 정렬 조건에 따라, 이미 조회된 포스트를 제외합니다.
  • sameLevelCursorFilter : 정렬 조건이 있다면, pk값을 통해 정확성을 높입니다.

 

세부적인 동적 쿼리를 살펴보겠습니다.

 

cursorPagination

private Predicate cursorPagination(Post cursorPost, SortStrategy sortStrategy) {

    if(cursorPost == null || sortStrategy == null) {
        return null;
    }

    switch (sortStrategy) {
        case LIKE:
            return post.postLikeList.size().loe(cursorPost.getPostLikeList().size());
        case VIEW:
            return post.viewCount.loe(cursorPost.getViewCount());
        case CHRONOLOGICAL:
        default:
            return post.id.lt(cursorPost.getId());
    }
}

서비스 특성상 특별하게 피드에 정렬이 있기 때문에 조금 복잡합니다.

정렬 조건이 있다면 해당 조건보다 크거나 같은 경우는 제외합니다.

 

여기에서 크거나 같은 것을 제외하는 이유는, 조회수나 좋아요수가 동일한 게시글이 존재한다면, pk가 높은 게시글만 조회되기 때문입니다.

이러한 데이터 누락을 위해서 크거나 '같은' 이라는 조건을 넣었고, 다음 sameLevelCursorFilter에서 처리합니다.

 

 

 

sameLevelCursorFilter

private Predicate sameLevelCursorFilter(Post cursorPost, SortStrategy sortStrategy) {

    if(cursorPost == null || sortStrategy == null) {
        return null;
    }

    switch (sortStrategy) {
        case LIKE:
            return ExpressionUtils.or(post.postLikeList.size().ne(cursorPost.getPostLikeList().size()), post.id.lt(cursorPost.getId()));
        case VIEW:
            return ExpressionUtils.or(post.viewCount.ne(cursorPost.getViewCount()), post.id.lt(cursorPost.getId()));
        case CHRONOLOGICAL:
        default:
            return null;
    }
}

조회수, 좋아요수가 같은 게시글들이 있다면 어떻게 처리할까요?

 

앞의 cursoPagination에서 level이 같은 게시글들을 남겨두었습니다.

그래서 여기에서는 pk값을 기준으로 중복될 게시글을 제거합니다.

 

동일 level에서는, cursor 게시글의 pk보다 큰 것들을 걸러내서 중복된 데이터를 처리합니다 

 

이렇게 커서 페이징에서 중복이 될 가능성을 없애보았습니다.

 

QueryDSL로 구현한 정렬 조건이 있는 피드의 한계점

1. 중복은 피할 수 있으나, 누락은 있을 수 있습니다.

만약 커서가 likeCount = 5 에 위치하고 있는데, 누군가가 좋아요가 5개였던 게시글의 좋아요를 취소한다면 그 게시글은 나타나지 않습니다. 즉 데이터가 감소되는 일이 있다면, 종종 이런 경우가 발생할 것입니다.

 

그렇지만 '중복'이 아닌 이상 사용자 경험에서 크게 마이너스 되는 부분은 아니라고 생각되고, facebook이나 instagram도 종종 중복되는 현상이 나타나는데, 아마 성능문제 등의 Trade-off 가 아닐까 생각됩니다.

 

2. 일반적으로 엔티티의 양방향 관계를 설정하는 것은 기피됩니다.

그렇다고 했을때 위의 쿼리를 풀어서 작성한다면, 엄청 복잡할 것입니다. 아마 이런 경우에는 native query로 접근하는 것이 더 좋을지도 모르겠네요.

 

 

 

 

잘 적용된 모습을 확인할 수 있습니다.

감사합니다 :)

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.