장고 실전 #3 Filtering / Ordering / Pagination

6 분 소요

#2에서 인증/권한이 갖춰졌으니, 이번엔 목록 조회를 다듬는 도구들 입니다. 글이 100건이면 다 내려보내도 되지만, 10만 건 / 1억 건이 되는 순간 페이지네이션과 필터링은 필수가 됩니다.

DRF는 세 영역에 잘 만들어진 빌트인을 가집니다.

  • Filtering?published=true&author=3 같은 조건
  • Ordering?ordering=-created_at 같은 정렬
  • Pagination — 한 번에 몇 개씩, 다음 페이지는 어디

filter_backends — 한곳에 모은 필터링 파이프라인 #

ListAPIView / ModelViewSetlist 액션은 queryset을 받아서 filter_backends를 차례로 거친 뒤 직렬화합니다. 백엔드는 셋이 표준.

settings.py
REST_FRAMEWORK = {
    "DEFAULT_FILTER_BACKENDS": [
        "django_filters.rest_framework.DjangoFilterBackend",
        "rest_framework.filters.SearchFilter",
        "rest_framework.filters.OrderingFilter",
    ],
}

각각이 다른 상황을 담당합니다.

백엔드동작쿼리 예
DjangoFilterBackend필드별 동등/범위 필터?author=3&published=true
SearchFilter텍스트 검색 (icontains 등)?search=django
OrderingFilter정렬?ordering=-created_at

django-filter — 필드 필터링 #

설치
uv add django-filter
settings.py
INSTALLED_APPS += ["django_filters"]

가장 단순한 — filterset_fields #

blog/views.py
from django_filters.rest_framework import DjangoFilterBackend


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    filter_backends = [DjangoFilterBackend]
    filterset_fields = ["author", "published"]

이게 끝. ?author=3&published=true 같은 쿼리가 자동으로 동등 비교 필터로 동작합니다.

요청
GET /api/posts/?author=3&published=true

FilterSet — 더 풍부한 표현 #

같은 필드라도 gte, lt, in 같은 lookup이 필요하면 FilterSet 클래스로.

blog/filters.py
import django_filters
from .models import Post


class PostFilter(django_filters.FilterSet):
    title = django_filters.CharFilter(lookup_expr="icontains")
    created_after = django_filters.DateFilter(
        field_name="created_at", lookup_expr="gte"
    )
    created_before = django_filters.DateFilter(
        field_name="created_at", lookup_expr="lte"
    )
    author_in = django_filters.BaseInFilter(field_name="author")
    has_comments = django_filters.BooleanFilter(method="filter_has_comments")

    class Meta:
        model = Post
        fields = ["author", "published"]

    def filter_has_comments(self, queryset, name, value):
        if value:
            return queryset.filter(comments__isnull=False).distinct()
        return queryset.filter(comments__isnull=True)
blog/views.py
from .filters import PostFilter


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    filter_backends = [DjangoFilterBackend]
    filterset_class = PostFilter

쿼리 예:

다양한 조건
?title=django                       # 제목에 "django" 포함 (icontains)
?created_after=2026-01-01           # 그 이후
?created_before=2026-12-31          # 그 이전
?author_in=1,2,3                    # author IN (1, 2, 3)
?has_comments=true                  # 댓글이 있는 글만

Method filter — 임의 로직 #

method=...를 쓰면 임의의 함수가 필터로 동작합니다 (has_comments가 그 예). 단순 lookup으로 표현 안 되는 조건을 풀기 좋습니다.

OrderingFilter — 정렬 #

blog/views.py
from rest_framework.filters import OrderingFilter


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    filter_backends = [DjangoFilterBackend, OrderingFilter]
    filterset_class = PostFilter
    ordering_fields = ["created_at", "title"]
    ordering = ["-created_at"]   # 기본 정렬

ordering_fields허용 목록 입니다. 적지 않은 필드는 무시됩니다 — 클라이언트가 ?ordering=secret_field 같은 위험한 정렬을 못 하게.

쿼리
?ordering=-created_at        # 내림차순
?ordering=title              # 오름차순
?ordering=-published,title   # 다중 정렬

ordering_fields = "__all__"도 가능하지만, 인덱스 없는 필드로 정렬하면 큰 테이블에서 느립니다. 명시적인 화이트리스트가 안전합니다.

SearchFilter — 텍스트 검색 #

여러 필드에 걸친 단순 검색.

blog/views.py
from rest_framework.filters import SearchFilter


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
    search_fields = ["title", "body", "author__username"]
쿼리
?search=django

search_fields의 prefix로 검색 모드를 지정할 수 있습니다.

