SW개발/Django

[Django]예제를 통해 알아보는 Django ORM이 호출하는 SQL의 구조 (feat. select_related, prefetch_related)

이번 포스팅에서는 다양한 예시 ORM을 통해 실제로 생성되는 SQL에 대해 알아볼 것이다. 지난 포스팅에 이어 select_related(), prefetch_related() 를 중점으로 다룰 것이다. 또한, ORM이 생성하는 SQL 구조는 어떻게 되는지, 추천하는 ORM 옵션 작성 순서는 무엇인지에 대해서도 같이 알아보려고 한다.

 

models.py

from django.db import models
 
class Blog(models.Model):
    name = models.CharField(max_length=50)
 
 
class Category(models.Model):
    blog = models.ForeignKey(Blog, on_delete=models.CASCADE, related_name='category')
    name = models.CharField(max_length=50)
 
 
class Post(models.Model):
    category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True,
    								related_name='post')
    title = models.CharField(max_length=50)
    content = models.CharField(max_length=500)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

앞으로 나올 여러 개의 연습 문제에서의 역참조를 위해 이전 포스팅의 모델에서 related_name을 추가적으로 설정하였다.

 

역참조 : 클래스명_set 과 related_name의 차이

related_name을 설정해줌으로써 역참조를 선언한 이름으로 가능하게 해준다. 이를 설정하지 않는다면 클래스명_set으로 default related_name이 설정된다. 이것이 지금 까지 역참조를 클래스명_set으로 사용할 수 있었던 이유다.

하지만 가끔 모델에 두 개 이상의 Foreign Key가 존재할 경우 자동으로 생성되는 이름이 겹치는 경우가 있으므로 이럴 때는 related_name을 꼭 명시해주어야 한다.

 


 

Queryset 연습 1
>>> queryset = Category.objects.prefetch_related('post') # post_set 대신 post 사용
>>> 
>>> queryset
SELECT "mysite2_category"."id",
       "mysite2_category"."blog_id",
       "mysite2_category"."name"
  FROM "mysite2_category"
 LIMIT 21
Execution time: 0.000147s [Database: default]
SELECT "mysite2_post"."id",
       "mysite2_post"."category_id",
       "mysite2_post"."title",
       "mysite2_post"."content",
       "mysite2_post"."created_at",
       "mysite2_post"."updated_at"
  FROM "mysite2_post"
 WHERE "mysite2_post"."category_id" IN (1, 2, 3)
Execution time: 0.001465s [Database: default]
<QuerySet [<Category: Category object (1)>, <Category: Category object (2)>, 
<Category: Category object (3)>]>

prefetch_related() 부분에 선언한 필드(post의 집합) 때문에 메인 쿼리 이후 한 개의 추가 쿼리가 발생하게 되었다.

 

Queryset 연습 2
>>> queryset = Post.objects.select_related('category').filter(category=1)
>>> 
>>> queryset
SELECT "mysite2_post"."id",
       "mysite2_post"."category_id",
       "mysite2_post"."title",
       "mysite2_post"."content",
       "mysite2_post"."created_at",
       "mysite2_post"."updated_at",
       "mysite2_category"."id",
       "mysite2_category"."blog_id",
       "mysite2_category"."name"
  FROM "mysite2_post"
 INNER JOIN "mysite2_category"
    ON ("mysite2_post"."category_id" = "mysite2_category"."id")
 WHERE "mysite2_post"."category_id" = 1
 LIMIT 21
Execution time: 0.000168s [Database: default]
<QuerySet [<Post: Post object (1)>, <Post: Post object (2)>, 
<Post: Post object (3)>, <Post: Post object (4)>, <Post: Post object (5)>]>

select_related() 부분에 선언한 필드 때문에 메인쿼리에서 JOIN이 발생하게 되었다. 추가 쿼리는 발생하지 않았다.

필드를 여러 개로 줄 경우 JOIN은 필드의 갯수에 비례해 늘어나게 된다.

 

Queryset 심화 연습 1
>>> queryset = Category.objects.prefetch_related(Prefetch('post', 
					queryset=Post.objects.select_related('category').all()))
