Django Query Expressions (1) - F(), Func(), Q(), 그리고 and, or, not

정현우·2022년 5월 28일
5

Django Basic to Advanced

목록 보기
22/37

Django Query Expressions 1편

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

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

Build-in 장고 표현식

F() expressions

  • F() 객체는 모델의 필드 혹은 어노테이트된 열의 값을 나타낸다. 실제로 데이터베이스에서 Python 메모리로 가져오지 않고, 모델 필드 값을 참조하고 이를 데이터베이스에서 사용하여 작업할 수 있다.

  • 즉, 우리가 python 코드로 작성해 python 런타임 환경에서 연산을 해서 DB에 적용하는 것이 아니라, F() object를 통해 해당 연산을 하는 Query를 만들 수 있다는 것이다.

공식문서에 있는 아래 예제를 살펴보자.

# [CASE1]
reporter = Reporters.objects.get(name='Tintin')
reporter.stories_filed += 1
reporter.save()

---
# [CASE2]
from django.db.models import F

reporter = Reporters.objects.get(name='Tintin')
reporter.stories_filed = F('stories_filed') + 1
reporter.save()

reporter = Reporters.objects.get(pk=reporter.pk)
# 또는 간결하게 아래처럼
reporter.refresh_from_db()
  • case1, case2 모두 stories_filed 값을 +1 하고 싶어한다. 연산을 처리하는 주체에 집중해서 차이점을 살펴보자.

  • case1

    • reporter를 python 런타임 - 메모리 값으로 가져와 해당 객체를 메모리에서 +1 (코드단에서, 해당 로직은 아직 DB에 반영이 안되는 것임) 시키고 save를 통해 DB update를 한다.
    • 이 경우 코드단에서 (Python 런타임 메모리) 처리를 하기 때문에, 2개의 요청이 동시에 왔을때, race-condition이 발생할 수 있다. 사실 이 부분은 시리즈에서 다뤘다.
    • +1을 도중에 동시에 요청이 들어왔을 때 +2가 되어야 할 값이 +1 만 되는 결과가 일어날 수 있는 것이다.
  • case2

    • case1과 역할은 동일하다. 하지만 F객체를 만나면 연산자를 오버라이딩하여 캡슐화된 SQL문을 생성한다.
    • 그리고 그 생성한 SQL을 save를 통해 호출하는 것이다. 그리고 왜 또 get을 통해 object를 가져올까? python 런타임 메모리에서 직접 한 연산이 아니기 때문에 해당 코드에서는 값이 +1 되었는지 모른다. 그 전의 값을 reporter 변수가 가지고 있는 것이다.
    • 그래서 다시 가져오거나 또는 refresh_from_db를 통해 다시 불러와야 한다.
  • 하나의 객체 상태로만 가능한 연산이 아니다. 아래와 같이 응용이 가능하다.
# filter로 특정 모델객체들 다 들고와서 값 +1 하기
reporter = Reporters.objects.filter(name='Tintin')
reporter.update(stories_filed=F('stories_filed') + 1)

# 모든 모델 객체 값들 +1 로 업데이트 하기
Reporter.objects.all().update(stories_filed=F('stories_filed') + 1)
  • Model.objects.all()을 통해 queryset을 가져오고, for 문을 통해 하나의 필드값을 +1 하는 건, 하나의 로우만 계속해서 업데이트하는 쿼리를 날린다. 당연히 위 로직보다 느리고, DB에도 부하가 많이가는 작업이다.

그래서 언제 사용하면 좋은가?

Queryset For문 멈춰!!
기본적으로 데이터베이스에서 해당 연산을 처리, 그에 따라 Query 튜닝 가능하며 그렇기 때문에 경쟁 조건 (race condition)을 피할 수 있음을 기억하고 있어야한다.

1. Avoiding race conditions using F()

2. Using F() in filters

  • 공식 문서에서 filter안의 F 객체 예제가 다양하게 있다. Entry.objects.filter(number_of_comments__gt=F('number_of_pingbacks') * 2) 예제와 같이, 핵심은 'DB에게 연산을 처리할 수 있는 SQL 만들어 준다' 에 초점이 맞춰져 있다.

3. Using F() with annotations

  • company = Company.objects.annotate(chairs_needed=F('num_employees') - F('num_chairs')) 예제와 같이 annotate을 활용해 Company모델이 가지고 있는 필드 num_employees, num_chairs를 연산해서 chairs_needed라는 필드로 만들어서 (create dynamic fields) 가져올 수 있다.

  • 즉 annotate이 as 와 비슷한 역할을 할 때 F를 통해 연산된 동적필드를 만들 수 있다는 것이다.

  • 만약 필드가 다른 타입이라면, F() 객체에서는 output_field를 지정할 수 없으니 아래처럼 ExpressionWrapper로 해당 표현식을 감싸줘야 한다.