prefix의미
(없음)icontains (부분 일치, 대소문자 무시)
^istartswith (접두 일치)
=iexact (정확 일치)
@full-text search (Postgres만)
$regex
search_fields 예
search_fields = ["^title", "=email", "body"]

진짜 검색은 별도 도구로 #

SearchFilter는 작은 데이터에서 빠른 시작용입니다. 진짜 검색이 필요하면:

  • PostgreSQL full-text search (SearchVector, SearchQuery)
  • OpenSearch / Elasticsearch / Meilisearch 같은 검색 엔진

고급 #3 쿼리 최적화에서 본 인덱스 전략과 같이 가야 큰 데이터에서 살아남습니다.

Pagination — 페이지네이션 세 가지 #

DRF가 표준으로 제공하는 세 가지 paginator. 각자 성격이 다릅니다.

PageNumberPaginationLimitOffsetPaginationCursorPagination
쿼리?page=2?limit=20&offset=40?cursor=cD0xMjM=
메타count, next, previouscount, next, previousnext, previous (no count)
큰 테이블느려짐 (count + offset)매우 느려짐 (offset 깊을수록)빠름 (인덱스 사용)
정렬 변경가능가능고정 ordering 필요
데이터 추가/삭제페이지 흔들림페이지 흔들림안정
어울리는 경우일반 UI 페이지네이션클라이언트가 offset 직접 조절무한 스크롤, 큰 테이블

PageNumberPagination #

settings.py
REST_FRAMEWORK = {
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
}
요청
GET /api/posts/?page=2
응답
{
  "count": 1234,
  "next": "http://localhost:8000/api/posts/?page=3",
  "previous": "http://localhost:8000/api/posts/?page=1",
  "results": [...]
}

가장 친숙한 구조 — “1, 2, 3 … 62” 같은 페이지네이션 UI와 자연스럽게 어울립니다.

커스텀 PageNumberPagination #

blog/pagination.py
from rest_framework.pagination import PageNumberPagination


class StandardPagination(PageNumberPagination):
    page_size = 20
    page_size_query_param = "page_size"   # 클라이언트가 조절
    max_page_size = 100                   # 한 페이지 최대

page_size_query_param을 두면 ?page_size=50 같은 형태로 클라이언트가 페이지 크기를 조절할 수 있습니다 (악의적 폭주 방지를 위해 max_page_size같이).

LimitOffsetPagination #

settings.py
REST_FRAMEWORK = {
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
}
요청
GET /api/posts/?limit=20&offset=40

페이지 번호 대신 offset으로 직접 점프하는 구조로, SQL의 LIMIT/OFFSET과 1:1 대응합니다.

CursorPagination — 큰 테이블의 안전한 답 #

PageNumber/LimitOffset 둘 다 offset 깊어질수록 느려지는 함정이 있습니다. SQL이 결과를 거기까지 다 세야 하기 때문입니다.

🚫 깊은 offset
SELECT * FROM blog_post ORDER BY created_at DESC LIMIT 20 OFFSET 100000;

이 쿼리는 100,020 행을 읽어서 마지막 20만 돌려줍니다. 페이지가 깊어질수록 정비례로 느려지고, 그동안 행이 추가/삭제되면 같은 글이 두 번 보이거나 빠져요 (페이지 흔들림).

CursorPagination인덱스로 점프 합니다.

✅ cursor
SELECT * FROM blog_post WHERE created_at < '2026-05-01 12:00:00' ORDER BY created_at DESC LIMIT 20;

마지막으로 본 행의 created_at을 기억해서, “그것보다 이전” 으로 다음 페이지를 가져옵니다. 데이터 크기와 무관하게 일정 시간.

blog/pagination.py
from rest_framework.pagination import CursorPagination


class PostCursorPagination(CursorPagination):
    page_size = 20
    ordering = "-created_at"   # 인덱스가 있는 필드여야
    cursor_query_param = "cursor"
blog/views.py
class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    pagination_class = PostCursorPagination
요청
GET /api/posts/                          # 첫 페이지
GET /api/posts/?cursor=cD0xMjM=          # 다음 페이지

응답은 count가 없습니다 (계산하려면 결국 전체 스캔).

{
  "next": "http://.../?cursor=cD0xMjM=",
  "previous": null,
  "results": [...]
}

언제 무엇을 #

상황추천
작은 테이블 (~몇만), 페이지 UIPageNumberPagination
클라이언트가 자유롭게 offset 조절LimitOffsetPagination
큰 테이블 (수십만 +), 무한 스크롤, 피드CursorPagination
Twitter/Instagram 스타일 타임라인CursorPagination

