Cursor-based Pagination

애이용·2021년 9월 1일
2

springboot

목록 보기
18/20

Pagination

한정된 네트워크 자원을 효율적으로 활용하기 위해 특정한 정렬 기준에 따라 데이터를 분할하여 가져오는 것이다.

즉, 데이터베이스에 만개의 데이터가 있을 때, 한번에 만 개를 전달하는 대신 0번부터 49번까지 50개씩 전달하는 것을 의미한다. 여기서 다음 요청이 들어오면 50번부터 99번까지, 또 다음 요청이 들어오면 100번부터 149번까지 돌려준다. 이렇게 함으로써 네트워크의 낭비를 막고, 빠른 응답을 기대할 수 있게 된다.

  1. 오프셋 기반 페이지네이션 (Offset-based Pagination)
    DB의 offset 쿼리를 사용하여 '페이지' 단위로 구분하여 요청/응답
  2. 커서 기반 페이지네이션 (Cursor-based Pagination)
    Cursor 개념을 사용하여 사용자에게 응답해준 마지막 데이터 기준으로 다음 n개 요청/응답

1. Offset-based Pagination

MySQL 에서라면 간단하게 OFFSET 쿼리와 LIMIT 쿼리에 콤마를 붙여 '건너 뛸' row 숫자를 지정하여 페이지네이션을 구현한다. 즉, 페이지 단위로 구분한다.

SELECT * FROM review limit [페이지사이즈] offset [페이지번호];
SELECT * FROM review ORDER BY review_id LIMIT [페이지번호], [페이지사이즈];

LIMIT 절 앞에 붙은 숫자가 바로 건너 뛸 개수(offset)이다.
([페이지번호]개 데이터 다음부터 [페이지사이즈]개 가져옴)

Offset-based Pagination엔 2가지 문제점이 있다.

  1. 페이지 요청 사이 데이터 변화가 있는 경우 중복 데이터 발생

전통적인 페이지네이션은 오랜 기간 잘 작동해왔다. 문제는 페이스북이나 인스타그램과 같은 잦은 수정, 생성, 삭제가 반복되는 서비스에서는 더 이상 효율적으로 작동하지 못하게 되었다.

예를 들어, 1페이지에서 20개의 row를 불러와서 유저에게 1페이지를 띄워주었다.
고객이 1페이지의 상품들을 보고 있는 사이, 상품 운영팀에서 5개의 상품을 새로 올렸다면?

유저가 1페이지 상품을 다 둘러보고 2페이지를 눌렀을때 1페이지에서 보았던 상품 20개 중 마지막 5개를 다시 2페이지에서 만나게 된다. (등록일 기준 내림차순이므로)

반대로 5개 상품을 삭제했다면 2페이지로 넘어갔을때 고객은 5개의 상품을 보지 못하게 된다.

참고 https://www.eversql.com/faster-pagination-in-mysql-why-order-by-with-limit-and-offset-is-slow/

  1. 대부분의 RDBMS에서 OFFSET 쿼리의 퍼포먼스 이슈

극단적으로 10억번째 페이지에 있는 값을 찾고 싶다면 OFFSET 또는 skip에 매우 큰 숫자가 들어가게 된다.
즉, 정렬기준(order by)에 대해 해당 row가 몇 번째 순서인지 알지 못하므로 OFFSET 값을 지정하여 쿼리를 한다고 했을 때 지정된 OFFSET까지 모두 만들어 놓은 후 지정된 갯수를 순회하여 자르는 방식이다. 때문에 퍼포먼스는 이에 비례하여 떨어지게 되어 있다.

기존에 사용하는 페이징 쿼리

SELECT *
FROM items
WHERE 조건문
ORDER BY id DESC
OFFSET 페이지번호
LIMIT 페이지사이즈

최신 👉 과거 순으로 조회한다는 가정하에 DESC가 붙었다.
이와 같은 형태의 페이징 쿼리가 뒤로 갈수록 느린 이유는 앞에 읽었던 행을 다시 읽어야 하기 때문이다.

예를 들어 offset 10000, limit 20 이라 하면 최종적으로 10,020개의 행을 읽어야 한다.
(10,000부터 20개를 읽어야 함)
그리고 이 중 앞의 10,000 개 행을 버린다.

