티스토리 뷰

한꺼번에 특정 모델 객체의 데이터를 전부 가져오기보단 일정 데이터만 먼저 가져오고

사용자의 요청이 도착했을 때 다음 데이터를 가져오는 방식인 페이지네이션을 직접 구현하려면 어렵습니다.

( 예시   --> 네이버 증권 (S&P500 종합))

(사이트에 들어가서 스크롤을 내려보세요 👀)

 

S&P 500 - 네이버 증권

관심종목의 실시간 주가를 가장 빠르게 확인하는 곳

m.stock.naver.com

하지만 DRF에서는 미리 만들어진 페이지네이션 API를 통해 쉽게 페이지네이션을 구현할 수 있습니다.

 

 

 

페이지네이션
from django.db import models

# Create your models here.

class Product(models.Model):
  name = models.CharField(max_length=128)
  price = models.IntegerField()
  expression = models.TextField()
    
  def __str__(self):
    return self.name

name, price, expression 세 개의 필드를 가진 Product모델이 있습니다.

 

DRF에서 페이지네이션을 적용하는 방법은 굉장히 간단합니다.

settings.py에

# settings.py

REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 100
}

OR

REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
    'PAGE_SIZE': 100
}

OR

REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.CursorPagination',
    'PAGE_SIZE': 100
}

세가지 코드 중 한 가지를 선택해서 입력하면 페이지네이션이 전역으로 적용됩니다.

 

DRF에서 기본적으로 제공하는 페이지네이션 클래스가 세가지이기 때문에

(PageNumberPagination, LimitOffsetPagination, CursorPagination)

사용할 페이지네이션을 선택해서 입력해야 합니다.

 

공통적으로 있는 값인 PAGE_SIZE는 각 페이지마다 몇 개의 객체를 가져올 것인지를 결정하는 값입니다.

 

먼저 DRF에서 제공하는 세가지 페이지네이션 클래스들의 각 특징들을 살펴보겠습니다.

 

 

 


PageNumberPagination

 

PageNumberPagination클래스는 단순하게 각 페이지마다 PAGE_SIZE의 값만큼의

모델 객체를 담은 쿼리 파라미터를 제공합니다.

settings.py

REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 3,
}

views.py

class ProductAPIGenerics(generics.ListAPIView):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer
    
    def get(self, request, *args, **kwargs):
        return self.list(request, *args, **kwargs)

views.py에 있는 기존 뷰 클래스는 아무것도 건들지 않았지만

위 settings.py에서 REST_FRAMEWORK코드 추가로

이제 해당 url에 GET메서드로 데이터를 요청해 보면

{
    "count": 10,
    "next": "http://127.0.0.1:8000/productss/?page=2",
    "previous": null,
    "results": [
        {
            "id": 1,
            "name": "자메이카 소떡만나치킨",
            "price": 24000,
            "expression": "BBQ자메이카 저크소스와 신선육, 소떡소떡을 조합하여 풍미를 올린 전국민이 최애하는 바베큐치킨"
        },
        {
            "id": 2,
            "name": "황금올리브치킨",
            "price": 20000,
            "expression": "황금빛 파우더의 바삭함과 육즙 가득 퍼지는 부드러운 속살이 환상적!"
        },
        {
            "id": 3,
            "name": "오리지날 양념치킨",
            "price": 21500,
            "expression": "블루치즈의 부드러움과 과실의 산뜻한 만남"
        }
    ]
}

딱 세개의 데이터만 가져오게 됩니다.

 

이외에도 count값으로 전체 객체 개수가 몇 개인지,

next값으로 다음 세개의 데이터를 받기 위한 url주소

그리고 previous값으로 이전 데이터 세개를 받기 위한 url주소가 담긴 JSON데이터가 Response됩니다.

 

next의 값을 자세히 살펴보면

url쿼리 부분이 "?page=2"라고 나와있는 걸 확인할 수 있습니다.

 

이렇게 PageNumberPagination은 page쿼리 파라미터를 통해

