Django Query Expressions (2) - value, value_list, Value(), annotate, Aggregate(), Subquery()

정현우·2022년 5월 31일
4

Django Basic to Advanced

목록 보기
23/39

전편, Django Query Expressions (1)을 꼭 먼저 읽어주세요. 그리고 해당 편 스크롤 압박 주의!

Django Query Expressions 2편

  • offical docs 3.2 기준으로, 핵심과 예제 중심으로 정리한 글입니다. Django ORM에 깊이 관여된 로직이라 당연히 DRF에서도 해당되는 내용입니다.

장고 쿼리 표현식 (Django Query Expressions)은 업데이트, 생성, 필터링, 순서 기준, 주석 또는 집계에서 사용할 수 있는 값 또는 계산을 설명한다. (update, create, filter, order by, annotation, or aggregate) 해당 부분을 살펴보자!

Build-in 장고 표현식

Value()

  • class Value(value, output_field=None)

  • Value() 개체는 식에서 가능한 가장 작은 구성 요소인 단순 값을 나타낸다. 식 내에서 정수, boolean 또는 string의 값을 나타내야 하는 경우 Value()으로 값을 바꿀 수 있다.

  • Value()을 직접 사용할 필요는 거의 없다. F('field') + 1을 쓰면 Django는 1을 Value()으로 암시적으로 감싸서 간단한 값을 더 복잡한 식에 사용할 수 있게 만든다.

  • Lower('name')과 같이 필드의 이름으로 해석하기 때문에, 문자열을 "식에 전달하려면" Value()을 사용해야 한다. 아래의 예시를 살펴보고 가자. 댓글로 아래 예시를 해석해주리라 믿습니다 여러분

def get_queryset(self):
        date = self._extract_date()
        user = self._extract_user()

        queryset = models.AbsenceType.objects.values("id")
        queryset = queryset.annotate(date=Value(date, DateField()))
        queryset = queryset.annotate(user=Value(user.id, IntegerField()))
        queryset = queryset.annotate(
            pk=Concat(
                "user", Value("_"), "id", Value("_"), "date", output_field=CharField()
            )
        )

values

참고로 value 표현식과 values는 전혀 다르다. 해당 부분을 다루기 위해 일부러 query expression에 넣었다.

  • 위 Value는 표현식 종류의 일환이며 사실상 직접 사용하는 경우는 거의 없고 django단에서 알아서 처리해 준다.

  • django.db.model에 가보면 BaseManager 를 볼 수 있다. 그리고 Model의 Object들은 (즉 model.objects ... 로 접근하는 case 생각) objects: BaseManager 로 정의되어 있어서 BaseManager (class)에 정의된 annotate, values 등을 접근해서 사용이 가능한 것 이다.

  • 이 values는 Queryset 값을 dict형태로 리턴하며, "원하는 필드" 만 가져올 수 있다. 그리고 values() 함수는 **expression: Any를 매개변수로 받아서 annotate() 등이 사용이 가능하다.

values_list

  • values와 기본적으로 동일하지만 Queryset 값을 key, value dict가 아닌 tuples 형태 list로 가져올 수 있다. 역시 특정 필드만 가져올 수 있다.

  • 특이하게 매개변수에 flat: Boolean 이 있는데 해당 값은 리턴 값을 리스트로 줄지, 튜플로 줄지 정하는 값이다. 기본값은 False이기 때문에 튜플로 리턴한다. 하지만 이 flat이 true일 때는 가져오려는 특정 필드가 여러개 일때는 사용할 수 없다.

### values

Post.objects.filter(id__lt=8).values()
# <QuerySet [{'id': 5, 'title': 'post #1'}, {'id': 6, 'title': 'title #1'}, {'id': 7, 'title': 'title #2'}]>

Post.objects.filter(id__lt=8).values('title')
# <QuerySet [{'title': 'post #1'}, {'title': 'title #1'}, {'title': 'title #2'}]>


### values_list

Post.objects.filter(id__lt=8).values_list('title')
# <QuerySet [('post #1',), ('title #1',), ('title #2',)]>

Post.objects.filter(id__lt=8).values_list(flat=True)
# <QuerySet [5, 6, 7]>

