Django의 Annotate쿼리를 활용한 DRF와의 연계, Queryset의 중요성

antoliny0919
2024823
필자가 얼마전에 마주쳤던 문제점이 있다.
지금 생각해 보면 굉장히 간단한 문제인데 특정 모델 쿼리셋에 ordering을 적용하고 싶었다.
하지만 ordering의 기준이 될 field가 해당 모델에 정의되어 있지 않은 값이고 집계함수를 통해 얻을 수 있는 값이었다.
python
COPY

from django.db import models
from django.contrib.auth import get_user_model

User = get_user_model()

# Create your models here.

class Tag(models.Model):
  name = models.CharField(max_length=128, unique=True)
  
  def __str__(self):
    return self.name

class Stock(models.Model):
  code = models.CharField(max_length=24, unique=True)
  name = models.CharField(max_length=128)
  price = models.DecimalField(max_digits=10, decimal_places=2)
  active = models.BooleanField(default=True)
  dividend_rate = models.DecimalField(decimal_places=2, max_digits=4)
  tags = models.ManyToManyField(Tag)
  
  def __str__(self):
    return self.name

class StockSet(models.Model):
  code = models.ForeignKey(Stock, related_name='stock_set', on_delete=models.CASCADE, to_field='code')
  quantity = models.IntegerField()
  purchase_date = models.DateField(auto_now_add=True)
  owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='stock_set', to_field='wallet')
  
  def __str__(self):
    return f'{self.owner} - {self.code}'
위 모델은 필자가 겪었던 상황과 비슷하게 만든 예시 모델로 앞으로 위 모델들을 통해서 설명하도록 하겠다.
간단하게 설명하자면 필자는 각 Tag를 가진 주식중에 active필드가 True인 객체가 몇개인지 궁금했고 해당 값들을 내림차순으로 정렬한 값을 Serializer를 통해 반환하려고 했다.
가장 먼저 필자가 생각해낸 방법은 SerializerMethodField를 사용해서 해결하는 방법이였다.
python
COPY

# views.py

class TagListView(ListAPIView):
  queryset = Tag.objects.all()
  serializer_class = TagSerializer

# serializers.py

class TagSerializer(serializers.ModelSerializer):
  
  tag_cnt = serializers.SerializerMethodField()
  
  def get_tag_cnt(self, obj):
    stock_count = Stock.objects.filter(Q(tags__in=[obj.id]) & Q(active=True)).count()
    return stock_count
  
  class Meta:
    model = Tag
    fields = ('name', 'tag_cnt',)
위와 같은 소스코드를 작성했을때 결과를 확인해보면
no-img
내림차순으로 정렬하는것 빼고는 필자의 목표를 달성했다.
하지만 이 상태에서 내림차순으로 데이터를 반환하려면 View단계에서 get메서드를 오버라이딩하여 Serializer에서 반환된 값에 파이썬 정렬 함수를 사용해서 해결해야 한다고 생각했다.
python
COPY

class TagListView(ListAPIView):
  queryset = Tag.objects.all()
  serializer_class = TagSerializer
  
  def get(self, request, *args, **kwargs):
    response = super().get(request, *args, **kwargs)
    response.data = sorted(response.data, key=lambda x:-x["tag_cnt"])
    return response
하지만 위와같은 코드를 작성했을때 원하는 결과를 얻을 수는 있었지만 Query의 ORDER_BY가 아닌 Python의 sorted함수를 사용하여 결과를 정렬한것이 속도면에서 굉장히 비효율적이고 반환값의 형태만 변경하기 위해 get메서드를 오버라이딩 한 부분들이 전부 마음에 들지 않았다.
그리고 가장 큰 부분은 필자는 URL 쿼리스트링(?ordering=tag_cnt)을 통해 "tag_cnt"값에 따라 오름차순, 내림차순이 적용된 값을 반환할 수 있도록 하려고 했지만 위와같은 코드에서는 RestFramework의 OrderingFilter를 추가했을때 "tag_cnt"기준으로 ordering이 동작하지 않는다.
python
COPY

from rest_framework.filters import OrderingFilter

