서론
Django REST Framework를 사용하면서 filterset_class를 설정하고 필터링을 적용하는 건 매우 흔한 일이다.
필터셋을 통해 URL 파라미터만으로 복잡한 조건 필터링을 쉽게 구현할 수 있고,
django-filter 라이브러리를 활용하면 필드 단위, 메서드 단위로 커스텀 필터도 자유롭게 정의할 수 있다.
필자 또한 filterset_class에 커스텀 메서드를 정의하여 필터링을 적용했다.
하지만 API 성능을 점검해보는 과정에서, 단일 응답에 수백 개의 SQL 쿼리가 발생하는 문제를 발견했다.
때문에 해당 문제를 아래 흐름으로 살펴보며 구체적인 원인을 파악해보고자 한다.
- filterset은 정확히 어떤 시점에 동작하는가?
- filterset 내부에서 Model.objects.filter()를 호출하면 무슨 일이 벌어지는가?
- get_queryset()에서 설정한 select_related, prefetch_related는 과연 유지되는가?
- 실제 SQL 쿼리는 언제 날아가며, 어느 타이밍에 평가되는가?
- 잘못된 방식으로 구현했을 때 어떤 성능 문제가 발생하는가?
본론
Cursor와 Claude Code 등 다수의 AI 에디터 등장에 따라 조금은 소외받고 계신 ChatGPT님께 고견을 여쭈었다.
1. 필터셋 클래스 정의
FilterSet을 상속받아 필터링할 필드와 조건을 정의합니다.
예를 들어 CharFilter, NumberFilter, DateFilter 등을 사용해 원하는 조건을 설정할 수 있습니다.
2. 필터 백엔드 설정
View나 ViewSet에서 filter_backends에 DjangoFilterBackend를 설정하고,
filterset_class로 위에서 정의한 필터셋을 지정합니다.
3. 요청 처리 시 필터 적용
'?name=홍길동&age__gte=20'과 같이 요청하면 DRF는 filter_queryset()에서 필터셋을 호출합니다.
이때 쿼리 파라미터를 바탕으로 queryset에 .filter(**kwargs)가 적용됩니다.
4. 결과 반환
필터링된 queryset은 serializer로 전달되어 클라이언트에 응답됩니다.
즉, DRF의 filterset은 쿼리 파라미터를 기반으로 필터 조건을 구성해 queryset에 적용해주는 역할을 하며,
FilterSet과 DjangoFilterBackend가 그 중심에 있습니다.
출처 | OpenAI. (2025). ChatGPT (4o Version) [Large language model]. https://chat.openai.com
기존엔 어떠하였는가
기본적인 코드 구조는 아래와 같았다.
문제 설명을 위해 간략화한 것이므로 참고 바란다.
# views.py
class AnalysisAPIView(ListAPIView):
serializer_class = AnalysisListSerializer
filter_backends = [DjangoFilterBackend]
filterset_class = AnalysisFilter
def get_queryset(self, *args, **kwargs):
return (
Analysis.objects.select_related(
...
)
.prefetch_related(
...
)
.all()
)# filters.py
class AnalysisFilter(FilterSet):
status = django_filters.CharFilter(method="filter_by_status")
def filter_by_status(self, queryset, name, value):
return Analysis.objects.filter(status=value) # 이게 문제였다겉으로 보기엔 아무 문제 없어 보인다. 하지만 실제 실행되는 SQL 쿼리를 추적해보면 상황이 달라진다.
View의 get_queryset() 에서 select_related, prefetch_related, .exclude()등을 열심히 적용해도, filterset 내부에서 Analysis.objects.filter() 로 새로 쿼리셋을 만들어버리는 순간 이 모든 최적화 설정이 무시된다.
해당 현상이 발생한 원인을 보다 면밀히 살펴보자.
DRF의 실행 순서
이 문제를 파악하려면 DRF(Django REST Framework)가 어떤 흐름으로 filterset을 적용하는지를 확인해야 한다.
1. get_queryset() > filter_queryset()
# django-rest-framework/rest_framework/generics.py line 52
class GenericAPIView:
...
def get_queryset(self):
queryset = self.queryset
if isinstance(queryset, QuerySet):
queryset = queryset.all()
return queryset
def filter_queryset(self, queryset):
for backend in list(self.filter_backends):
queryset = backend().filter_queryset(self.request, queryset, self)
return querysetListAPIView와 같은 DRF의 Generic View 클래스는 내부적으로 다음과 같은 순서로 queryset을 처리한다.
- get_queryset() 호출
View에 설정된 queryset 속성을 기반으로 전체 데이터셋을 반환한다.
이때 .select_related(), .prefetch_related() 등의 최적화가 함께 적용될 수 있다. - filter_queryset() 호출
View에 정의된 filter_backends 리스트를 순회하며 각 필터 백엔드의 filter_queryset() 메서드를 호출한다.
이 과정에서 전달받은 queryset을 기준으로 필터링이 수행된다. - DjangoFilterBackend의 동작
DjangoFilterBackend는 내부적으로 get_filterset()을 호출해 FilterSet 객체를 생성하고,
이후 .qs 속성을 통해 최종 필터링된 queryset을 반환한다.
이때 전달받은 queryset을 기반으로 조건 필터링이 적용되며,
View에서 설정한 .select_related() 등의 최적화도 그대로 유지된다.
즉, DRF는 View에 정의된 최적화된 queryset을 기반으로 필터 백엔드가 조건을 덧붙이는 방식으로 작동하며, FilterSet 내부에서도 이 queryset을 그대로 사용해야 최적화가 보존된다.
2. DjangoFilterBackend 동작 원리
# django-filter/django_filters/rest_framework/backends.py
class DjangoFilterBackend(BaseFilterBackend):
def get_filterset(self, request, queryset, view):
filterset_class = self.get_filterset_class(view, queryset)
if filterset_class is None:
return None
kwargs = self.get_filterset_kwargs(request, queryset, view)
return filterset_class(**kwargs)
...
def filter_queryset(self, request, queryset, view):
filterset = self.get_filterset(request, queryset, view)
if filterset is None:
return queryset
if not filterset.is_valid() and self.raise_exception:
raise utils.translate_validation(filterset.errors)
return filterset.qs중요한 포인트는 filterset_class(..., queryset=...) 형태로 View의 queryset을 그대로 전달하고 있다는 점이다. 이로 인해 filterset 내부에서도 이 queryset을 기반으로 필터링이 일어나야 최적화가 유지된다.
3. Filterset의 구조
그렇다면 Filterset은 어떤 방식으로 동작하는 것일까?
# django-filter/django_filters/filterset.py
class BaseFilterSet:
...
def filter_queryset(self, queryset):
"""
Filter the queryset with the underlying form's `cleaned_data`. You must
call `is_valid()` or `errors` before calling this method.
This method should be overridden if additional filtering needs to be
applied to the queryset before it is cached.
"""
for name, value in self.form.cleaned_data.items():
queryset = self.filters[name].filter(queryset, value)
assert isinstance(
queryset, models.QuerySet
), "Expected '%s.%s' to return a QuerySet, but got a %s instead." % (
type(self).__name__,
name,
type(queryset).__name__,
)
return queryset
@property
def qs(self):
if not hasattr(self, "_qs"):
qs = self.queryset.all()
if self.is_bound:
# ensure form validation before filtering
self.errors
qs = self.filter_queryset(qs)
self._qs = qs
return self._qs.qs 는 FilterSet에 주어진 초기 queryset을 바탕으로, 유효성 검사를 거친 파라미터(cleaned_data)를 기반으로 순차적으로 각 필터를 적용해 최종 queryset을 생성한다.
그래서 왜?
def filter_by_status(self, queryset, name, value):
return Analysis.objects.filter(status=value)여기까지 살펴보면 알 수 있었겠지만, N+1 문제가 발생한 근본적인 원인은 새로운 쿼리셋을 재정의하였기 때문이다. 이렇게 되면 View에서 설정한 .select_related()나 .exclude() 등 모든 최적화 설정은 전혀 반영되지 않는다.
왜 이렇게 하였는가
이와 같은 코드를 작성한 배경은 View의 queryset을 필터링 없이 모든 데이터를 가져온 뒤, 이를 filterset에서 필터를 적용하게 된다면 모든 데이터를 가져오는 셈이므로 비효율적이라고 생각하였다.
실제 성능상의 문제가 없을 지 살펴보니 다음과 같은 내용을 알 수 있었다.
실제 SQL 쿼리 실행 시점
Django ORM은 lazy evaluation을 따르기 때문에, 지금까지의 모든 과정은 쿼리셋 정의만 했을 뿐 실제 SQL은 아직 날아가지 않았다.
실제 Generic views 가이드에 따르면 다음과 같이 명시되어 있음을 확인할 수 있다.

