Django 기본 Paginator의 한계를 짚고, timestamp 커서 기반 커스텀 페이지네이션을 구현합니다.
Django 기본 Paginator 이해
대량의 데이터를 한 번에 렌더링하면 응답 시간이 길어지고 메모리 사용량이 증가합니다. Pagination은 데이터를 일정 단위로 나누어 요청할 때마다 한 페이지 분량만 반환하는 기법입니다.
Django에서 가장 간단하게 페이지네이션을 적용하려면 Paginator 클래스를 사용합니다.
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예외를 적절히 처리
템플릿 예시
{% 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로 대부분 처리할 수 있지만, 다음 경우에는 커스텀이 필요합니다.
- 페이지 번호 대신 다른 식별자 사용 (해시값, 날짜, 슬러그 등)
- Infinite Scroll(스크롤 끝에서 다음 데이터를 이어 불러오는 방식) 구현 시 동적 데이터 로딩
paginate_by가 동적으로 변경되어야 할 때- 복잡한 필터/검색/정렬 조건과 함께 사용
Paginator 상속으로 커스텀하기
flowchart LR
R[요청<br/>timestamp 선택] --> Q[created_at 조건<br/>최신순 조회]
Q --> S[per_page만큼 반환]
S --> C[마지막 항목 시각을<br/>다음 커서로 전달]
C --> R
created_at 기준 페이지네이션
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 같은 메서드가 없고, 다음 페이지로 넘어갈 커서를 직접 계산해 넘겨야 합니다.
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= 값으로 다시 보내면 그 시각보다 과거 데이터가 이어집니다. 템플릿에서는 페이지 번호 대신 이 값을 다음 페이지 링크로 씁니다.
{% 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
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설정 방법
# 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 Pagination | API 응답, 동적 page_size |
페이지 번호 UI가 필요한 목록은 기본 Paginator로 충분합니다. Infinite Scroll처럼 이어 붙이는 흐름에는 커서 기반 방식이 맞습니다. 정렬 키가 중복될 수 있으면 timestamp와 고유 ID를 함께 커서에 넣어야 누락과 중복을 피할 수 있습니다.
커서 방식은 임의 페이지로 바로 이동하기 어렵습니다. API 응답이나 동적 page_size가 필요하면 DRF의 Pagination 클래스를 사용합니다. 대용량 정렬 결과에는 안정적인 정렬 조건을 둔 CursorPagination을 우선 검토합니다.