[Django] Pagination: Paginator, ListView, DRF
django pagination, Paginator, page_size, CursorPagination, ListView paginate_by
정의
Django는 빌트인 Paginator 클래스와 ListView의 자동 페이지네이션을 제공한다. DRF는 PageNumber / LimitOffset / Cursor 3종. 페이지네이션은 큰 결과셋의 메모리·응답 시간 보호의 기본.
빌트인 Paginator
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
def post_list(request):
posts = Post.objects.all().order_by("-created_at")
paginator = Paginator(posts, 20) # 페이지당 20개
page_number = request.GET.get("page", 1)
try:
page_obj = paginator.page(page_number)
except PageNotAnInteger:
page_obj = paginator.page(1)
except EmptyPage:
page_obj = paginator.page(paginator.num_pages)
return render(request, "posts.html", {"page_obj": page_obj})
또는 더 간단히 paginator.get_page(page_number):
page_obj = paginator.get_page(page_number) # 자동 처리
템플릿
{% for post in page_obj %}
<article>{{ post.title }}</article>
{% endfor %}
<nav>
{% if page_obj.has_previous %}
<a href="?page=1">«</a>
<a href="?page={{ page_obj.previous_page_number }}">이전</a>
{% endif %}
<span>{{ page_obj.number }} / {{ page_obj.paginator.num_pages }}</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">다음</a>
<a href="?page={{ page_obj.paginator.num_pages }}">»</a>
{% endif %}
</nav>
ListView 자동
class PostListView(ListView):
model = Post
paginate_by = 20
template_name = "posts.html"
context_object_name = "posts"
자동으로 page_obj, paginator, is_paginated context 제공. 쿼리스트링 ?page=2.
DRF Pagination
PageNumberPagination (기본)
# settings.py
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
}
요청: ?page=2, 응답:
{
"count": 100,
"next": "https://.../?page=3",
"previous": "https://.../?page=1",
"results": [...]
}
커스텀 (page_size_query_param)
class StandardPagination(PageNumberPagination):
page_size = 20
page_size_query_param = "page_size" # ?page_size=50
max_page_size = 100
# ViewSet에서
class PostViewSet(ModelViewSet):
pagination_class = StandardPagination
LimitOffsetPagination
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
}
요청: ?limit=20&offset=40, 응답:
{
"count": 100,
"next": "?limit=20&offset=60",
"previous": "?limit=20&offset=20",
"results": [...]
}
OFFSET이 크면 SQL 느려짐. 깊은 페이지에 부적합.
CursorPagination
class PostCursorPagination(CursorPagination):
ordering = "-created_at" # 필수 (unique 정렬 권장)
page_size = 20
응답:
{
"next": "https://.../?cursor=cD0yMDI2LTA2LTIw...",
"previous": null,
"results": [...]
}
장점:
- 안정적 (행 추가/삭제 영향 X)
- O(log n) 성능 (인덱스 활용)
- 깊은 페이지도 빠름
단점: 임의 페이지 점프 X (순차만). 무한 스크롤·실시간 피드에 최적.
큰 데이터셋 함정
count() 비용
PostgreSQL은 COUNT가 전체 스캔. 수백만 행이면 1초+.
class FastPagination(PageNumberPagination):
page_size = 20
def paginate_queryset(self, queryset, request, view=None):
# COUNT 우회
self.page = request.query_params.get("page", 1)
...
또는 추정 카운트:
from django.db import connection
with connection.cursor() as c:
c.execute("SELECT reltuples FROM pg_class WHERE relname = 'blog_post'")
estimate = c.fetchone()[0]
OFFSET 깊은 페이지
SELECT * FROM posts ORDER BY id LIMIT 20 OFFSET 1000000
PG는 100만 행을 읽어서 버린다. CursorPagination이 답.
SELECT * FROM posts WHERE id > :last_id ORDER BY id LIMIT 20 -- 빠름
자주 보는 패턴
무한 스크롤 (cursor)
class FeedPagination(CursorPagination):
page_size = 20
ordering = "-created_at"
cursor_query_param = "cursor"
// 클라이언트
async function loadMore() {
const response = await fetch(`/api/feed/?cursor=${nextCursor}`);
const data = await response.json();
appendItems(data.results);
nextCursor = parseCursor(data.next);
}
페이지 + 쿼리 조합
{% url 'post-list' %}?page={{ page_obj.next_page_number }}&q={{ request.GET.q }}&sort={{ request.GET.sort }}
기존 query string 유지하면서 페이지만 변경.
Pagy / Kaminari 대신 빌트인
Django는 Paginator로 충분. 외부 라이브러리 거의 불필요.
HTMX 무한 스크롤
<div hx-get="?page={{ page_obj.next_page_number }}"
hx-trigger="revealed"
hx-swap="afterend">
Loading...
</div>
뷰포트에 들어오면 다음 페이지 자동 로드.
함정
1. order_by 없이 페이지네이션
Paginator(Post.objects.all(), 20) # 정렬 없음 → 순서 보장 X
기본 정렬이 없으면 페이지마다 같은 행이 중복 표시되거나 누락. 항상 정렬.
2. queryset이 list로 변환됨
posts = list(Post.objects.all()) # 모두 로드
paginator = Paginator(posts, 20) # 비효율
QuerySet 그대로 전달 → lazy.
3. CursorPagination + 비유일 정렬
ordering = "created_at" # 같은 시각 행 여러 개면 cursor 깨짐
ordering = ("-created_at", "id") # 유일성 보장
4. SEO와 페이지네이션
큰 페이지 수는 검색 엔진이 다 크롤링 못 함. canonical/prev/next 헤더 또는 사이트맵.
5. DRF count 정확성
분산 환경에서 COUNT 정합성 보장 X. 실시간 데이터는 cursor 또는 estimate.
💬 댓글