본문으로 건너뛰기
김신건의 로그

[Django] Pagination: Paginator, ListView, DRF

· 수정 · 📖 약 1분 · 470자/단어 #django #pagination
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.

이 개념을 다룬 위키 페이지 (1)

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기