SW개발/Django

[Django]Django ORM, 실수하기 쉬운 Queryset의 특징

Django ORM을 작성하다보면 종종 당연히 '이렇게 수행 될거야' 라고 생각하지만 실제로는 그렇지 않은 부분들이 꽤 많이 존재한다.

이번 포스팅에서는 놓치기 쉽고 실수하기 쉬운 queryset의 특징들에 대해 다루어보려고 한다.

 

다음과 같은 순서로 설명을 진행하려고 한다.

  1. queryset 캐시를 재활용하지 못하는 queryset 호출
  2. prefetch_related() 와 filter()의 올바른 사용법
  3. 서브쿼리가 발생하는 조건
  4. values(), values_list() 사용시 주의점
Queryset 캐시를 재활용하지 못하는 queryset 호출
>>> blog_list = list(Blog.objects.prefetch_related('category').all())
SELECT "mysite2_blog"."id",
       "mysite2_blog"."name"
  FROM "mysite2_blog"
Execution time: 0.000100s [Database: default]
SELECT "mysite2_category"."id",
       "mysite2_category"."blog_id",
       "mysite2_category"."name"
  FROM "mysite2_category"
 WHERE "mysite2_category"."blog_id" IN (1)
Execution time: 0.000076s [Database: default]
>>> 
>>> blog = blog_list[0]
>>> blog.category.all()
<QuerySet [<Category: Category object (1)>, <Category: Category object (2)>, 
<Category: Category object (3)>]>
------------------------------------------------------------------------------
# 캐시를 재활용하지 못함
>>> blog.category.filter(name__contains='programming')
SELECT "mysite2_category"."id",
       "mysite2_category"."blog_id",
       "mysite2_category"."name"
  FROM "mysite2_category"
 WHERE ("mysite2_category"."blog_id" = 1 AND "mysite2_category"."name" 
 	LIKE '%programming%' ESCAPE '\')
 LIMIT 21
Execution time: 0.000194s [Database: default]
<QuerySet [<Category: Category object (1)>]>
------------------------------------------------------------------------------
# 캐시를 재활용 하려면 다음과 같이 해야함
>>> category_name_list = [category for category in blog.category.all()
			if 'programming' in category.name]
>>> category_name_list
[<Category: Category object (1)>]

먼저 Blog 모델의 정보를 가져오는데 그 blog의 Category 정보를 함께 미리 불러왔다. 그 후 카테고리에 대한 정보를 all()로 질의하게 되면 이전 포스팅에서 설명했던 Queryset 객체에서 _result_cache 부분에 저장된 데이터를 불러와서 SQL을 호출하지 않는다. 하지만 filter 옵션을 통해 조건을 걸게 된다면 그 조건에 대한 SQL문이 새롭게 호출된다.

 

그렇다면 지금 상황에서 이미 가지고 있는 cache 데이터를 재 활용하려면 어떻게 해야할까?

정답은 바로, 파이썬의 list comprehension 방식을 이용하는 것이다.

 

ORM을 작성할 때 조건절을 부여하기 위해서 filter() 함수를 당연하고 우선적으로 사용하게 되지만, 이를 사용할 때 주의 해야할 점은 이미 있는 정보에 대해 불필요한 SQL 호출을 야기할 수 있다는 것이다. 이를 방지하기 위해 파이썬 레벨에서 조건절을 처리해주는 방식을 사용해 cache 데이터를 재활용할 수 있다. (지금 예시에서는 blog.category.all() 안에서 category의 name에 대한 조건을 부여하였다)

 


prefetch_related() 와 filter()의 올바른 사용법

 

이번에도 Blog 모델의 정보를 가져오고 그 blog에 대한 category의 name에 python이 포함되어 있는지를 확인하는 ORM을 작성해보려고 한다. 바로 머릿속에 떠오르는 ORM은 다음과 같을 것이다.

# category 모델을 2번 조회함 (불필요한 호출)
>>> blog_queryset = Blog.objects.prefetch_related('category')
			.filter(name__contains='leffe', category__name__contains='programming')
>>> 
>>> blog_queryset
SELECT "mysite2_blog"."id",
       "mysite2_blog"."name"
  FROM "mysite2_blog"
 INNER JOIN "mysite2_category"
    ON ("mysite2_blog"."id" = "mysite2_category"."blog_id")
 WHERE ("mysite2_category"."name" LIKE '%programming%' ESCAPE '\' 
 AND "mysite2_blog"."name" LIKE '%leffe%' ESCAPE '\')
 LIMIT 21
Execution time: 0.000210s [Database: default]
SELECT "mysite2_category"."id",
       "mysite2_category"."blog_id",
       "mysite2_category"."name"
  FROM "mysite2_category"
 WHERE "mysite2_category"."blog_id" IN (1)
Execution time: 0.000080s [Database: default]
<QuerySet [<Blog: Blog object (1)>]>

막상 이 ORM을 실행해보면 생각했던 것과는 조금 다르게 동작하는 것을 확인할 수 있다. category의 이름에 대한 where 조건절이 추가 쿼리에 생성되지 않고 메인 쿼리에 생성됨으로써 의도와도 다르고, 불필요한 SQL문이 생성된다.

 

이렇게 동작하는 이유는 다음과 같다.

  1. filter() 안에서 category__name으로 역참조 하였기에 메인쿼리에서 category 모델 JOIN 발생
  2. prefetch_related() 안에서 category 모델을 역참조 하였기에 catregory 모델에 대한 추가 쿼리 발생

바로 이것이 prefetch_related() 와 filter() 의 잘못된 사용방법이다. prefetch 안의 참조된 모델에 대한 조건절을 filter()를 통해 부여하려는 생각이 실수하기 쉬운 부분이다. 올바르게 사용하는 방법은 무엇일까? 두 가지의 방법이 존재한다.

 

  1. prefetch_related() 함수 제거
  2. Prefetch() 쿼리셋 재선언을 이용한 조건절 부여

 

1. prefetch_related() 함수 제거

>>> blog_queryset = Blog.objects.filter(name__contains='leffe', 
					category__name__contains='programming')
>>> 
>>> blog_queryset
SELECT "mysite2_blog"."id",
       "mysite2_blog"."name"
  FROM "mysite2_blog"
 INNER JOIN "mysite2_category"
    ON ("mysite2_blog"."id" = "mysite2_category"."blog_id")
 WHERE ("mysite2_category"."name" LIKE '%programming%' ESCAPE '\' 
 AND "mysite2_blog"."name" LIKE '%leffe%' ESCAPE '\')
 LIMIT 21
Execution time: 0.000163s [Database: default]
<QuerySet [<Blog: Blog object (1)>]>

 

2. Prefecth() 쿼리셋 재선언을 이용한 조건절 부여

>>> blog_queryset = Blog.objects.filter(name__contains='leffe').prefetch_related(
		Prefetch('category', queryset=Category.objects.filter(name__contains='programming')))
>>> 
>>> 
>>> blog_queryset
SELECT "mysite2_blog"."id",
       "mysite2_blog"."name"
  FROM "mysite2_blog"
 WHERE "mysite2_blog"."name" LIKE '%leffe%' ESCAPE '\'
 LIMIT 21
Execution time: 0.000180s [Database: default]
SELECT "mysite2_category"."id",
       "mysite2_category"."blog_id",
       "mysite2_category"."name"
  FROM "mysite2_category"
 WHERE ("mysite2_category"."name" LIKE '%programming%' ESCAPE '\' 
 AND "mysite2_category"."blog_id" IN (1))
Execution time: 0.000112s [Database: default]
<QuerySet [<Blog: Blog object (1)>]>

 

맨 처음에 생각했던 방식을 위의 두 가지 방법을 통해 올바르게 사용함으로써 필요한 데이터를 제대로 가져오는 SQL문을 만들 수 있게 되었다. 

 

장고로 개발을 진행하다 보면 실제 SQL이 어떻게 호출되는지 모른 채 할 때가 많은데 분명히 나중에 성능적으로 문제를 야기시키는 시점이 온다고 생각한다.

(장고 뿐만 아니라 다른 ORM도 동일하다) 

빠르게 개발을 진행해야 할 경우에는 우선 개발부터 하고, 추후 퍼포먼스에 문제가 생길 때 개선해나가는 방법을 택해야 할 것이고, 시간적인 여유가 있다면 성능을 염두에 퍼포먼스 문제를 사전에 방지하면서 개발을 진행하는 편이 좋을 것이라고 생각한다. (물론 애플리케이션이 퍼포먼스가 중요하냐의 여부에 따라 달라질 것이다)


서브쿼리가 발생하는 조건

장고에서는 Subquery() 라는 객체를 통해서 서브쿼리를 작성할 수 있다. 하지만 종종 ORM을 작성하다보면 의도치 않게 서브쿼리가 발생하는 경우가 생기는데, 이는 슬로우쿼리가 되기 마련이다. 서브쿼리가 발생하는 몇 가지를 예시와 함께 살펴보려고 한다.

 

  1. 쿼리셋안에 쿼리셋이 있는 경우
  2. exclude() 에서 역방향 참조모델 사용 시 서브쿼리 발생

 

1 - 쿼리셋안에 쿼리셋이 있는 경우

>>> blog_queryset = Blog.objects.filter(id='1').values_list('id', flat=True)
>>> category_queryset = Category.objects.filter(blog__id__in=blog_queryset)
>>> 
>>> category_queryset
SELECT "mysite2_category"."id",
       "mysite2_category"."blog_id",
       "mysite2_category"."name"
  FROM "mysite2_category"
 WHERE "mysite2_category"."blog_id" IN (
        SELECT U0."id"
          FROM "mysite2_blog" U0
         WHERE U0."id" = 1
       )
 LIMIT 21
Execution time: 0.000117s [Database: default]
<QuerySet [<Category: Category object (1)>, <Category: Category object (2)>,
<Category: Category object (3)>]>

위의 결과 처럼 쿼리셋안에 쿼리셋이 있는 경우 서브쿼리가 발생하게 된다. 먼저 위치한 blog_queryset이 실제로 수행되어 있지 않은 상태이기 때문에 장고의 Lazy Loading 특성에 의해 category_queryset 이 실행되는 타이밍에 blog_queryset도 같이 수행되기 때문에 서브쿼리가 발생하게 된다.

 

1 - 서브쿼리가 발생하지 않는 해결법

# list()를 통해 blog_queryset을 즉시 실행
>>> blog_queryset = list(Blog.objects.filter(id='1').values_list('id', flat=True))
SELECT "mysite2_blog"."id"
  FROM "mysite2_blog"
 WHERE "mysite2_blog"."id" = 1
Execution time: 0.000152s [Database: default]
>>> 
>>> category_queryset = Category.objects.filter(blog__id__in=blog_queryset)
>>> 
>>> category_queryset
SELECT "mysite2_category"."id",
       "mysite2_category"."blog_id",
       "mysite2_category"."name"
  FROM "mysite2_category"
 WHERE "mysite2_category"."blog_id" IN (1)
 LIMIT 21
Execution time: 0.000136s [Database: default]
<QuerySet [<Category: Category object (1)>, <Category: Category object (2)>,
<Category: Category object (3)>]>

위의 코드처럼 list()를 통해 blog_queryset을 즉시 사용함으로써 category_queryset을 사용할 때 서브쿼리가 생성되지 않도록 한다. 이렇게 하여 서브쿼리의 발생을 막을 수 있다.

 

2.1 - filter()에서 역방향 참조모델 사용시

>>> category_queryset = Category.objects.filter(name__contains='programming', post__title__contains='python')
>>> 
>>> category_queryset
SELECT "mysite2_category"."id",
       "mysite2_category"."blog_id",
       "mysite2_category"."name"
  FROM "mysite2_category"
 INNER JOIN "mysite2_post"
    ON ("mysite2_category"."id" = "mysite2_post"."category_id")
 WHERE ("mysite2_category"."name" LIKE '%programming%' ESCAPE '\' 
 AND "mysite2_post"."title" LIKE '%python%' ESCAPE '\')
 LIMIT 21
Execution time: 0.000243s [Database: default]
<QuerySet [<Category: Category object (1)>, <Category: Category object (1)>, <Category: Category object (1)>, 
<Category: Category object (1)>, <Category: Category object (1)>]>

filter()에서 역방향 참조모델을 사용시 정상적으로 JOIN된 메인 쿼리를 확인할 수 있다. 하지만 모든 부분은 똑같이하고 역방향 참조모델만 exclude()에 넣게되면 의도하지 않은 서브쿼리가 발생하게 된다.

 

2.2 - exclude()에서 역방향 참조모델 사용시 (~Q() 객체)

>>> category_queryset = Category.objects.filter(name__contains='programming')
					.exclude(post__title__contains='python')
>>> 
>>> category_queryset
SELECT "mysite2_category"."id",
       "mysite2_category"."blog_id",
       "mysite2_category"."name"
  FROM "mysite2_category"
 WHERE ("mysite2_category"."name" LIKE '%programming%' ESCAPE '\' 
 AND NOT (EXISTS(SELECT (1) AS "a" FROM "mysite2_post" U1 
 WHERE (U1."title" LIKE '%python%' ESCAPE '\' AND U1."category_id" = "mysite2_category"."id")
 LIMIT 1)))
 LIMIT 21
Execution time: 0.000238s [Database: default]
<QuerySet []>

바로 전에 작성했던 ORM에서 filter() 부분만 그대로 옮겨 왔음에도 불구하고 역방향 참조모델이 존재할 경우에는 서브쿼리가 발생함을 확인할 수 있다. filter() 안에서 ~Q() 객체를 사용하더라도 서브쿼리는 동일하게 발생한다.

 

2.3 - exclude()에서 정방향 참조모델 사용시

>>> category_queryset = Category.objects.filter(name__contains='programming')
					.exclude(blog__name__contains='leffe')
>>> 
>>> category_queryset
SELECT "mysite2_category"."id",
       "mysite2_category"."blog_id",
       "mysite2_category"."name"
  FROM "mysite2_category"
 INNER JOIN "mysite2_blog"
    ON ("mysite2_category"."blog_id" = "mysite2_blog"."id")
 WHERE ("mysite2_category"."name" LIKE '%programming%' ESCAPE '\' 
 AND NOT ("mysite2_blog"."name" LIKE '%leffe%' ESCAPE '\'))
 LIMIT 21
Execution time: 0.000360s [Database: default]
<QuerySet []>

하지만 exclude()에서 정방향 참조모델을 사용할 때에는 정상적으로 JOIN 되는 것을 확인할 수 있다. 역방향 참조모델에서만 서브쿼리가 발생하게 되는데, 정확한 이유는 찾지 못하였다.

 

이렇게 exclude()에서 역방향 참조모델을 사용시 서브쿼리가 발생하는 것을 정상적으로 해결하는 방법은 아직 찾지 못했다. 하지만 다음과 같은 차선책을 통해 이를 해결할 수 있다.

 

2 - 서브쿼리가 발생하지 않는 해결법

>>> category_queryset = Category.objects.prefetch_related(
Prefetch('blog', queryset=Blog.objects.exclude(name__contains='leffe'))).filter(name__contains='programming')
>>> 
>>> category_queryset
SELECT "mysite2_category"."id",
       "mysite2_category"."blog_id",
       "mysite2_category"."name"
  FROM "mysite2_category"
 WHERE "mysite2_category"."name" LIKE '%programming%' ESCAPE '\'
 LIMIT 21
Execution time: 0.000712s [Database: default]
SELECT "mysite2_blog"."id",
       "mysite2_blog"."name"
  FROM "mysite2_blog"
 WHERE (NOT ("mysite2_blog"."name" LIKE '%leffe%' ESCAPE '\') 
 AND "mysite2_blog"."id" IN (1))
Execution time: 0.000106s [Database: default]
<QuerySet [<Category: Category object (1)>]>

 이전 포스팅에서 다루었던 Prefetch() 객체와 prefetch_related()를 통하여 따로 추가 쿼리를 생성하는 방식으로 해결할 수 있다.

 

서브쿼리의 경우 데이터가 조금이라도 많아지면 성능에 심각한 영향을 미치기 때문에 의도적으로 사용하는 것이 아니라면 발생되지 않게 하는 것이 상당히 중요하다. 대부분의 경우에 서브쿼리가 필요하지 않다고 생각하기 때문에 만약 서브쿼리가 발생한 경우라면 ORM을 다른 방법으로 작성해보면서 문제를 해결해야 할 것이다.


values(), values_list() 사용시 주의점

 

values(), values_list()를 사용할때에는 Eager Loading 옵션인 select_related(), prefetch_related() 옵션을 전부 무시하게 된다.

그 이유는 values는 DB의 Raw단위로 데이터를 반환하기 때문에 객체와 관계들간의 매핑이 일어나지 않기 때문이다. 

 

우선 아래의 예제를 보면서 설명을 이어나가겠다.

 

values() 사용시 Eager Loading 무시

# select_related() 무시
>>> category_queryset = Category.objects.select_related('post__title').filter(id=1).values()
>>> 
>>> category_queryset
SELECT "mysite2_category"."id",
       "mysite2_category"."blog_id",
       "mysite2_category"."name"
  FROM "mysite2_category"
 WHERE "mysite2_category"."id" = 1
 LIMIT 21
Execution time: 0.000166s [Database: default]
<QuerySet [{'id': 1, 'blog_id': 1, 'name': 'programming'}]>

value()를 사용할 경우 select_related()와 관련된 JOIN 옵션이 완전히 무시된 것을 확인할 수 있다. prefetch_related()의 경우에도 동일한 결과를 볼 수 있다.

 

values_list() 사용시 Eager Loading 무시

# prefetch_related() 무시
>>> category_queryset = Category.objects.prefetch_related('post').filter(id=1).values_list('post')
>>> 
>>> category_queryset
SELECT "mysite2_post"."id"
  FROM "mysite2_category"
  LEFT OUTER JOIN "mysite2_post"
    ON ("mysite2_category"."id" = "mysite2_post"."category_id")
 WHERE "mysite2_category"."id" = 1
 LIMIT 21
Execution time: 0.000177s [Database: default]
<QuerySet [(1,), (2,), (3,), (4,), (5,)]>

prefetch_related()를 사용하여 Post 모델에 대한 추가 쿼리를 가져오라고 Eager Loading 하였지만 전부 수행되지 않았다.

또한, values_list()에서도 Post 모델을 가져오지 않고 단순히 foreign key의 정보만 가져와서 1, 2, 3 ...와 같은 결과가 나온 것을 확인할 수 있다.

 

위에서 잠깐 언급하였지만 Eager Loading 옵션이 무시되는 이유는 단순하고 명료하다. values(), values_list()는 DB의 Raw 단위로(데이터의 가로 한줄을 뜻함)

데이터를 반환하는 특성 때문에 객체와 관계들 간의 매핑이 일어날 수 없기 때문이다.


지금까지 실수하기 쉬운 Queryset의 특징들에 대해서 알아보았습니다. 장고 개발을 진행하면서 헷갈리거나, 모른채 사용할 수 있는 부분들을 다뤄보았는데 쉬운 부분은 아니라고 생각합니다. 예시를 참고하여 직접 모델을 만들어보고 데이터를 넣어보고, shell_plus에서 ORM을 작성하면서 공부한다면 Queryset을 이해하는데 큰 도움이 될 것이라고 생각합니다.

 

지금까지 작성한 코드는 해당 레포지토리의 queryset_3 브랜치에서 확인할 수 있습니다.

github.com/na86421/Django-ORM-Queryset/tree/queryset_3

 

na86421/Django-ORM-Queryset

Django-ORM-Queryset 공부용. Contribute to na86421/Django-ORM-Queryset development by creating an account on GitHub.

github.com

 

공부하면서 정리한 내용이니 틀린 부분이 있을 수 있습니다. 댓글로 남겨주시면 감사하겠습니다.

 

728x90