[Spring Boot] QueryDSL 커서 기반 페이지네이션 구현해보기

오진서·2022년 8월 8일
4

offset vs cursor

페이지네이션의 구현 방법에는 크게 두 가지가 있는데 바로 offset과 cursor 방식입니다.

먼저 offset 기반과 cursor 기반 페이지네이션의 차이점을 알아보겠습니다.

아래와 같은 게시물이 5개이고, 한 페이지당 2개의 게시글을 나타낸다 가정해보겠습니다.

boards = [1, 2, 3, 4, 5]


1. offset-based pagination

offset은 limit과 건너뛸 개수(offset)를 사용하여 페이지를 매기는 방식입니다.

예를 들어 offset 방식으로 페이징 처리를 해보면,

page0 = boards.offset(0).limit(2) // [1, 2] --- 0번 페이지

page1 = boards.offset(2).limit(2) // [3, 4] --- 1번 페이지

page2 = boards.offset(4).limit(2) // [5] --- 2번 페이지

로 나타낼 수 있습니다.

정리해보면, offset에는 현재 페이지 x 페이지에 나타낼 게시글 수가, limit에는 페이지에 나타낼 게시글 수가 담기게 됩니다.

offset 방식은 보시다시피 구현이 간단합니다. 또한, offset 값만 달리하면, 모든 페이지를 마음대로 이동할 수 있는 장점이 있습니다. 하지만 두 가지 문제가 존재합니다.

offset 방식의 문제점

1) RDBMS를 사용 중이고 수천만 개의 레코드가 있는 경우, 높은 offset을 질의 하게 되면 해당 페이지까지 모든 레코드를 로드해야 되며, 성능은 이에 비례하여 떨어지게 됩니다.

2) 페이지를 요청하는 도중에 데이터의 변화가 있으면 중복 데이터가 발생할 수 있습니다.
아까와 같은 상황에서 예시를 들어보겠습니다.

page0 = boards.offset(0).limit(2) // [1, 2] --- 0번 페이지

page1 = boards.offset(2).limit(2) // [3, 4] --- 1번 페이지
**
여기서 0번 게시글이 추가되면,
boards = [0, 1, 2, 3, 4, 5] 가 됩니다. 여기서 2번 페이지를 조회해보겠습니다.
**
page2 = boards.offset(4).limit(2) // [4, 5] --- 2번 페이지 (4번 게시글 중복)


2. cursor-based pagination

offset 방식은 우리가 원하는 데이터가 몇 번째에 있느냐를 집중한다면, cursor 방식은 우리가 원하는 데이터가 어떤 데이터의 다음에 있다는 데에 집중합니다.

다시 말해, cursor 방식은 마지막으로 읽은 데이터(cursor) 다음으로 올 데이터만 들고오는 데에만 집중하므로 offset과 달리 이전/다음 페이지로만 이동할 수 있습니다.

그럼 예를 들어보겠습니다.

offset 방식에서는 클라이언트로부터 건너뛸 개수(offset)를 받아와야 했다면, cursor 방식에서는 이전 페이지에서의 마지막 게시글 id(cursor)를 받아와야 합니다.

그리고 다음 페이지에서는 cursor보다 id 값이 큰 게시글 2개를 가져오면 될 것입니다.

page0 = boards.cursor(null).limit(2) // [1, 2] --- 첫 페이지는 그냥 limit으로 2개 짤라줍니다.

page1 = boards.cursor(>2번 게시글 id).limit(2) // [3, 4]

page2 = boards.cursor(>4번 게시글 id).limit(2) // [5]

이렇듯 cursor 기반은 offset처럼 모든 데이터를 로드할 필요도 없으며, 데이터가 중간에 변경되어 중복될 일도 발생하지 않습니다. 하지만 offset처럼 페이지를 자유롭게 이동 못 한다는 제약이 존재합니다.

이러한 특징 때문에 cursor 방식은 1, 2, 3... 페이지를 클릭하여 컨텐츠를 보는 것이 아닌, 페이스북이나 유튜브처럼 스크롤을 내리면 컨텐츠가 보이는 무한 스크롤을 이용한 페이징 방식에 적합합니다.



querydsl로 cursor 기반 페이지네이션 구현해보기

JpaRepository에서 querydsl을 사용할 수 있도록 Custom 인터페이스 + Impl 클래스(구현체)를 구현해보겠습니다.

1. CustomRepository 인터페이스 정의

public interface BoardRepositoryCustom {
    Slice<BoardDto> findAllByCondition(Long cursorId
    , BoardReadCondition boardReadCondition, Pageable pageable);
}
  • 구현할 메소드를 정의해줍니다.

  • 인자에는 사용자에게 응답한 페이지의 마지막 게시글 id인 cursorId, 검색 및 필터링 조건이 담긴 condition 클래스, 페이지에 불러올 게시글의 수가 담긴 pageable 인터페이스가 전달됩니다.



2. Custom 인터페이스 상속

public interface BoardRepository extends JpaRepository<Board, Long>, BoardRepositoryCustom{
}
  • BoardRepository에서 querydsl 기능을 사용할 수 있도록 Custom 인터페이스를 상속받도록 해줍니다.