class TagListView(ListAPIView):
  queryset = Tag.objects.all()
  serializer_class = TagSerializer
  filter_backends = [OrderingFilter]
  ordering_fields = ['tag_cnt']
  
  def get(self, request, *args, **kwargs):
    response = super().get(request, *args, **kwargs)
    response.data = sorted(response.data, key=lambda x:-x["tag_cnt"])
    return response
실제로 ?ordering=tag_cnt 쿼리스트링을 추가하여 다시 요청해보면 에러가 반환되며 반환되는 html화면을 보면 친절하게 Django는 ordering이 가능한 인자들을 보여주며 "tag_cnt"는 사용할 수 없는 키워드라는걸 알 수 있다.
no-img
이와 같은 결과를 반환하는 이유에 대해서는 차차 이 포스트에서 설명해 나갈것이다.
아무튼 중요한건 SerializerMethodField로 문제를 해결할 수 없다는 점이였다.(필자의 부족한 지식 내에서)
다른 해결방법을 찾아내기 전에 DRF Filtering와 Serializer의 동작을 조금 살펴보면 문제를 어떻게 해결해야할 수 있을지 중요한 열쇠가 어떤 부분인지 알 수 있다.
 
DRF Ordering과 Serializer가 다루는 데이터
 

Ordering이 가능한??

 
Django에서 Ordering은 View클래스에 지정된 filter_backends값을 통하여 각 Filter클래스의 filter_queryset을 호출하는식으로 동작한다.
즉 필자같은 경우에는 OrderingFilter의 filter_queryset메서드가 호출될것이다.
python
COPY

class OrderingFilter(BaseFilterBackend):
    ...
    def filter_queryset(self, request, queryset, view):
        ordering = self.get_ordering(request, queryset, view)
        if ordering:
            return queryset.order_by(*ordering)

        return queryset
OrderingFilter클래스의 filter_queryset메서드를 보면 딱히 문제가 될 형식으로 요청하지 않는한 인자로 전달한 값으로 order_by쿼리를 실행한다.
결국 필자의 queryset에 order_by를 적용한 Tag.objects.all().order_by("tag_cnt")가 실행되는 것이다.
어쩌면 OrderingFilter클래스의 Ordering을 적용할 수 있는 조건이 되는 부분은 쿼리에 order_by메서드를 실행했을 때 전달해준 인자가 유효한것인가? 로 볼 수 있다.
필자의 경우에서 "tag_cnt"는 유효하지 않은 인자였기 때문에 요청이 실패하는걸 확인했었다.
그렇다면 order_by를 실행할 수 있는 유효한 인자가 되기 위한 조건은 무엇일까?
위 질문에 대한 해답은 간단하게 생각하면 해결할 수 있는 문제였다.
위 OrderingFilter클래스의 filter_queryset은 결국 queryset의 order_by쿼리를 사용한다는 점을 설명했다.
즉 필자의 View에 설정된 queryset --> Tag.objects.all()에 order_by가 적용되고 유효하려면 Tag.objects.all()을 실행했을때 결과인 QuerySet객체들이 전부 "tag_cnt"라는 값에 접근할 수 있어야 함을 의미한다.
간단하게 queryset은 하나의 SQL Query의 실행문(SELECT ...)을 거치며 결과인 행들을 담고 있기 때문에 실행된 SQL Query의 결과에 "tag_cnt"라는 열이 존재하면 된다.
그렇기 때문에 필자의 경우에는 아래와 같은 Query를 Django ORM으로 표현하여 queryset변수에 할당하면 문제를 해결할 수 있다.
sql
COPY

SELECT t.id, t.name, 
COUNT(CASE WHEN st.active = True THEN sst.stock_id END) AS tag_cnt 
FROM stock_tag t 
JOIN stock_stock_tags sst ON (t.id = sst.tag_id) 
JOIN stock_stock st ON (sst.stock_id = st.id) GROUP BY t.id;
위와 같은 Query를 Django ORM으로 표현하면 아래와 같다.
python
COPY

class TagListView(ListAPIView):
  queryset = Tag.objects.all().annotate(tag_cnt=Count("stock", filter=Q(stock__active=True)))
  ...
  
