[drf] Cursor pagination

최동혁·2023년 5월 9일
0

DRF

목록 보기
12/19
post-thumbnail

커서란?

  • drf에서 제공해주는 cursor pagination을 사용하기에 앞서 커서라는 개념부터 알고가야 한다.

  • 커서 (Cursor)는 컴퓨터로 문서를 작성해 본 사람이라면 누구나 알고 있는 용어이다. 화면에서 현재 사용자의 위치를 나타내며 깜빡거리는 막대기가 바로 커서이다.

  • 데이터베이스에서의 커서 또한 유사한 개념이다. 방대한 양의 데이터에서 특정 위치, 특정 로우(row)를 가리킬때 커서가 사용된다. 위키피디아에서는 커서에 대해 '많은 로우 중 한 로우를 가리키는 포인터와 같다'고 설명하고 있다.

  • 즉 커서란 현재 작업중인 레코드를 가리키는 오브젝트이다.

왜?

  • 왜 커서를 쓸까?

  • 우리가 흔히 테스트를 할 때에는 데이터가 많아봤자 100개를 넘어가지 않는다.

  • 만약 cursor를 사용하지 않고 목록을 불러오는 api를 호출한다면 100개의 데이터를 한번에 불러오는 쿼리문을 실행할 것이다.

  • 100개정도 되는 선에서는 무리가 없다. 하지만 그 이상의 대용량 데이터를 불러오게 되면 서버 자원을 갉아먹는 원인이 된다.

  • 그래서 우리는 이 많은 데이터들을 페이지 별로 나눈 후, 사용자가 특정 페이지를 볼 때 그 페이지에 해당하는 데이터들만 불러오게 해주는데 가장 큰 역할을 하는것이 커서이다.

  • 여기서 커서는 이전 페이지의 마지막 데이터를 추적하고, 다음 페이지를 검색할 때 사용한다.

  • drf에서 제공하는 pagination 기법은 3개가 있다.

    • PageNumberPagination
      • 페이지 번호를 기반으로 한 페이지네이션을 제공한다.
      • 'page_size'와 'page_query_param'을 통해 페이지 크기와 페이지 번호를 지정할 수 있다.
      • 'page_size_query_param'을 통해 클라이언트가 페이지 크기를 지정할 수 있도록 할 수 있다.
      • 'max_page_size'를 설정하여 한 페이지당 최대 항목 수를 제한할 수 있다.
    • LimitOffsetPagination
      • 'limit'과 'offset'을 기반으로 한 페이지네이션을 제공한다.
      • 'default_limit'과 'limit_query_param'을 통해 페이지 크기와 limit 값을 지정할 수 있다.
      • 'offset_query_param'을 통해 offset 값을 지정할 수 있다.
      • 페이지의 순서는 정렬을 통해 변경할 수 있다.
    • CursorPagination
      • 커서를 기반으로 한 페이지네이션을 제공한다.
      • 커서는 결과 집합에서 이전 페이지와 다음 페이지를 나누는 데 사용된다.
      • 'ordering'을 통해 정렬 순서를 지정할 수 있다. (default는 created_at)
      • 커서를 사용하여 페이지를 이동할 수 있다.
      • 페이지간 이동이 빠르고 적은 자원을 사용한다.페이지간 이동이 빠르고 적은 자원을 사용한다.
  • 이 중 커서기반은 pagination 성능이 향상되고 서버 리소스 사용량이 최소화되는 장점이 있다.

사용법

settings.py

REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.CursorPagination',
    'PAGE_SIZE': 10,
}
  • pagination class를 cursor 기반으로 사용한다고 명시
  • page_size는 자신이 수정하기 나름.
  • 한 페이지의 들어가는 데이터의 갯수를 뜻함.

views.py

from rest_framework import status, pagination

class ProductPagination(pagination.CursorPagination):
    page_size = 10
    ordering = "-created_at"
    cursor_query_param = "cursor"

    def get_paginated_response(self, data):
        if self.get_previous_link() is None:
            return Response(
                {
                    "meta": {"code": 301, "message": "OK"},
                    "data": {
                        "next": self.get_next_link(),
                        "previous": self.get_previous_link(),
                        "products": data,
                    },
                },
                status=status.HTTP_301_MOVED_PERMANENTLY,
            )
        else:
            return Response(
                {
                    "meta": {"code": 200, "message": "OK"},
                    "data": {
                        "next": self.get_next_link(),
                        "previous": self.get_previous_link(),
                        "products": data,
                    },
                },
                status=status.HTTP_200_OK,
            )
  • 하나의 예시인데, settings.py에서 설정한 page_size는 default이다.
  • 그렇기에 위의 코드처럼 page_size를 명시하지 않으면 settings.py에서 설정한대로 적용된다.
  • 물론 ordering도 deafult는 created_at이지만, 헷갈리기 쉬우니깐 명시해준다.
  • cursor_query_param 또한 default는 cursor이다. 이건 요청을 받았을때, url의 쿼리 파라미터를 ?cursor= 이런식으로 쓴다고 명시를 해준것이다.
  • front와 협의를 해서 쿼리 파라미터를 무엇으로 쓸지 정한 후 커스텀 하면된다.