from django.db.models import DateTimeField, ExpressionWrapper, F

Ticket.objects.annotate(
    expires=ExpressionWrapper(
        F('active_at') + F('duration'), 
        output_field=DateTimeField()
    )
)

4. Using F() to sort null values

  • 정렬할 때 에도 기가 막히게 가능하다. Company.objects.order_by(F('last_contacted').desc(nulls_last=True)) 와 같이 Company 모델 인스턴스들을 last_contacted 필드를 기준으로 내림차순 정렬하되, 해당 필드가 null 값을 가졌을 경우에 제일 뒤로 보내는 쿼리를 아쌉하게 만들 수 있다.

Func()

  • "Func() 식은 MERGE 및 LOWER와 같은 데이터베이스 함수 또는 SUM과 같은 Aggregate를 포함하는 모든 식들의 기본 유형이다." 라고 공식문에서 말한다. 즉 데이터베이스의 함수를 표현하는 것의 Base가 Func object라는 것이다.

  • 즉, Djnago가 지원하지 않는 DB 함수를 쓸 땐, DB에서 데이터를 받아온 다음 파이썬 코드를 작성해야 하며, Func()를 사용해 장고에서 지원하지 않는 DB 함수를 구현하면 된다는 것이다.

class Lower(Func):
    function = 'LOWER'

queryset.annotate(field_lower=Lower('field'))

# 위 python 코드는 아래 SQL query를 만들어 낸다.
SELECT
...
LOWER("db_table"."field") as "field_lower"
  • postgresql에서 unnest 라는 함수가 있다. 하지만 django에서는 제공하지 않는다. 이럴 때 Func를 활용하면 된다.
from django.db.models import Func

# 단발성, 1회성인 경우에는 인라인으로
temp = Test.objects.annotate(te1=Func('test_data', function='UNNEST'))

# 또는 class를 만들어서 재사용이 가능하게
class UNNEST(Func):
    function = 'UNNEST'

temp = Test.objects.annotate(test_data=UNNEST('test_data'))
  • 단순해 보이지만 살펴봐야 할 부분이 많다. class Func(*expressions, **extra) 를 좀 더 살펴보자. 아래 실제로 Func 함수가 어떻게 정의되어 있는지 단편적 코드를 가져왔다.
class Func(SQLiteNumericMixin, Expression):
    """An SQL function call."""
    function = None
    template = '%(function)s(%(expressions)s)'
    arg_joiner = ', '
    arity = None  # The number of arguments the function accepts.

	# ... 중략 ...
    
    def as_sql(self, compiler, connection, function=None, template=None, arg_joiner=None, **extra_context):
    
	# ... 생략 ...

function

  • 생성 될 함수를 설명하는 속성. 즉 데이터베이스에 어떤 함수를 호출할 지 명시 해야 한다. 기본값은 None이다.

template

  • function과 expression을 결합하여 DB에 질의할 query를 만드는 템플릿이다. 기본값은 '%(function)s(%(expressions)s)' 이다.

  • 만약 DB 쿼리 시, strftime('%W', 'date')와 같이 %가 필요한 경우에는 %가 두 번 입력되기 때문에 템플릿 속성에서 % 문자를 (%%%%)로 4배 늘려야 합니다.

arg_joiner

  • expressions에 입력된 컬럼들을 결합하는 데 사용되는 문자를 나타내는 속성이다. 기본값은 ', '

arity

  • 해당 함수가 몇 개의 컬럼을 받을 수 있는지 숫자를 표시한다. 해당 옵션에 숫자가 주어지고 호출할 수 있는 컬럼의 수를 넘어가면 TypeError를 발생시킨다. 기본값은 None이다.

as_sql(compiler, connection, function=None, template=None, arg_joiner=None, **extra_context)

  • DBSM 마다 같은 기능을 하는 DB 함수의 명칭이 다를 수 있다. 그래서 as_dbms이름와 같은 형태로 다양한 형태의 DBMS에 대해 정의 해 줄 수 있다.

  • as_DB명()은 function, template, arg_joiner와 추가적인 **extra_context 파라미터를 선언해야 한다.

class ConcatPair(Func):
    ...
    function = 'CONCAT'
    ...

    def as_mysql(self, compiler, connection, **extra_context):
        return super().as_sql(
            compiler, connection,
            function='CONCAT_WS',
            template="%(function)s('', %(expressions)s)",
            **extra_context
        )