페이지네이션을 제공하고 각 페이지마다 정렬된 데이터를 순서대로 PAGE_SIZE만큼만 담아서 제공합니다.

 

 


LimitOffsetPagination

 

다음으로 LimitOffsetPagination은 limit와 offset쿼리를 통해 데이터를 가져옵니다.

limit는 말 그대로 한 url당 가져올 데이터의 한계(개수)를 의미하고

offset은 몇 번째부터 데이터를 가져와야 할지 기억하는 역할입니다.(책갈피 같은)

settings.py

REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
    'PAGE_SIZE': 3,
}

views.py

class ProductAPIGenerics(generics.ListAPIView):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer
    
    def get(self, request, *args, **kwargs):
        return self.list(request, *args, **kwargs)

PageNumberPagination을 적용할 때와 같이

views.py에 있는 기존 뷰 로직은 추가한 게 없고

그저 settings.py에 REST_FRAMEWORK코드만 추가하기만 하면

{
    "count": 10,
    "next": "http://127.0.0.1:8000/productss/?limit=3&offset=3",
    "previous": null,
    "results": [
        {
            "id": 1,
            "name": "자메이카 소떡만나치킨",
            "price": 24000,
            "expression": "BBQ자메이카 저크소스와 신선육, 소떡소떡을 조합하여 풍미를 올린 전국민이 최애하는 바베큐치킨"
        },
        {
            "id": 2,
            "name": "황금올리브치킨",
            "price": 20000,
            "expression": "황금빛 파우더의 바삭함과 육즙 가득 퍼지는 부드러운 속살이 환상적!"
        },
        {
            "id": 3,
            "name": "오리지날 양념치킨",
            "price": 21500,
            "expression": "블루치즈의 부드러움과 과실의 산뜻한 만남"
        }
    ]
}

LimitOffset 페이지네이션이 바로 적용됩니다.

 

next에 있는 다음 데이터를 가져오는 url값을 보면

?limit=3&offset=3이라는 쿼리를 확인할 수 있습니다.

쿼리 그대로 다음 데이터는 3개의 데이터를 가져올 것이고 offset값의+1 데이터부터 가져오게 됩니다.

지금까지 PageNumberPagination과 LimitOffsetPagination에 대해 알아봤는데

사실 쿼리 형태의 차이만 존재할뿐 큰 차이점은 느낄 수 없습니다.

 

사실 이 두 방식의 페이지네이션은 큰 문제점이 있는데

만약 모델 객체에 대한 추가가 빈번한 사이트라고 가정한다면

사용자가 4, 5, 6 데이터를 보고 있는 와중에 누군가가 3개의 데이터를 추가하면

4, 5, 6 데이터가 밀리게 되어 7, 8, 9로 가게 되고 사용자가 다음 데이터를 봐야지 하고 다음 데이터를 받아오게 되면

밀리게 된 7, 8, 9 데이터가 보여지게 되기 때문에 중복된 데이터가 보이게 되는 문제점이 존재합니다.

 

심지어 데이터가 많아졌을 때

오프셋이 커지면 커질수록 앞에 있는 데이터도 탐색해야 하는 문제 때문에

속도가 점점 느려지는 문제도 존재합니다.

(자세한 내용은 아래 사이트를 참고하세요 👀)

--> Why You Shouldn't Use OFFSET and LIMIT For Your Pagination

(+ 이에 대한 내용은 DRF 공식문서에서도 확인할 수 있습니다.)

 

Why You Shouldn't Use OFFSET and LIMIT For Your Pagination

Gone are the days when we wouldn’t need to worry about database performance optimization. With the advance of times and every new entrepreneur wanting to build the next Facebook combined with the mindset of collecting every possible data-point to provide

ivopereira.net

이러한 문제점들을 극복하기 위해

마지막 페이지네이션 클래스인 Cursor페이지네이션을 DRF에서 제공합니다.

 

그렇다고 PageNumberPagination, LimitOffsetPagination이 나쁜 방법은 아닙니다.

데이터가 적고

사용자에게 중복된 데이터가 보여질 가능성이 없다면