3. Impl 클래스 구현

@RequiredArgsConstructor
public class BoardRepositoryImpl implements BoardRepositoryCustom{
    private final JPAQueryFactory jpaQueryFactory;

    @Override
    public Slice<BoardDto> findAllByCondition(Long cursorId
    , BoardReadCondition condition
    , Pageable pageable) {
        QBoard board = QBoard.board; // (1)
        QProjectPost projectPost = QProjectPost.projectPost;
        QStudyBoard studyBoard = QStudyBoard.studyBoard;
        QProjectOrganization organization = QProjectOrganization.projectOrganization;
        QStudyTechStack studyTechStack = QStudyTechStack.studyTechStack;

        List<Board> result = jpaQueryFactory
                .selectFrom(board) // (2)
                .leftJoin(projectPost).on(projectPost.eq(board))
                .leftJoin(studyBoard).on(studyBoard.eq(board))
                .leftJoin(studyBoard.studyTechStacks, studyTechStack)
                .leftJoin(projectPost.organizations, organization)
                .distinct()
                .where(eqType(condition.getDType()), eqCity(condition.getCity())
                , eqCursorId(cursorId) ,eqTechIds(condition.getTechIds()) // (3)
                , eqCareerStatus(condition.getCareerStatus()))
                .limit(pageable.getPageSize() + 1) // (4)
                .fetch();

        List<BoardDto> content = result.stream().map(i -> { // (5)
            if (i instanceof ProjectPost) { 
                return new BoardProjectDto((ProjectPost) i);
            } else {
                return new BoardStudyDto((StudyBoard) i);
            }
        }).collect(Collectors.toList());

        boolean hasNext = false;
        if (content.size() > pageable.getPageSize()) { // (6)
            content.remove(pageable.getPageSize());
            hasNext = true;
        }

        return new SliceImpl<>(content, pageable, hasNext);
    }
    
    ...
    
    private BooleanExpression eqCursorId(Long cursorId) { // (7)
        if (cursorId != null) {
            return board.id.gt(cursorId);
        }
        return null;
    }

(1) 쿼리 조건들을 설정하기 위해 Q-type Class들을 정의해주었습니다.

(2) board에 연관된 테이블들을 left outer join으로 묶어주고, distinct()를 통해 중복 레코드를 제거해주었습니다.

(3) where 조건절에 cursorId 값을 넘겨주는 부분(eqCursorId)이 cursor 페이징의 핵심입니다.

(4), (6) limit 값으로 원래 불러올 게시글 개수보다 1개 더 불러와 다음에 호출할 게시글이 있는지를 판단할 수 있습니다. Slice 방식의 경우, Page와 달리 총 데이터 개수를 가져올 필요가 없으며 그저 다음 데이터가 있는지에 대한 사실만 전달해주면 되므로 hasNext 변수를 설정해주었습니다.

(5) 상속 관계로 구성된 엔티티 집합의 결과를 각 타입에 맞는 DTO로 변환해주는 코드입니다.

(7) gt()는 querydsl에서 제공해주는 부등호 메서드이며 인자로 들어온 cursorId값보다 크다면 참을 반환해줍니다. 만약 첫 번째 페이지에 접근한다면, cursorId 값은 null이므로 해당 where절은 실행되지 않습니다. 그리고 n번째 페이지에 접근한다면 n-1번째 페이지의 마지막 게시글 id를 클라이언트로부터 받게 되고, 그 이후의 게시글 id가 참이 되어 where 절에 추가될 것입니다.


cursor-based-pagination 한계

지금까지는 순차적이고 유니크한 게시글 id(pk)를 cursor로 사용하여 구현에 별문제가 없었습니다.
하지만 만약 특정 컬럼을 기준으로 정렬을 하여 게시글 Id 값이 순차적이지 않거나, cursor 값에 중복된 값이 존재한다면? where 조건절 구현이 까다로워집니다.

즉, cursor 값은 순차적이어야 하며, 정렬에 포함되는 하나 이상의 필드는 unique해야 합니다.

offset 방식 같은 경우, 스킵할 데이터 수와 limit 값만 주어진다면 어떤 쿼리를 수행해도 페이징을 구현할 수 있겠지만.. cursor 방식은 특히 정렬 부분에서 큰 제약이 따라옵니다.

데이터 수가 그리 많지 않고, 실시간 데이터 처리가 필요 없다면 offset 방식을 사용하는 것도 고려해볼만 하다고 생각합니다.




참고

https://jojoldu.tistory.com/372
querydsl 적용하는 데에 많은 도움이 되었습니다.

https://velog.io/@znftm97/%EC%BB%A4%EC%84%9C-%EA%B8%B0%EB%B0%98-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98Cursor-based-Pagination%EC%9D%B4%EB%9E%80-Querydsl%EB%A1%9C-%EA%B5%AC%ED%98%84%EA%B9%8C%EC%A7%80-so3v8mi2
커서 기반 페이지네이션 구현하는 부분에서 참고하였습니다.

profile
안녕하세요

0개의 댓글