pagination.CursorPagination

  • 위의 views.py에서 상속한 클래스이다.
  • CursorPagination을 타고 들어가보면
class CursorPagination(BasePagination):
    """
    The cursor pagination implementation is necessarily complex.
    For an overview of the position/offset style we use, see this post:
    https://cra.mr/2011/03/08/building-cursors-for-the-disqus-api
    """
    cursor_query_param = 'cursor'
    cursor_query_description = _('The pagination cursor value.')
    page_size = api_settings.PAGE_SIZE
    invalid_cursor_message = _('Invalid cursor')
    ordering = '-created'
    template = 'rest_framework/pagination/previous_and_next.html'
    
    def paginate_queryset(self, queryset, request, view=None):
        self.page_size = self.get_page_size(request)
        if not self.page_size:
            return None

        self.base_url = request.build_absolute_uri()
        self.ordering = self.get_ordering(request, queryset, view)

        self.cursor = self.decode_cursor(request)
        if self.cursor is None:
            (offset, reverse, current_position) = (0, False, None)
        else:
            (offset, reverse, current_position) = self.cursor

        # Cursor pagination always enforces an ordering.
        if reverse:
            queryset = queryset.order_by(*_reverse_ordering(self.ordering))
        else:
            queryset = queryset.order_by(*self.ordering)

        # If we have a cursor with a fixed position then filter by that.
        if current_position is not None:
            order = self.ordering[0]
            is_reversed = order.startswith('-')
            order_attr = order.lstrip('-')

            # Test for: (cursor reversed) XOR (queryset reversed)
            if self.cursor.reverse != is_reversed:
                kwargs = {order_attr + '__lt': current_position}
            else:
                kwargs = {order_attr + '__gt': current_position}

            queryset = queryset.filter(**kwargs)

        # If we have an offset cursor then offset the entire page by that amount.
        # We also always fetch an extra item in order to determine if there is a
        # page following on from this one.
        results = list(queryset[offset:offset + self.page_size + 1])
        self.page = list(results[:self.page_size])

        # Determine the position of the final item following the page.
        if len(results) > len(self.page):
            has_following_position = True
            following_position = self._get_position_from_instance(results[-1], self.ordering)
        else:
            has_following_position = False
            following_position = None

        if reverse:
            # If we have a reverse queryset, then the query ordering was in reverse
            # so we need to reverse the items again before returning them to the user.
            self.page = list(reversed(self.page))

            # Determine next and previous positions for reverse cursors.
            self.has_next = (current_position is not None) or (offset > 0)
            self.has_previous = has_following_position
            if self.has_next:
                self.next_position = current_position
            if self.has_previous:
                self.previous_position = following_position
        else:
            # Determine next and previous positions for forward cursors.
            self.has_next = has_following_position
            self.has_previous = (current_position is not None) or (offset > 0)
            if self.has_next:
                self.next_position = following_position
            if self.has_previous:
                self.previous_position = current_position

        # Display page controls in the browsable API if there is more
        # than one page.
        if (self.has_previous or self.has_next) and self.template is not None:
            self.display_page_controls = True

        return self.page
    .......
  • 이런식으로 정의되어 있다.
  • 위에서 말한대로 각 필드 별로 default값이 정해져있다.
  • queryset을 파라미터로 받아서, 위의 설정대로 필터링 해준 후, page_size 만큼 list로 만들어서 return을 해준다.
  • 위에서 말했듯이 한 페이지에 해당하는 쿼리문만 호출하기 때문에, 다음 페이지에 있는 것은 url로 주어지게 된다.
  • 그 url은 위에서 설정한대로 쿼리 파라미터를 cursor로 하는 ?cursor= 형식으로 오고, 해당 url에 get 요청을 하게 되면 또 다시 그 size에 맞게 쿼리문을 호출하게 된다.

요청 결과 (get_paginated_response)

  • 요청한 결과를 custom 하고 싶다면 get_paginated_response를 오버라이딩 하면 된다.
    def get_paginated_response(self, data):
        return Response(OrderedDict([
            ('next', self.get_next_link()),
            ('previous', self.get_previous_link()),
            ('results', data)
        ]))
  • get_next_link(), get_previous_link()는 이전 페이지에 대한 link와 다음 페이지에 대한 link를 return 해주는 method이다.
  • 위의 함수를 custom 하지 않고 그대로 사용했을때
{
  "next" : "http://127.0.0.1:8000/products/?cursor=cD0yMDIzLTA1LTA5KzEyJTNBMzklM0EzNi45NzY3MzAlMkIwMCUzQTAw",
  "previous" : null,
  "results" : []
}
  • 이런식으로 나온다.
profile
항상 성장하는 개발자 최동혁입니다.

0개의 댓글