이제 다시 ordering 쿼리 스트링을 추가한 뒤 요청하여 "tag_cnt"기준으로 오름차순, 내림차순으로 데이터가 정렬이 되는지 확인해보겠다.
no-img
위와 같이 필자가 원하는대로 정확히 동작하는걸 확인할 수 있다.
지금까지 출력되는 결과를 보면 "tag_cnt"라는 키가 추가되어 있다.
이와 같이 출력되는 이유는 필자가 위 url과 연결된 View의 Serializer클래스에 해당 필드를 직접 추가했기 때문이다.
 
Serializer와 queryset
 
필자의 TagListView에 지정된 Serializer클래스인 TagSerializer의 코드를 보면
python
COPY

class TagSerializer(serializers.ModelSerializer):
  
  tag_cnt = serializers.IntegerField()
  
  class Meta:
    model = Tag
    fields = ('name', 'tag_cnt',)
annotate쿼리로 추가한 열 "tag_cnt"를 직접 추가했다.
이러한 부분을 보면 Serializer도 결국은 queryset과 연관이 있고 queryset을 기반으로 serializer field가 될 수 있음을 의미한다.
실제로 확인해보자.
 

Serializer Field 조건

 
python
COPY

class ListModelMixin:
    """
    List a queryset.
    """
    def list(self, request, *args, **kwargs):
        queryset = self.filter_queryset(self.get_queryset())

        page = self.paginate_queryset(queryset)
        if page is not None:
            serializer = self.get_serializer(page, many=True)
            return self.get_paginated_response(serializer.data)
        serializer = self.get_serializer(queryset, many=True)
        return Response(serializer.data)

class GenericAPIView(views.APIView):
    ...
    def get_serializer(self, *args, **kwargs):
        """
        Return the serializer instance that should be used for validating and
        deserializing input, and for serializing output.
        """
        serializer_class = self.get_serializer_class()
        kwargs.setdefault('context', self.get_serializer_context())
        return serializer_class(*args, **kwargs)

    def get_serializer_class(self):
        """
        Return the class to use for the serializer.
        Defaults to using `self.serializer_class`.

        You may want to override this if you need to provide different
        serializations depending on the incoming request.

        (Eg. admins get full serialization, others get basic serialization)
        """
        assert self.serializer_class is not None, (
            "'%s' should either include a `serializer_class` attribute, "
            "or override the `get_serializer_class()` method."
            % self.__class__.__name__
        )

        return self.serializer_class
필자의 TagListView는 위 ListModelMixin을 상속받은 클래스로 GET요청이 왔을때 ListModelMixin클래스의 list메서드가 동작하게 된다.
list메서드에서 serializer가 호출되는 부분인 self.get_serializer(queryset, many=True)를 보면 get_serializer를 통해 View에 지정된 serializer_class변수 값에 queryset을 전달한다.
그렇기 때문에 사실상 self.get_serializer(queryset, many=True)TagSerializer(queryset, many=True)와 같다.
그리고 마지막으로 Response클래스 객체 생성 인자로 Serializer객체 data프로퍼티를 전달한다.
 

Serializer.data

 
Serializer의 data프로퍼티는 DRF를 사용해본 사람들에겐 굉장히 익숙한 프로퍼티로HTTP Response에 담길 클라이언트에게 전달될 데이터로 전환하는 과정이 담겨있다.
필자의 Serializer인 TagSerializer는 "tag_cnt"라는 필드를 추가했고 하위 Meta클래스를 보면 fields값으로 ("name", "tag_cnt")가 할당되어 있다.
이와 같은 Serializer에 data프로퍼티 속성에 접근했을때의 동작과정에 대해 잠깐 설명하겠다.
python
COPY

class SerializerMetaclass(type):
   ...
   def to_representation(self, instance):
        """
        Object instance -> Dict of primitive datatypes.
        """
        ret = {}
        fields = self._readable_fields
        for field in fields:
            try:
                attribute = field.get_attribute(instance)
            except SkipField:
                continue

            # We skip `to_representation` for `None` values so that fields do
            # not have to explicitly deal with that case.
            #
            # For related fields with `use_pk_only_optimization` we need to
            # resolve the pk value.
            check_for_none = attribute.pk if isinstance(attribute, PKOnlyObject) else attribute
            if check_for_none is None:
                ret[field.field_name] = None
            else:
                ret[field.field_name] = field.to_representation(attribute)
        return ret