주의!

  • **extra_contextkey=value 쌍으로 표현할 수 있는데 이는 "문자열 그대로 전달"하여 사용하므로 잘못 사용하면 SQL 인젝션에 취약할 수 있다.

  • 위 케이스의 예시는 postgresql를 위해 만드는 django 미지원 함수 글에서 확인이 가능하다.


Q()

  • Q object는 from django.db.models import Q에 속해 있는 Django built in object이다. Where에서 AND OR NOT 등과 같은 조금 복잡한 질의에 활용되는 object이다.

  • 쉽게 생각하면 filter, get등의 ORM 질의 함수에 들어가는 Q object다. 아래 예제들을 살펴보자!

AND

  • AND는 & 로 연결해주면 된다. 사실 AND는 꼭 Q() object를 사용할 필요는 없다. 질의 함수 내부에 , (콤마)로 구분하면 똑같이 작동하기 때문이다.
# SQL 쿼리문
SELECT * FROM product WHERE category='A' AND sub_category='AB'

# Django ORM, 아래 2개가 동일하다. 
Product.objects.filter(Q(category='A') & Q(sub_category='AB'))
Product.objects.filter(category='A', sub_category='AB')

OR

  • OR은 | 로 연결해주면 된다.
# SQL 쿼리문
SELECT * FROM product WHERE category='A' OR sub_category='AB'

# Django ORM
Product.objects.filter(Q(category='A') | Q(sub_category='AB'))

NOT

  • NOT은 ~ 을 Q() object앞에 붙여주자! SQL 쿼리에서도 뒤에 오는 조건을 부정하는 역할을 한다. 때문에 혼자서는 되지 않는다.
# SQL 쿼리문
SELECT * FROM product WHERE category='A' AND sub_category != 'AB' # 더 정확하게 '<>' 임

# Django ORM
Product.objects.filter(Q(category='A') & ~Q(sub_category='AB'))

복잡 예제

  • Query는 AND OR NOT등은 기본적인 요소일 뿐이다. 복합적으로 질의를 하는 경우가 대부분이다. 다양한 경우에서 AND OR NOT을 사용하는 방법을 살펴보자.
# User의 first_name이 R로 시작하면서 last_name이 Z로 시작하지 않는 것
User.objects.filter(
    Q(first_name__startswith='R') & ~Q(last_name__startswith='Z')
)


# Product의 category에 A가 포함되거나, sub_category가 AA, AB, AC 중 하나 인 것
Product.objects.filter(
    Q(category__contains='A') | Q(sub_category__in=['AA', 'AB', 'AC'])
)
# 추가로 icontains는 contains와 같지만 대소구분을 하지않는 쿼리를 던진다. 
# 그리고 대소구분은 DB(or table, or column)의 charset, BINARY 설정과 관련되어 있다. 
# 아래 코드의 답은 읽으시는 분의 해석에 맡기겠다. 댓글 기대하겠습니다. :)
Product.objects.filter(
    Q(category__contains='A') &
    Q(sub_category__in=['AA', 'AB', 'AC']) &
    Q(option__isnull=False) &
    Q(price__gte=5000)
)
  • Queryset을 가져와 재가공하거나 직접 list를 가공하는 것 보다, 한 줄의 Query, ORM 코드로 표현하는 것이 훨씬 빠르다. (물론 절대적이라는 의미가 아니라, 결과가 1~2개 object인 경우 인 등의 특수 케이스를 제외하고)

  • 특히 하기 쉬운 타협점이 queryset을 들고와 또 해당 queryset으로 filter 등의 질의 함수를 호출한다. 아마 직접 확인해보면 알겠지만, 아무리 캐싱을 하더라도 해당 작업은 쿼리를 2개 이상 날리게 될 것이다. 그래서 가능하면 하나의 쿼리로 묶어서 질의를 던지는게 DB에 부담도 훨씬 덜 가고, 최적화를 하는 것이 올바른 방향이다.

  • 실제 query는 어떻게 되는지, queryset.query 의 str 을 확인하면 된다. 계속 실제 쿼리를 확인하면서 SQL을 최적화(쿼리 튜닝)을 하는 것이 BE의 가장 기본적인 소명이라고 생각한다.


다음편

  • Aggregate(), Value(), Subquery() 를 추가적으로 다룬다. 한 글에 다 쓰고 싶었으나 예제가 까지 포함하면 분량이 너무 많아서 2개로 나누었다.
profile
도메인 중심의 개발, 깊이의 가치를 이해하고 “문제 해결” 에 몰두하는 개발자가 되고싶습니다. 그러기 위해 항상 새로운 것에 도전하고 노력하는 개발자가 되고 싶습니다!

0개의 댓글