즉 뒤로 갈수록 버리지만 읽어야 할 행의 개수가 많아져서 뒤로 갈수록 페이징 쿼리가 느려진다.

2. Cursor-based Pagination

Cursor-based Pagination은 그 부분에서 조회 시작 부분을 인덱스로 빠르게 찾아 매번 첫 페이지만 읽도록 하는 방식이다. (클러스터 인덱스인 PK를 조회 시작 부분 조건문으로 사용했기 때문에 빠르게 조회된다.)

SELECT *
FROM items
WHERE 조건문
AND id < 마지막조회ID # 직전 조회 결과의 마지막 id
ORDER BY id DESC
LIMIT 페이지사이즈

Offset 기반 페이지네이션은 우리가 원하는 데이터가 ‘몇 번째’에 있다는 데에 집중하고 있다면, 커서 기반 페이지네이션은 우리가 원하는 데이터가 '어떤 데이터의 다음'에 있다는데에 집중한다.

이전에 조회된 결과룰 한번에 건너뛸 수 있게 마지막 조회 결과의 id를 조건문에 사용하는 것으로, 매번 이전 페이지 전체를 건너뛸 수 있다.

즉, 아무리 페이지가 뒤로 가더라도 처음 페이지를 읽은 것과 동일한 성능을 가지게 된다.

구현 코드

    public List<Problem> findAllByTag(String tagName, Long problemId, Pageable page){
        // id < 파라미터를 첫 페이지에선 사용하지 않기 위한 동적 쿼리
        BooleanBuilder dynamicLtId = new BooleanBuilder();

        if (problemId.equals(0L)) {
            dynamicLtId.and(problem.id.lt(problemId));
        }

        return queryFactory
                .selectFrom(problem)
                .join(problemTag).on(problemTag.problem.eq(problem))
                .where(dynamicLtId.and(problemTag.tag.tagName.eq(tagName)))
                .orderBy(problem.id.desc())
                .limit(page.getPageSize())
                .fetch();
    }

첫 페이지를 조회할 때와 두 번째 페이지부터 조회할 때 사용되는 쿼리가 달라서 동적 쿼리가 필요하다.
(첫 페이지를 조회할 때는 기준이 되는 id를 알 수 없다.)

        BooleanBuilder dynamicLtId = new BooleanBuilder();

        if (problemId != 0L) {
            dynamicLtId.and(problem.id.lt(cursorId));
        }
  • 첫 페이지를 조회할 때는 problem.id.lt(cursorId)가 조건문에 없어야 한다.
  • 두 번째 페이지부터 조회할 때는 problem.id.lt(cursorId)가 조건문에 들어가야 한다.

좀 더 가독성 있게 코드를 작성해보자.

    public List<Problem> findAllByTag(String tagName, Long problemId, Pageable page){
        return queryFactory
                .selectFrom(problem)
                .join(problemTag).on(problemTag.problem.eq(problem))
                .where(
                    ltProblemId(problemId),
                        problemTag.tag.tagName.eq(tagName)
                )
                .orderBy(problem.id.desc())
                .limit(page.getPageSize())
                .fetch();
    }

    private BooleanExpression ltProblemId(Long problemId) {
        // id < 파라미터를 첫 페이지에선 사용하지 않기 위한 동적 쿼리
        if (problemId.equals(0L)) {
            return null; // // BooleanExpression 자리에 null 이 반환되면 조건문에서 자동으로 제거
        }
        return problem.id.lt(problemId);
    }

정리

  • 동일 레코드 중복 노출, 데이터의 빈번한 C/U/D 가 없는 리스트의 페이지네이션은 오프셋 기반으로 구현해도 좋다.
  • 그외 거의 모든 리스트는 커서 기반 페이지네이션을 사용하는 것이 무조건적으로 좋다.
  • 서버의 쿼리 퍼포먼스 / 클라이언트의 사용 편의를 위해서 커서로 사용할 값을 별도로 정의하고, 이 값을 활용한 WHERE / LIMIT 으로 커서 기반 페이지네이션을 구현할 수 있다.
  • 이렇게 구현하는 경우 각 정렬 방식마다 cursor 값과 정렬할 필드, ASC / DESC를 지정함으로써 쿼리 생성을 깔끔하게 할 수 있다.

참고 링크

profile
로그를 남기자 〰️

0개의 댓글