즉, get_queryset() 은 쿼리셋을 생성할 뿐 실제 SQL 실행은 지연 평가(lazy evaluation)되어, .serializer.data 가 호출되는 순간에 실제적인 쿼리가 실행된다.
실제 SQL 쿼리 비교
그렇다면 실제 SQL 쿼리상으론 어떠한 차이가 있을 지 비교해보자.
def get_queryset(self):
return (
Analysis.objects
.select_related(...)
.prefetch_related(...)
.all()
)
---
def filter_by_status(self, queryset, name, value):
return queryset.filter(...)위처럼 View로부터 전달받은 queryset을 정상적으로 활용했을 경우 단일 쿼리문이 실행됨을 확인할 수 있다.
보다 정확히는 prefetch_related를 위해 추가 쿼리가 실행되긴 하였다
SELECT ...
FROM analysis
LEFT OUTER JOIN A ...
LEFT OUTER JOIN B ...
WHERE
C = FALSE
AND B.D = 'E';
SELECT *
FROM F
WHERE
B_id IN (1, 2, 3, ...)반면 전달받은 queryset이 아닌 경우는 어떠할지 살펴보자
def filter_by_status(self, queryset, name, value):
return Analysis.objects.filter(experiment__specimen__status=value)SELECT * FROM A
WHERE ...
SELECT * FROM B
WHERE
id = 1;
SELECT * FROM C
WHERE
id = 1;
SELECT * FROM D
WHERE id = 1;
...이러할 경우 분석 50건 기준 1 + (3 x 50) = 151개의 쿼리가 발생하는 결과를 초래하게 된다.
결론
즉 현재까지 내용을 요약해보면 다음과 같은 결론을 얻을 수 있다.
- DRF에서 filterset_class는 get_queryset() 결과에 조건을 덧붙이는 구조로 동작한다.
- FilterSet 내부에서 queryset.filter() 체이닝을 하면 최적화가 유지된다.
- FilterSet 내부에서 Model.objects.filter()로 새 쿼리셋을 만들면 View의 설정이 무시된다.
- 실제 SQL 쿼리는 serializer.data 평가 시점에 날아간다.