jongkwan.dev
개발 · Essay №007

Django 커스텀 페이지네이션 구현하기

Django 기본 Paginator의 한계와 커서 기반 커스텀 페이지네이션 구현

이종관2025년 3월 29일6 min read
Contents

Django 기본 Paginator의 한계를 짚고, timestamp 커서 기반 커스텀 페이지네이션을 구현한다.

Django 기본 Paginator 이해

대량의 데이터를 한 번에 렌더링하면 응답 시간이 길어지고 메모리 사용량이 증가한다. Pagination은 데이터를 일정 단위로 나누어 요청할 때마다 한 페이지 분량만 반환하는 기법이다.

Django에서 가장 간단하게 페이지네이션을 적용하려면 Paginator 클래스를 사용한다.

python
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.shortcuts import render
from .models import Post
 
def post_list_view(request):
    post_qs = Post.objects.all().order_by('-created_at')
 
    # Paginator 객체 생성 (페이지당 10개)
    paginator = Paginator(post_qs, 10)
 
    page = request.GET.get('page', 1)
 
    try:
        posts = paginator.page(page)
    except PageNotAnInteger:
        posts = paginator.page(1)
    except EmptyPage:
        posts = paginator.page(paginator.num_pages)
 
    return render(request, 'blog/post_list.html', {'posts': posts})

핵심 개념

  • Paginator(queryset, per_page): queryset과 페이지당 개수를 인자로 받음
  • paginator.page(number): 해당 페이지의 Page 객체 반환
  • 예외 처리: PageNotAnInteger, EmptyPage 예외를 적절히 처리

템플릿 예시

html
{% for post in posts %}
  <h2>{{ post.title }}</h2>
  <p>{{ post.content }}</p>
{% endfor %}
 
<div class="pagination">
  {% if posts.has_previous %}
    <a href="?page={{ posts.previous_page_number }}">이전</a>
  {% endif %}
 
  <span>{{ posts.number }} / {{ posts.paginator.num_pages }}</span>
 
  {% if posts.has_next %}
    <a href="?page={{ posts.next_page_number }}">다음</a>
  {% endif %}
</div>

커스텀 Paginator가 필요한 상황

기본 Paginator로 대부분 처리할 수 있지만, 다음 경우에는 커스텀이 필요하다:

  1. 페이지 번호 대신 다른 식별자 사용 (해시값, 날짜, 슬러그 등)
  2. Infinite Scroll(스크롤 끝에서 다음 데이터를 이어 불러오는 방식) 구현 시 동적 데이터 로딩
  3. paginate_by가 동적으로 변경되어야 할 때
  4. 복잡한 필터/검색/정렬 조건과 함께 사용

Paginator 상속으로 커스텀하기

created_at 기준 페이지네이션

python
from django.core.paginator import Paginator
 
class CreatedAtPaginator(Paginator):
    """created_at 기준 커스텀 Paginator"""
 
    def __init__(self, object_list, per_page, **kwargs):
        super().__init__(object_list, per_page, **kwargs)
 
    def page_by_timestamp(self, timestamp):
        """timestamp 이후 데이터를 per_page만큼 반환"""
        if timestamp:
            filtered_qs = self.object_list.filter(created_at__lt=timestamp)
        else:
            filtered_qs = self.object_list
 
        return filtered_qs.order_by('-created_at')[:self.per_page]

created_at__lt=timestamp는 직전 페이지의 마지막 항목보다 과거 데이터를 가져오는 커서다. timestamp가 없으면 첫 페이지로 보고 전체에서 최신순으로 per_page만큼 자른다.

뷰에서 사용

page_by_timestamp는 슬라이스된 queryset을 돌려주므로 기본 Paginator의 Page 객체가 아니다. 따라서 has_previous/has_next 같은 메서드가 없고, 다음 페이지로 넘어갈 커서를 직접 계산해 넘겨야 한다.

python
def post_list_by_timestamp(request):
    timestamp = request.GET.get('timestamp', None)
 
    post_qs = Post.objects.all()
    paginator = CreatedAtPaginator(post_qs, 10)
    posts = list(paginator.page_by_timestamp(timestamp))
 
    # 마지막 항목의 created_at이 다음 요청의 커서가 된다
    next_timestamp = posts[-1].created_at.isoformat() if posts else None
 
    return render(request, 'blog/post_list.html', {
        'posts': posts,
        'next_timestamp': next_timestamp,
    })

next_timestamp는 이번 페이지의 마지막 항목 시각이며, 다음 요청에서 ?timestamp= 값으로 다시 보내면 그 시각보다 과거 데이터가 이어진다. 템플릿에서는 페이지 번호 대신 이 값을 다음 페이지 링크로 쓴다.

html
{% for post in posts %}
  <h2>{{ post.title }}</h2>
{% endfor %}
 
{% if next_timestamp %}
  <a href="?timestamp={{ next_timestamp }}">다음</a>
{% endif %}

Django REST Framework 페이지네이션

Django REST Framework(DRF)는 클래스 기반 Pagination을 제공한다. 세 가지 방식의 선택 기준은 다음과 같다.

  • PageNumberPagination: 페이지 번호 기반. UI에 페이지 번호를 노출하는 일반 목록에 적합하다.
  • LimitOffsetPagination: limit, offset 파라미터 사용. 임의 구간 조회가 필요할 때 쓴다.
  • CursorPagination: 커서 기반. 대용량에서 일관된 순서를 보장하고 offset 방식의 누락/중복이 없다.

커스텀 PageNumberPagination

python
from rest_framework.pagination import PageNumberPagination
 
class CustomPageNumberPagination(PageNumberPagination):
    page_size = 10
    page_size_query_param = 'page_size'  # ?page_size=20 가능
    max_page_size = 100

설정 방법

python
# settings.py - 전역 설정
REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'myapp.paginations.CustomPageNumberPagination',
    'PAGE_SIZE': 10,
}
 
# 또는 개별 ViewSet에서
class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    pagination_class = CustomPageNumberPagination

정리

방식사용 사례
기본 Paginator단순 페이지 번호 기반
커스텀 Paginator시간/슬러그 기반, 특수 로직
DRF PaginationAPI 응답, 동적 page_size

페이지 번호 UI가 필요한 단순 목록은 기본 Paginator로 충분하다. 식별자가 페이지 번호가 아니거나 Infinite Scroll처럼 이어붙이는 흐름이면 timestamp 같은 커서 기반 커스텀이 맞다. 커서 방식은 데이터가 중간에 추가·삭제돼도 누락이나 중복 없이 일관된 순서를 보장하지만, 임의 페이지로 바로 점프할 수는 없다는 단점이 있다. API 응답이나 동적 page_size가 필요하면 DRF의 Pagination 클래스를 쓰고, 대용량 정렬 결과에는 CursorPagination을 우선 고려한다.