위 to_representation메서드는 queryset의 결과인 모든 Tag객체들이 거치게 되는 메서드이다.
파라미터 instance가 Tag객체이며 self._readable_fields의 값인 fields는 TagSerializer에 하위 Meta클래스 fields값에 할당된 "name", "tag_cnt" 필드의 DRF Field클래스가 담겨있다.
그 뜻은 name은 Serializer에 직접 정의하지 않았지만 모델 필드에 의해 CharField, tag_cnt는 TagSerializer에 직접 정의한 대로 IntegerField가 된다.
이후 코드를 보면 fields를 반복하여 각 필드에 get_attribute메서드를 호출하며 instance를 인자로 전달하는데
python
COPY

def get_attribute(instance, attrs):
    """
    Similar to Python's built in `getattr(instance, attr)`,
    but takes a list of nested attributes, instead of a single attribute.

    Also accepts either attribute lookup on objects or dictionary lookups.
    """
    for attr in attrs:
        try:
            if isinstance(instance, Mapping):
                instance = instance[attr]
            else:
                instance = getattr(instance, attr)
        except ObjectDoesNotExist:
            return None
        if is_simple_callable(instance):
            try:
                instance = instance()
            except (AttributeError, KeyError) as exc:
                # If we raised an Attribute or KeyError here it'd get treated
                # as an omitted field in `Field.get_attribute()`. Instead we
                # raise a ValueError to ensure the exception is not masked.
                raise ValueError('Exception raised in callable attribute "{}"; original exception was: {}'.format(attr, exc))

    return instance
get_attribute메서드는 getattr함수를 통해 인자로 전달된 instance에 attr속성이 존재하는지 확인한다.
여기서 instance는 queryset의 결과인 QuerySet에서 반복문을 통하여 전달된 각 Tag클래스 객체에 해당하며 attr은 TagSerializer의 fields에 할당된 "name"과 "tag_cnt"가 된다.
결론은 QuerySet에 존재하는 모든 Tag클래스에 "name", "tag_cnt"속성이 존재하는지 확인하는 과정을 진행한다는 뜻이다.
만약 필자가 queryset을 이전과 달리 아래와 같이 변경하면 각 태그 객체에 "tag_cnt"속성이 사라지게 된다.
python
COPY

class TagListView(ListAPIView):
  # queryset = Tag.objects.all().annotate(tag_cnt=Count("stock", filter=Q(stock__active=True)))
  queryset = Tag.objects.all()
  serializer_class = TagSerializer
  filter_backends = [OrderingFilter]
  ordering_fields = ['tag_cnt']
 이 상태에서 똑같은 TagSerializer를 사용하여 요청했을때 위에서 봤던 get_attribute과정에서 이전과 달리 "tag_cnt"속성을 찾지 못해 AttributeError가 발생하게 된다.
no-img
필자가 이러한 부분을 설명한 이유는 Serializer또한 Ordering과 같이 queryset을 기반으로 동작한다고 볼 수 있기 때문이다.
그 뜻은 Django 프레임워크를 사용하면서 자신이 원하는 형태의 데이터를 얻기 위해서는 머릿속에 생각나는 Database Query문을 Django ORM을 통해 표현할 줄 알아야 한다는 것이고 이 과정이 굉장히 중요한 부분이라는 점이다.
어쩌면 이 부분이 속도와 가장 깊게 관련된 부분이기도 하기 때문에 Django 프레임워크 초보를 벗어나기 위한 하나의 출발점이 아닌가 싶다.
 

필자의 이야기

 
필자또한 포스트 초반에 이야기했듯이 Ordering과 관련된 문제점을 겪고 고수분에게 소중한 답변을 얻어 문제를 해결한 뒤 나중에서야 queryset이 이렇게나 중요한 역할을 한다는걸 알게되었다.
당시 필자는 ORM을 정말 정말 활용하지 못하는 수준이었기에 이런 부분들이 충격으로 다가왔고 잠깐의 현자타임과 멍청하기 그지없는 나의 수준을 되돌아보며 아직도 난 멀었구나... 라는 생각과 함께 이 포스트를 작성했다.
아무튼 앞으로 Django의 ORM과 관련된 포스트들을 많이 작성할 예정이다. Django를 잘 다루는 핵심중 하나가 queryset을 효율적으로 잘 표현하는 것에 있다고 생각했기 때문이다.