굳이 복잡한 방식의 로직을 사용할 필요또한 없습니다.

 

 

 


CursorPagination

 

CursorPagination도 똑같이 settings.py에

REST_FRAMEWORK값으로 설정해서 잘 작동되나 확인해 보겠습니다.

settings.py

REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.CursorPagination',
    'PAGE_SIZE': 3,
}

이전 페이지네이션 클래스와는 다르게 오류가 발생합니다.

사실 CursorPagination은 기본값으로 created_at필드를 기준으로 정렬하는데

필자의 Product 모델은 created_at필드가 없기 때문에 오류가 발생했습니다.

pagination.py

class CustomCursorPagination(CursorPagination):
  page_size = 3
  ordering = 'id'

settings.py

REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'products.pagination.CustomCursorPagination',
    'PAGE_SIZE': 3,
}

그렇기 때문에 CusrorPagination클래스의 ordering특성을 id값으로 재정의해서 다시 실행해 보겠습니다.

{
    "next": "http://127.0.0.1:8000/productss/?cursor=cD0z",
    "previous": null,
    "results": [
        {
            "id": 1,
            "name": "자메이카 소떡만나치킨",
            "price": 24000,
            "expression": "BBQ자메이카 저크소스와 신선육, 소떡소떡을 조합하여 풍미를 올린 전국민이 최애하는 바베큐치킨"
        },
        {
            "id": 2,
            "name": "황금올리브치킨",
            "price": 20000,
            "expression": "황금빛 파우더의 바삭함과 육즙 가득 퍼지는 부드러운 속살이 환상적!"
        },
        {
            "id": 3,
            "name": "오리지날 양념치킨",
            "price": 21500,
            "expression": "블루치즈의 부드러움과 과실의 산뜻한 만남"
        }
    ]
}

ordering속성을 페이지네이션을 사용할 모델의 필드값으로 설정해줬더니 잘 작동합니다.

 

다음 데이터가 존재하는 url값이 담긴 next부분을 봐보면 쿼리 부분이

cursor=cD0z로 되어있는 걸 확인할 수 있습니다.

 

이렇게 CursorPagination은 고유한 책갈피를 가지고 있는 방식입니다.

"cD0z다음에 있는 데이터 3개를 가져와주세요"라고 생각하면 됩니다.

 

고유한 책갈피를 가졌기 때문에 Limit Offset처럼 값이 밀리더라도 전혀 상관이 없습니다.

 

 

APIView에서 적용해 보기

 

이렇게 DRF에서 제공하는 세가지의 페이지네이션 클래스에 대해 알아봤습니다.

 

하지만 APIView를 사용하는 분들에게는 해당 페이지네이션이 자동으로 적용이 되지 않습니다.

그 이유에 대해 잠깐 설명해보기 위해 잠깐 ListApiView클래스의 코드를 보면

class ListAPIView(mixins.ListModelMixin,
                  GenericAPIView):
    """
    Concrete view for listing a queryset.
    """
    def get(self, request, *args, **kwargs):
        return self.list(request, *args, **kwargs)

ListAPIView 같은 경우 GET메서드는 self.list를 리턴하게 됩니다.

다시 원인을 파악하기 위해 list메서드를 확인해 보면

class ListModelMixin:
    """
    List a queryset.
    """
    def list(self, request, *args, **kwargs):
        queryset = self.filter_queryset(self.get_queryset())

        page = self.paginate_queryset(queryset)
        if page is not None:
            serializer = self.get_serializer(page, many=True)
            return self.get_paginated_response(serializer.data)

        serializer = self.get_serializer(queryset, many=True)
        return Response(serializer.data)

list메서드는 중간에 paginate_queryset(queryset)을 통해

페이지네이션하는 과정이 존재합니다.

그리고 기본적으로 GenericAPIView를 상속받기 때문에

부모인 GenericAPIView의 paginate_queryset메서드를 사용하게 되는데