Post.objects.filter(id__lt=8).values_list('title', flat=True)
# <QuerySet ['post #1', 'title #1', 'title #2']>
  • 사실 쿼리 튜닝의 기본은 SELECT * 를 지양하는 것이다. 필요한 DB 컬럼만 가져오는 것이 DB 부하를 줄이는 가장 빠르고 기본적인 방법이기 때문에 value, value_list를 적절하게 활용하는 것이 Django에서 Query 튜닝을하는 기본이 될 것이다.

annotate, Aggregate()

  • Aggregate 단어 자체는 프로그래밍, 전산 등에서 특별한 의미로 사용이 된다. 공학적인 글을 참고해보자. (java기준 설명이긴 하다).

  • Django에서 Model object의 Aggregate는 "GROUP BY 절이 필요하다는 것을 쿼리에 알리는 Func() 식의 특수한 경우입니다. Sum() 및 Count()와 같은 모든 Aggregate 함수는 Aggregate()에서 상속됩니다." 라고 공식문서에서 말한다.

  • 우선 Annotate을 먼저 살펴보자. 사전적 의미는 '주석'이다. SQL의 as와 가장 비슷하다. 전편에서도 예시들이 anotate를 사용하는 것을 많이 봤을 것이다. 한마디로 "새로운 요소를 쿼리에(object) 추가하고 해당 요소를 속성으로 접근, 사용할 수 있도록 해주는 것"이 아닐까 한다.

  • 그래서 이 Annotate는 혼자만으로는 큰 의미를 가질 수 없다. annoteate를 다양한 표현식과 엮어서 사용을 한다. 물론 as로 묶으면서 내부적으로 다양한 함수를 쓸 수 있는 것이다.

Annotate는 entire queryset 대상으로 연산을 하고, Annotate는 each item(in the queryset) 대상으로 summary 하거나 연산을 한다는 점이 가장 큰 차이점이다.

  • class Aggregate(*expressions, output_field=None, distinct=False, filter=None, default=None, **extra)

Template

  • 기본적으로 Func()에 GROUP BY가 가미된 class이기 때문에 template이라는 부분이 존재한다. from django.db.models import Aggregate 에 있는 Aggregate의 core를 살펴보자. 아래와 같이 되어있다. Func를 상속 받고 있다. 해당 부분을 알려면 앞 선 시리즈의 Func를 꼭 알아야 한다.
class Aggregate(Func):
    template = '%(function)s(%(distinct)s%(expressions)s)'
    contains_aggregate = True
    name = None
    filter_template = '%s FILTER (WHERE %%(filter)s)'
    window_compatible = True
    allow_distinct = False

	# ... 생략
  • template을 '%(function)s(%(distinct)s%(expressions)s)'. 로 정의하고 있다. 해당 부분을 살짝 살펴보자.

function 부분

  • 생성할 집계 함수를 설명하는 클래스 특성이다. 특히, 함수는 템플릿 내의 함수 자리 표시자로 보간됩니다. 기본값은 None이다.

window_compatible

  • 대부분의 집계 함수는 원본 식으로 사용할 수 있으므로 기본값은 True다. 나중에 Window functions을 살펴볼 때 더 자세하게 살펴보자!!

allow_distinct

  • 이 집계 함수가 "고유한 키워드 인수를 전달할 수 있는지 여부를 결정" 하는 클래스 속성이다.

  • False(기본값)로 설정하면 TypeError(유형 오류)가 발생한다. True는 해당 오류를 발생시키지 않고 pass한다.

filter

objects로 다루는 실제 예제

  1. 모델 하나 대상으로 전체 집계 함수 실시
from django.db.models import Avg, Max, Min

>>> Point.objects.all().aggregate(Max('saved_point'))
{'saved_point__max': 313500}

>>> Point.objects.all().aggregate(Min('saved_point'))
{'saved_point__min': 313}

>>> Point.objects.all().aggregate(Avg('saved_point'))
{'saved_point__avg': 112536.5625}
  1. 실제로는 다양한 class와 혼합되어서 사용되는 aggregate