>>> 
>>> queryset
SELECT "mysite2_category"."id",
       "mysite2_category"."blog_id",
       "mysite2_category"."name"
  FROM "mysite2_category"
 LIMIT 21
Execution time: 0.000113s [Database: default]
SELECT "mysite2_post"."id",
       "mysite2_post"."category_id",
       "mysite2_post"."title",
       "mysite2_post"."content",
       "mysite2_post"."created_at",
       "mysite2_post"."updated_at",
       "mysite2_category"."id",
       "mysite2_category"."blog_id",
       "mysite2_category"."name"
  FROM "mysite2_post"
 INNER JOIN "mysite2_category"
    ON ("mysite2_post"."category_id" = "mysite2_category"."id")
 WHERE "mysite2_post"."category_id" IN (1, 2, 3)
Execution time: 0.000245s [Database: default]
<QuerySet [<Category: Category object (1)>, <Category: Category object (2)>, 
<Category: Category object (3)>]>

이번에는 prefetch_related() 안에서 Prefetch()를 통해 쿼리셋을 재 선언하였고, 그 안에서 select_related()를 이용하였다.

쿼리문 자체만 보면 매우 복잡하고 어렵게 보이지만 실제로 생성되는 SQL문을 보면 쉽게 이해할 수 있다.

 

  1. 메인 쿼리로서 Category 모델의 정보를 가져오는 것
  2. prefetch로 인해 추가 쿼리가 발생하였고 쿼리셋의 재 선언을 통해 select_related() 옵션을 주었으므로 추가쿼리에서의 JOIN 발생

 

즉, prefetch_related() -> 추가쿼리 / select_related() -> JOIN 발생의 규칙을 알고 있다면 생성되는 SQL을 예측할 수 있다.

 

Queryset 심화 연습 2
>>> queryset = Blog.objects.filter(id=1).prefetch_related(Prefetch('category__post', 
			queryset=Post.objects.filter(title__contains='python')))
>>> 
>>> queryset
SELECT "mysite2_blog"."id",
       "mysite2_blog"."name"
  FROM "mysite2_blog"
 WHERE "mysite2_blog"."id" = 1
 LIMIT 21
