장고 실전 #3 Filtering / Ordering / Pagination
#2에서 인증/권한이 갖춰졌으니, 이번엔 목록 조회를 다듬는 도구들 입니다. 글이 100건이면 다 내려보내도 되지만, 10만 건 / 1억 건이 되는 순간 페이지네이션과 필터링은 필수가 됩니다.
DRF는 세 영역에 잘 만들어진 빌트인을 가집니다.
- Filtering —
?published=true&author=3같은 조건 - Ordering —
?ordering=-created_at같은 정렬 - Pagination — 한 번에 몇 개씩, 다음 페이지는 어디
filter_backends — 한곳에 모은 필터링 파이프라인
#
ListAPIView / ModelViewSet의 list 액션은 queryset을 받아서 filter_backends를 차례로 거친 뒤 직렬화합니다. 백엔드는 셋이 표준.
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-filterINSTALLED_APPS += ["django_filters"]가장 단순한 — filterset_fields
#
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=trueFilterSet — 더 풍부한 표현
#
같은 필드라도 gte, lt, in 같은 lookup이 필요하면 FilterSet 클래스로.
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)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 — 정렬
#
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 — 텍스트 검색
#
여러 필드에 걸친 단순 검색.
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=djangosearch_fields의 prefix로 검색 모드를 지정할 수 있습니다.
| prefix | 의미 |
|---|---|
| (없음) | icontains (부분 일치, 대소문자 무시) |
^ | istartswith (접두 일치) |
= | iexact (정확 일치) |
@ | full-text search (Postgres만) |
$ | regex |
search_fields = ["^title", "=email", "body"]진짜 검색은 별도 도구로 #
SearchFilter는 작은 데이터에서 빠른 시작용입니다. 진짜 검색이 필요하면:
- PostgreSQL full-text search (
SearchVector,SearchQuery) - OpenSearch / Elasticsearch / Meilisearch 같은 검색 엔진
고급 #3 쿼리 최적화에서 본 인덱스 전략과 같이 가야 큰 데이터에서 살아남습니다.
Pagination — 페이지네이션 세 가지 #
DRF가 표준으로 제공하는 세 가지 paginator. 각자 성격이 다릅니다.
| PageNumberPagination | LimitOffsetPagination | CursorPagination | |
|---|---|---|---|
| 쿼리 | ?page=2 | ?limit=20&offset=40 | ?cursor=cD0xMjM= |
| 메타 | count, next, previous | count, next, previous | next, previous (no count) |
| 큰 테이블 | 느려짐 (count + offset) | 매우 느려짐 (offset 깊을수록) | 빠름 (인덱스 사용) |
| 정렬 변경 | 가능 | 가능 | 고정 ordering 필요 |
| 데이터 추가/삭제 | 페이지 흔들림 | 페이지 흔들림 | 안정 |
| 어울리는 경우 | 일반 UI 페이지네이션 | 클라이언트가 offset 직접 조절 | 무한 스크롤, 큰 테이블 |
PageNumberPagination #
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 #
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 #
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이 결과를 거기까지 다 세야 하기 때문입니다.
SELECT * FROM blog_post ORDER BY created_at DESC LIMIT 20 OFFSET 100000;이 쿼리는 100,020 행을 읽어서 마지막 20만 돌려줍니다. 페이지가 깊어질수록 정비례로 느려지고, 그동안 행이 추가/삭제되면 같은 글이 두 번 보이거나 빠져요 (페이지 흔들림).
CursorPagination은 인덱스로 점프 합니다.
SELECT * FROM blog_post WHERE created_at < '2026-05-01 12:00:00' ORDER BY created_at DESC LIMIT 20;마지막으로 본 행의 created_at을 기억해서, “그것보다 이전” 으로 다음 페이지를 가져옵니다. 데이터 크기와 무관하게 일정 시간.
from rest_framework.pagination import CursorPagination
class PostCursorPagination(CursorPagination):
page_size = 20
ordering = "-created_at" # 인덱스가 있는 필드여야
cursor_query_param = "cursor"class PostViewSet(viewsets.ModelViewSet):
queryset = Post.objects.all()
serializer_class = PostSerializer
pagination_class = PostCursorPaginationGET /api/posts/ # 첫 페이지
GET /api/posts/?cursor=cD0xMjM= # 다음 페이지응답은 count가 없습니다 (계산하려면 결국 전체 스캔).
{
"next": "http://.../?cursor=cD0xMjM=",
"previous": null,
"results": [...]
}언제 무엇을 #
| 상황 | 추천 |
|---|---|
| 작은 테이블 (~몇만), 페이지 UI | PageNumberPagination |
| 클라이언트가 자유롭게 offset 조절 | LimitOffsetPagination |
| 큰 테이블 (수십만 +), 무한 스크롤, 피드 | CursorPagination |
| Twitter/Instagram 스타일 타임라인 | CursorPagination |
View 별 paginator 오버라이드 #
글로벌과 다른 paginator를 쓰고 싶으면 pagination_class.
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에서 본 IsOwner는 list에 안 통합니다 — 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 캐싱이 효과적입니다.
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 쿼리 최적화의 패턴이 그대로.
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로 감쌉니다.
{
"count": 1234,
"next": "http://...?page=3",
"previous": "http://...?page=1",
"results": [
{ "id": 1, ... },
{ "id": 2, ... }
]
}이 구조가 거의 표준이라 프론트도 같은 패턴으로 받아 처리하면 됩니다. 클라이언트는 next가 null이 아닐 때까지 follow.
정리 #
이번 글에서 잡은 것:
filter_backends파이프라인 — DjangoFilter / Search / Ordering 세 백엔드django-filter—filterset_fields단순형,FilterSet클래스로gte/in/method표현OrderingFilter—ordering_fields화이트리스트, 기본orderingSearchFilter—search_fields와 prefix (^,=,@,$)- 진짜 검색은 PG full-text 또는 OpenSearch로 분리
- 세 paginator의 비교:
PageNumberPagination— 일반 UILimitOffsetPagination— 클라이언트가 offset 직접CursorPagination— 큰 테이블/무한 스크롤의 안전한 답
pagination_class로 view 별 오버라이드,None으로 끄기get_queryset()에서 사용자별/액션별 분기select_related/prefetch_related와 같이 가야 N+1 안 터짐- view-level 캐싱과의 결합
고급 #4 캐싱의 view 캐시, 고급 #3 쿼리 최적화의 인덱스/JOIN 패턴이 페이지네이션과 자연스럽게 같이 갑니다 — 큰 데이터를 다루는 도구들은 늘 한 묶음.
다음 글(#4 Celery로 비동기 작업)에서는 무거운 작업 — 이메일 발송, 외부 API 호출, 무거운 변환 — 을 응답 흐름에서 떼어내는 표준 도구 Celery를 다룹니다.