View 별 paginator 오버라이드 #

글로벌과 다른 paginator를 쓰고 싶으면 pagination_class.

ViewSet 별
class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    pagination_class = PostCursorPagination


class CommentViewSet(viewsets.ModelViewSet):
    queryset = Comment.objects.all()
    serializer_class = CommentSerializer
    pagination_class = StandardPagination

페이지네이션을 끄고 싶으면 pagination_class = None.

get_queryset — 사용자별 큐어리셋 #

#2에서 본 IsOwnerlist에 안 통합니다 — list는 객체 단위 검사가 없습니다. 자기 글만 보이게 하려면 get_queryset에서 거릅니다.

자기 것만
class PostViewSet(viewsets.ModelViewSet):
    serializer_class = PostSerializer

    def get_queryset(self):
        qs = Post.objects.all()
        if self.action == "list":
            # 목록은 공개 글 + 내 글
            if self.request.user.is_authenticated:
                return qs.filter(
                    Q(published=True) | Q(author=self.request.user)
                )
            return qs.filter(published=True)
        return qs   # retrieve/update는 객체 권한이 따로 검사

get_queryset()이 필터링/페이지네이션의 출발점 이라는 점이 핵심. 모든 backend가 이 queryset 위에서 동작합니다.

캐싱과의 관계 #

같은 쿼리/같은 페이지가 자주 호출되면 고급 #4 캐싱의 view-level 캐싱이 효과적입니다.

간단한 view 캐시
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer

    @method_decorator(cache_page(60))   # 60초
    def list(self, request, *args, **kwargs):
        return super().list(request, *args, **kwargs)

다만 페이지네이션/필터/정렬은 쿼리 파라미터마다 다른 응답이 됩니다 — cache_page는 URL + 쿼리스트링 단위로 캐시하므로 자연스럽게 분리됩니다. 단, 인증된 사용자별 응답이 다른 경우는 vary_on_headers / 사용자 단위 키가 필요합니다.

select_related / prefetch_related — 같이 가야 하는 도구 #

페이지네이션이 효과를 보려면 ORM 쿼리도 N+1을 피해야 합니다. 고급 #3 쿼리 최적화의 패턴이 그대로.

get_queryset에서 미리 로드
def get_queryset(self):
    return (
        Post.objects.select_related("author")        # FK
        .prefetch_related("comments")                # reverse FK / M2M
        .all()
    )

ViewSet의 queryset 클래스 속성은 모듈 import 시점에 한 번 만들어집니다 — 매 요청마다 평가해야 하는 동적 로직(요청 사용자 등)은 get_queryset()으로 풀어야 합니다.

응답 메타 — count / next / previous #

paginator는 응답을 한 dict로 감쌉니다.

PageNumber 응답
{
  "count": 1234,
  "next": "http://...?page=3",
  "previous": "http://...?page=1",
  "results": [
    { "id": 1, ... },
    { "id": 2, ... }
  ]
}

이 구조가 거의 표준이라 프론트도 같은 패턴으로 받아 처리하면 됩니다. 클라이언트는 next가 null이 아닐 때까지 follow.

정리 #

이번 글에서 잡은 것:

  • filter_backends 파이프라인 — DjangoFilter / Search / Ordering 세 백엔드
  • django-filterfilterset_fields 단순형, FilterSet 클래스gte/in/method 표현
  • OrderingFilterordering_fields 화이트리스트, 기본 ordering
  • SearchFiltersearch_fields와 prefix (^, =, @, $)
  • 진짜 검색은 PG full-text 또는 OpenSearch로 분리
  • 세 paginator의 비교:
    • PageNumberPagination — 일반 UI
    • LimitOffsetPagination — 클라이언트가 offset 직접
    • CursorPagination — 큰 테이블/무한 스크롤의 안전한 답
  • pagination_class로 view 별 오버라이드, None으로 끄기
  • get_queryset()에서 사용자별/액션별 분기
  • select_related / prefetch_related와 같이 가야 N+1 안 터짐
  • view-level 캐싱과의 결합

고급 #4 캐싱의 view 캐시, 고급 #3 쿼리 최적화의 인덱스/JOIN 패턴이 페이지네이션과 자연스럽게 같이 갑니다 — 큰 데이터를 다루는 도구들은 늘 한 묶음.

다음 글(#4 Celery로 비동기 작업)에서는 무거운 작업 — 이메일 발송, 외부 API 호출, 무거운 변환 — 을 응답 흐름에서 떼어내는 표준 도구 Celery를 다룹니다.

X