class GenericAPIView(views.APIView):
    ...
    @property
    def paginator(self):
        """
        The paginator instance associated with the view, or `None`.
        """
        if not hasattr(self, '_paginator'):
            if self.pagination_class is None:
                self._paginator = None
            else:
                self._paginator = self.pagination_class()
        return self._paginator

    def paginate_queryset(self, queryset):
        """
        Return a single page of results, or `None` if pagination is disabled.
        """
        if self.paginator is None:
            return None
        return self.paginator.paginate_queryset(queryset, self.request, view=self)
    ...

해당 메서드를 확인해 보면 self.paginator를 통해 페이지네이터 클래스가 있는지 먼저 확인합니다.

만약 페이지네이터 클래스가 존재한다면 해당 페이지네이션 클래스의

paginate_queryset메서드를 호출합니다.

 

GenericAPIView의 pagination_class속성값을 확인해보면

pagination_class = api_settings.DEFAULT_PAGINATION_CLASS

이런 코드가 적어져 있습니다.

해당 코드는 settings.py에서 DEFAULT_PAGINATION_CLASS에 값인 페이지네이터 클래스를 가져오게 됩니다.

이러한 과정 때문에 settings.py에 DEFAULT_PAGINATION_CLASS값을 적어놓으면

해당 페이지네이션이 전역으로 적용되는 이유입니다.

 

( 반대로 전역이 아닌 단일로 페이지네이션을 적용할 때는 해당 View의 pagination_class값을 따로 설정하면

해당 페이지네이션 클래스를 통해 동작하겠구나를 추측할 수 있습니다. )

 

하지만 APIView에는 페이지네이션과 관련된 코드가 존재하지 않습니다.

그렇기 때문에 그냥 간단하게 생각하면 APIView에서 데이터를 가져오는

GET메서드에도 페이지네이션을 하는 코드를 직접 추가하면 됩니다.

page = self.paginate_queryset(queryset)

class ProductAPI(APIView, CustomCursorPagination):

    def get(self, request):
        products = Product.objects.all()
        result_page = self.paginate_queryset(products, request)
        if result_page is not None:
            serializer = ProductSerializer(result_page, many=True, context={'request':request})
            return self.get_paginated_response(serializer.data)
        
        response = Response(serializer.data, status=status.HTTP_200_OK)
        return response

paginate_queryset을 사용하기 위해서는 당연히

페이지네이션 클래스를 상속받아야 합니다.

필자는 CursorPagination에서 사용했던 CustomCursorPagination을 상속받았습니다.

 

이제 다시 실행해 보면

{
    "next": "http://127.0.0.1:8000/products/?cursor=cD0z",
    "previous": null,
    "results": [
        {
            "id": 1,
            "name": "자메이카 소떡만나치킨",
            "price": 24000,
            "expression": "BBQ자메이카 저크소스와 신선육, 소떡소떡을 조합하여 풍미를 올린 전국민이 최애하는 바베큐치킨"
        },
        {
            "id": 2,
            "name": "황금올리브치킨",
            "price": 20000,
            "expression": "황금빛 파우더의 바삭함과 육즙 가득 퍼지는 부드러운 속살이 환상적!"
        },
        {
            "id": 3,
            "name": "오리지날 양념치킨",
            "price": 21500,
            "expression": "블루치즈의 부드러움과 과실의 산뜻한 만남"
        }
    ]
}

APIView도 정상적으로 잘 작동하는 걸 확인할 수 있습니다.

 

마지막으로 Pagination클래스를 커스터마이징 하는 방법에 대해 알아보겠습니다.

 

 


Pagination클래스 커스터마이징


만약에 PageNumberPagination클래스를 사용하는 도중에

해당 클래스의 기본값으로 제공하는 Response의 형태가 마음에 들지 않는다면 어떻게 해야 할까요?