Execution time: 0.000094s [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.000068s [Database: default]
SELECT "mysite2_post"."id",
       "mysite2_post"."category_id",
       "mysite2_post"."title",
       "mysite2_post"."content",
       "mysite2_post"."created_at",
       "mysite2_post"."updated_at"
  FROM "mysite2_post"
 WHERE ("mysite2_post"."title" LIKE '%python%' ESCAPE '\' 
 	AND "mysite2_post"."category_id" IN (1, 2, 3))
Execution time: 0.000206s [Database: default]
<QuerySet [<Blog: Blog object (1)>]>

이전의 쿼리에서 약간 더 심화된 쿼리이다. Prefetch의 필드를 category__post로 정함으로써 post 모델에 관련된 정보까지 미리 가져오도록 하였다. 그렇기 때문에 총 3개의 쿼리가 발생하였다.

 

  1. 메인 쿼리로서 Blog 모델의 정보를 가져오는 것
  2. 추가 쿼리 1 - Category 모델의 정보를 가져오는 쿼리
  3. 추가 쿼리 2 - Post 모델의 정보를 가져오는 쿼리 (title로 필터를 주었으므로 where절 발생)

 

Queryset 심화 연습 3
>>> queryset = Blog.objects.filter(id=1).prefetch_related(
	Prefetch('category', queryset=Category.objects.filter(name__contains='programming')), 
	Prefetch('category__post', queryset=Post.objects.filter(title__contains='python')))
>>> 
>>> queryset
SELECT "mysite2_blog"."id",
       "mysite2_blog"."name"
  FROM "mysite2_blog"
 WHERE "mysite2_blog"."id" = 1
 LIMIT 21
Execution time: 0.000121s [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.000175s [Database: default]
SELECT "mysite2_post"."id",
       "mysite2_post"."category_id",
       "mysite2_post"."title",
       "mysite2_post"."content",
       "mysite2_post"."created_at",
       "mysite2_post"."updated_at"
  FROM "mysite2_post"
 WHERE ("mysite2_post"."title" LIKE '%python%' ESCAPE '\' AND "mysite2_post"."category_id" IN (1))
Execution time: 0.000210s [Database: default]
<QuerySet [<Blog: Blog object (1)>]>
>>> 

바로 이전에 알아본 것과 굉장히 유사한 쿼리가 생성된 것을 볼 수 있다. 이번 예시에서 한 가지 달라진 점은 바로 Category와 Post에 대한 조건절을 Prefetch()를 두번 사용해 따로 분리해주었다는 점이다. 이렇게 하면 모델에 대한 조건절을 각각 부여할 수 있다.

 


 

다양한 예시를 통하여 ORM이 생성하는 쿼리셋의 구조에 대해서 조금이나마 익히게 되었을 것이다. 그렇다면 이제 ORM이 생성하는 쿼리셋의 구조와, 추천하는 ORM의 옵션 작성 순서에 대해 소개할 것이다.

 

ORM이 생성하는 SQL 구조 / 추천하는 ORM 작성 순서
queryset = (Model.objects
            .select_related('정방향_참조필드1,','정방향_참조필드2',....) 
            # N개 만큼 JOIN 함 
            .annotate(커스텀속성1=F('모델필드'),  
                      커스텀속성2=Case(
                          When(모델필드__isnull=False,  
                          	   # when : case의 조건절, filter에 관련한 옵션 전부 사용 가능
                               then=Count('모델필드')), 
                               # 모델 필드에서 Count() 함수를 질의함
                          default=Value(0, output_field=IntegerField(),
                          # output_field=장고에서 무슨 타입으로 결과를 받을 지 선언하는 부분
                          ),
                      ))
            .filter(필터옵션)
            .prefetch_related(
                        Prefetch('역방향_참조필드', 
                        # 추가 쿼리 발생, 쿼리셋의 재 선언을 통해 다양한 튜닝 가능 
                                       queryset=(역방향_참조모델.objects
                                                 .select_related('역방향_참조모델의_정방향참조모델')
                                                 .filter(역방향_각종_질의문))
                                               # .prefetch_related('역방향_참조모델의_역(정)방향참조모델') 
                                               # 위처럼 사용도 가능함
                                )
            )
            )
SELECT *
       모델필드 AS 커스텀 속성1,
       CASE
           WHEN 모델필드 IS NOT NULL
               THEN COUNT('모델필드')
        ELSE 0 END AS 커스텀 속성2,  # IntegerField()는 쿼리 영향 X
      
FROM `메인쿼리 Model`
         LEFT INNER JOIN '정방향 참조필드1'  
         # INNER, OUTER 는 ForignKey의 null 옵션 값에 의해 결정
                         ON (~~~~)
         LEFT OUTER JOIN '정방향 참조필드2'  
         # INNER, OUTER 는 ForignKey의 null 옵션 값에 의해 결정
                         ON (~~~~)
WHERE (필터 옵션)


SELECT *
FROM 역방향_참조모델
         INNER JOIN '역방향_참조모델의_정방향참조모델'
                    ON ( )
WHERE (역방향_각종_질의문 AND 메인쿼리의_Model.`related_id` IN (1,2,3,4,....));

 

위와 같은 순서로 ORM을 작성하는 것이 실제로 생성되는 SQL의 순서와 가장 유사하다. 유심히 살펴보면 같은 옵션을 여러 번 사용하면 대부분 비슷한 구조로 SQL이 생성된다. 하지만 모든 경우에 같은 구조를 생성한다는 것은 보장할 수 없으므로 유독 느려지거나(서브쿼리, 슬로우쿼리) 하는 부분에 대해서는 실제로 어떻게 수행되는지 꼭 디버깅 해 볼 필요가 있을 것이다.


이번 포스팅에서는 ORM이 호출하는 SQL의 정형화 된 구조에 대해서 알아보았다. 다음 포스팅에서는 실수하기 쉬운 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