class Product(TimeStampedModel):
	
    name = models.CharField("이름", max_length=150, unique=True)
    price = models.IntegerField("가격")

    def __str__(self):
        return self.name


class OrderLog(models.Model):
    
    created = models.DateTimeField()
    product = models.ForeignKey('products.Product', related_name='order_log', on_delete=models.CASCADE)

OrderLog로 부터 product 가져와서 created 컬럼 살펴보기

logs = OrderLog.object.values(
   'created', 'product__name', 'product__price'
 )
 # 외래키로 참조되어 있는 필드를 불러오기 위해선 더블언더바(__)를 사용하면된다.
 # order_log의 외래키 관계에 있는 product의 name을 갖고 오기 위해선 product__name
 

for log in logs:
    # print(log.created) 이렇게 안됨
    print(log['created']

출력에 product__ 가 붙는게 보기 싫다. annotate 활용

from django.db.models import F

logs = OrderLog.objects.annotate(
   name=F("product__name"),
   price=F("product__price")
   ).values(
   'created', 'name', 'price'
   )
   
for log in logs:
    print(log)

"전체 판매 데이터"의 총합과 날짜 별 합

from django.db.models import Sum

# 위에서 annotate된 QuerySet을 사용하면된다.
logs.aggregate(total_price=Sum('price'))
>>> {'total_price': 488000}

from django.db.models import Sum

# 위에서 annotate된 QuerySet을 사용하면된다.
daily_sum_list = logs.values(
	'created'
    ). annotate(
	daily_sum = Sum('price')     
)

for data in daily_sum_list:
    print(data)

날짜별로 어떤 제품이 몇개 팔렸는지

from django.db.models import Count

# 위에서 annotate된 QuerySet을 사용하면된다.
product_cnt_list = logs.values(
	'created','name'
    ). annotate(
	product_cnt = Count('name')     
)

for data in product_cnt_list:
    print(data)

마지막으로 특정 제품의 날짜별 판매 수량을 가져오기

from django.db.models import Count

# 위에서 annotate된 QuerySet을 사용하면된다.
coupang_daily_cnt_list = qs.filter(
     name="쿠팡"
     ).values(
	'created','name'
    ). annotate(
	product_cnt = Count('name')     
)

for data in coupang_daily_cnt_list:
    print(data)

Subquery()

  • 특정 QuerySet 을 활용해 서브쿼리를 만들 수 있다. class Subquery(queryset, output_field=None) 을 활용하면 된다.
>>> from django.db.models import OuterRef, Subquery
>>> newest = Comment.objects.filter(post=OuterRef('pk')).order_by('-created_at')
>>> Post.objects.annotate(newest_commenter_email=Subquery(newest.values('email')[:1]))
  • 위 예제에서 특정 post의 댓글을 날짜 역순으로 다 가져온 queryset이 있다. Post 모델에 해당 Queryset(newest)를 활요해서 subquery를 날리는 것이다. 위 예제는 아래 SQL과 같다.
SELECT "post"."id", (
    SELECT U0."email"
    FROM "comment" U0
    WHERE U0."post_id" = ("post"."id")
    ORDER BY U0."created_at" DESC LIMIT 1
) AS "newest_commenter_email" FROM "post"

위 예제에서 OuterRef

  • 위 예시에서 서브쿼리가 (newest 가) post의 pk를 참조한다. 즉 외부에서 참조되는 필드를 참조할때 OuterRef를 활용해서 정상적으로 외부 참조를 하게 해줘야 한다.

  • F() 와 동일하게 작동하지만, 유효한 필드를 참조하는지 검사가 수행되지 않는다!

Subquery 중 Exisits

  • SQL의 EXISTS 문을 그대로 활용한 subquery다. 아래 예시를 보면 바로 알 수 있다.
>>> from django.db.models import Exists, OuterRef
>>> from datetime import timedelta
>>> from django.utils import timezone
>>> one_day_ago = timezone.now() - timedelta(days=1)
>>> recent_comments = Comment.objects.filter(
...     post=OuterRef('pk'),
...     created_at__gte=one_day_ago,
... )
>>> Post.objects.annotate(recent_comment=Exists(recent_comments))
  • 최근 댓글을 가지고 오는 ORM query는 아래와 SQL 구문과 동일하다!
SELECT "post"."id", "post"."published_at", EXISTS(
    SELECT (1) as "a"
    FROM "comment" U0
    WHERE (
        U0."created_at" >= YYYY-MM-DD HH:MM:SS AND
        U0."post_id" = "post"."id"
    )
    LIMIT 1
) AS "recent_comment" FROM "post"

서브쿼리에서 aggregates 사용하기

  • aggregates를 서브쿼리에서 활용할 수 있고 aggregates의 그룹화를 올바르게 수행하려면 filter(), values(), annotate() 등의 조합이 필요하다.

  • 위 예시에서 계속 본 Post, Comments 모델 둘 다 length 필드가 있다고 가정하고 아래 예시를 보자.

>>> from django.db.models import OuterRef, Subquery, Sum
>>> comments = Comment.objects.filter(post=OuterRef('pk')).order_by().values('post')
>>> total_comments = comments.annotate(total=Sum('length')).values('total')
>>> Post.objects.filter(length__gt=Subquery(total_comments))
  • comments는 참조할 post key가 같은 것을 정렬된 순서로 가져오고, total_comments는 comments queryset을 활용해 'length' 라는 필드의 합을 가져온다.

  • annotate를 활용한 total_comments는 마지막 ORM query에서 Subquery를 통해 aggregate를 실시한다. 가이드에선 하위 쿼리 내에서 집계를 수행하는 유일한 방법이라고 설명한다.

Raw SQL expressions

Django에서는 Raw SQL을 "보안적, 퍼포먼스" 측면에서 추천하지 않는다.

  • 아래 예시를 살펴보면 raw sql query를 어떻게 활용할 수 있는지 볼 수 있다.
>>> from django.db.models.expressions import RawSQL
>>> queryset.annotate(val=RawSQL("select col from sometable where othercol = %s", (param,)))

>>> queryset.filter(id__in=RawSQL("select id from sometable where col = %s", (param,)))
  • 보면 알 수 있듯 param 값에 의해 query가 조작이 되어지는데 해당 param값이 client, reuqest로 부터 받아오는 값이면 바로 SQL injection 공격에 매우 취약해 진다.

  • escape 등의 철저한 벨리데이션이 필요하다. 사실 param 값 자체를 자유로운 값을 사용하지 못하게 하는 것이 최고의 방법이다.


마무리

  • expression의 Value / 그리고 values, values_list

  • annotate, aggregate 그리고 subquery

  • 마지막으로 raw sql

  • 사실상 하나만 알아서는 django ORM을 활용한 다양한 query를 하는 것은 매우 어렵다. 실제로 사용하는 ORM query는 여러가지 function을 사용해서 하나의 queryset을 도출하는 경우가 대부분이기 때문이다.

  • 게다가, 아이러니하게 (어쩌면 당연한) SQL을 모르면 ORM을 활용하는 것은 분명한 한계가 있다. group by와 subquery, 그리고 MATCH등의 다양한 SQL을 활용하는 경험을 하는 것이 결국 Django ORM query를 잘 짤 수 있는 방법인 것 같다.

    • 특히 경험으로는, "원하는 DB - 데이터 아웃풋" 을 두고 먼저 SQL로 최적화를 한 뒤에 Django ORM으로 옮길때 가장 좋은 아웃풋이 나온 것 같다.
  • 사실 여기서 다루지 못한 window, Expression API 등이 있다. 해당 부분은 와닿는 예제나 사용법이 있다면 꼭 다뤄야 겠다. 생각보다 Django는 파면 팔 수 록 깊은 부분까지 고민의 흔적이 많이 느껴지는 Full-stack Framework이다. Model만 다루는데에도 깊이 있게 알려면 정말 많은 부분을 살펴봐야하는 것 같다.

profile
도메인 중심의 개발, 깊이의 가치를 이해하고 “문제 해결” 에 몰두하는 개발자가 되고싶습니다. 그러기 위해 항상 새로운 것에 도전하고 노력하는 개발자가 되고 싶습니다!

0개의 댓글