class PageNumberPagination(BasePagination):
    """
    A simple page number based style that supports page numbers as
    query parameters. For example:
    http://api.example.org/accounts/?page=4
    http://api.example.org/accounts/?page=4&page_size=100
    """
    # The default page size.
    # Defaults to `None`, meaning pagination is disabled.
    page_size = api_settings.PAGE_SIZE

    django_paginator_class = DjangoPaginator

    # Client can control the page using this query parameter.
    page_query_param = 'page'
    page_query_description = _('A page number within the paginated result set.')

    # Client can control the page size using this query parameter.
    # Default is 'None'. Set to eg 'page_size' to enable usage.
    page_size_query_param = None
    page_size_query_description = _('Number of results to return per page.')

    # Set to an integer to limit the maximum page size the client may request.
    # Only relevant if 'page_size_query_param' has also been set.
    max_page_size = None

    last_page_strings = ('last',)

    template = 'rest_framework/pagination/numbers.html'

    invalid_page_message = _('Invalid page.')

방법은 간단합니다.

새로운 페이지네이션 클래스를 만들어 기존에 사용하던 페이지네이션을 상속받아서

해당 어트리뷰트나 메서드를 조금 수정해주면 사용자 마음대로 페이지네이션 클래스를 커스터마이징 할 수 있습니다.

 

한 가지 예시로 필자는 위에서 Response형태가 마음에 안 든다고 했으니

해당 형태를 바꿔보겠습니다.

class PageNumberPagination(BasePagination):
    ...
    def get_paginated_response(self, data):
        return Response(OrderedDict([
            ('count', self.page.paginator.count),
            ('next', self.get_next_link()),
            ('previous', self.get_previous_link()),
            ('results', data)
        ]))
    ...

위 코드는 PageNumberPagination이 기본적으로 제공하는 Response 형태인데

필자는 next의 값으로 다음 데이터가 존재하는 주소가 아닌

그냥 다음 데이터(페이지)가 존재하기만 하면 True를 반환하도록 해보겠습니다.

 

그리고 필자의 사이트는 인덱싱 형태로 데이터에 접근하는 게 아닌

순차적으로 접근하는 방식이기 때문에(가장 처음에 소개했던 네이버 증권 사이트처럼)

previous는 제거하도록 하겠습니다.

class LargeResultsSetPagination(PageNumberPagination):
  page_query_param = 'paged'
  page_size = 3
  
  def get_paginated_response(self, data):

    return Response(OrderedDict([
        ('count', self.page.paginator.count),
        ('next', self.page.has_next()),
        ('results', data),
    ]))

이제 다시 커스터마이징 한 클래스를 상속받아 실행해 보면

{
    "count": 10,
    "next": true,
    "results": [
        {
            "id": 1,
            "name": "자메이카 소떡만나치킨",
            "price": 24000,
            "expression": "BBQ자메이카 저크소스와 신선육, 소떡소떡을 조합하여 풍미를 올린 전국민이 최애하는 바베큐치킨"
        },
        {
            "id": 2,
            "name": "황금올리브치킨",
            "price": 20000,
            "expression": "황금빛 파우더의 바삭함과 육즙 가득 퍼지는 부드러운 속살이 환상적!"
        },
        {
            "id": 3,
            "name": "오리지날 양념치킨",
            "price": 21500,
            "expression": "블루치즈의 부드러움과 과실의 산뜻한 만남"
        }
    ]
}

Response형태가 바뀐 걸 확인할 수 있습니다.

 

이렇게 자유자재까지는 아니지만 기존에 제공되는 페이지네이션 클래스가 마음에 안들어

커스터마이징 할 분들은 DRF 페이지네이션 가이드 공식문서와 해당 클래스들의 소스코드를 읽어 이해도를 높이면

커스터마이징 하는데 많이 도움이 될 거라고 생각합니다.

--> Pagination - Django REST framework

 

Pagination - Django REST framework

pagination.py Django provides a few classes that help you manage paginated data – that is, data that’s split across several pages, with “Previous/Next” links. — Django documentation REST framework includes support for customizable pagination styl

www.django-rest-framework.org

--> DRF Paginator class source code

 

GitHub - encode/django-rest-framework: Web APIs for Django. 🎸

Web APIs for Django. 🎸. Contribute to encode/django-rest-framework development by creating an account on GitHub.

github.com

 

댓글
공지사항