Django ORM의 쿼리가 실제로 실행될 때는?

cokemania·2022년 9월 18일
0

django

목록 보기
1/1
post-thumbnail

Django ORM query evaluation

ORM은 ORM(Objects Relational Mapping)의 약자로 code로 DB에 접근 할 수 있는 방법입니다.

python에서는 flask, fastapi에서 쓰는 SQLAlchemy, django에서 쓰는 DjangoORM등이 있는데 Django ORM을 기준으로 query evaluation에 대해서 알아볼 예정입니다.

When QuerySets are evaluated - 📎

Lazy-loading

django ORM은 다른 ORM과 비슷하게 lazy-loading을 사용합니다. lazy-loading은 명령어를 실행할 때 바로 DB에 접근하는것이 아닌, 실제 데이터가 필요할 때 SQL명령어를 실행하는 방식입니다.

실행하는 시점은 다음과 같습니다.

  • 0. Iteration
    >>> todos = Todo.objects.all() # 단순히 변수에 할당할 때는 쿼리가 실행되지 않는다.
    >>>
    >>> for todo in todos: 
    ...     print(todo.owner) # 반복문을 처음 돌리는 순간 쿼리가 실행된다.
    ...
    SELECT "todo_todo"."id",
           "todo_todo"."title",
           "todo_todo"."completed",
           "todo_todo"."owner_id"
      FROM "todo_todo"
    Execution time: 0.000511s [Database: default]
  • 1. Slicing
    >>> Todo.objects.all()[0] # django orm의 쿼리셋은 슬라이싱이 가능하다.
    SELECT "todo_todo"."id",
           "todo_todo"."title",
           "todo_todo"."completed",
           "todo_todo"."owner_id"
      FROM "todo_todo"
     LIMIT 1
    Execution time: 0.000123s [Database: default]
    <Todo: Todo object (1)>
    쿼리셋을 슬라이싱을 하는 순간 쿼리가 실행됩니다. 참고로 Negative indexing, [-1] 과 같은 indexing은 지원하지 않습니다.
  • 2. len()
    >>> len(Todo.objects.all())
    SELECT "todo_todo"."id",
           "todo_todo"."title",
           "todo_todo"."completed",
           "todo_todo"."owner_id"
      FROM "todo_todo"
    Execution time: 0.005203s [Database: default]
    6
    쿼리셋에 **len()** 을 사용하는 경우 쿼리가 실행됩니다. 내부적으로 **len()**SELECT * FROM table을 사용해서 결과값을 가져오고 count()SELECT COUNT(*) FROM table 사용해서 결과값을 가져옵니다. 성능, 속도 차이로 봤을 때 단순히 길이만 구할것 이라면 count()를 사용하는게 좋을거 같습니다. 자세한 설명
  • 3. list()
    >>> list(Todo.objects.all())
    SELECT "todo_todo"."id",
           "todo_todo"."title",
           "todo_todo"."completed",
           "todo_todo"."owner_id"
      FROM "todo_todo"
    Execution time: 0.008840s [Database: default]
    [<Todo: Todo object (1)>, <Todo: Todo object (2)>, <Todo: Todo object (3)>, <Todo: Todo object (4)>, <Todo: Todo object (5)>, <Todo: Todo object (6)>]
    쿼리셋을 list형태로 변환해줄 때 쿼리가 실행됩니다.
  • 4. bool() 결과값이 있는지 보고싶을 때 if문에 넣게 되는데 이때 또 쿼리가 실행됩니다.
    >>> if Todo.objects.filter(title='hi'):
    ...     print("hello")
    ...
    SELECT "todo_todo"."id",
           "todo_todo"."title",
           "todo_todo"."completed",
           "todo_todo"."owner_id"
      FROM "todo_todo"
     WHERE "todo_todo"."title" = 'hi'
    Execution time: 0.006811s [Database: default]
    hello

따라서 ORM을 통해서 쿼리에 접근할 때 위와 같은 시점을 주의해서 쿼리가 많이 발생하지 않도록 코드를 작성하는것이 중요합니다.

Eager-loading

Eager-loading 은 Lazy-loading과는 반대되는 개념으로 지금 당장 사용하지 않을 데이터 또한 포함하여 쿼리문을 실행하여 가져오는데 select_related메서드와 prefetch_related 메서드를 사용하여 실행 시킬 수 있습니다.

Eager-loading 은 다음과 같은 상황에 필요합니다. 흔히 말하는 N+1 문제.

post와 star의 관계가 1:1 관계이고, 모든 post가 갖고있는 star의 name이 필요할때 다음과 같이 가져오게 되면 post(star)의 갯수(N) + 1 개 만큼의 쿼리가 발생합니다.

  • n+1 예시
    >>> for post in Post.objects.all():
    ...     print(post.star.name)
    ...
    SELECT "blog_post"."id",
           "blog_post"."title",
           "blog_post"."content",
           "blog_post"."date_posted"
      FROM "blog_post"
    Execution time: 0.003525s [Database: default]
    SELECT "blog_star"."id",
           "blog_star"."post_id",
           "blog_star"."name"
      FROM "blog_star"
     WHERE "blog_star"."post_id" = 22
     LIMIT 21
    Execution time: 0.000734s [Database: default]
    Star 0
    SELECT "blog_star"."id",
           "blog_star"."post_id",
           "blog_star"."name"
      FROM "blog_star"
     WHERE "blog_star"."post_id" = 23
     LIMIT 21
    Execution time: 0.000066s [Database: default]
    Star 1
    SELECT "blog_star"."id",
           "blog_star"."post_id",
           "blog_star"."name"
      FROM "blog_star"
     WHERE "blog_star"."post_id" = 24
     LIMIT 21
    Execution time: 0.000063s [Database: default]
    Star 2

N이 커지면 커질수록 DB에 접근하는 횟수 또한 늘어날것입니다. select_related와 prefetch_related 를 사용하면 쿼리를 시간복잡도를 O(n)에서 O(1)로 줄여서 성능을 개선할 수 있습니다.

SELECT "blog_post"."id",
       "blog_post"."title",
       "blog_post"."content",
       "blog_post"."date_posted",
       "blog_star"."id",
       "blog_star"."post_id",
       "blog_star"."name"
  FROM "blog_post"
  LEFT OUTER JOIN "blog_star"
    ON ("blog_post"."id" = "blog_star"."post_id")
Execution time: 0.000792s [Database: default]
Star 0
Star 1
Star 2
Star 3
Star 4

select_related는 n:1 또는 1:1 관계에서 사용이 가능합니다. 쿼리를 보면 JOIN을 이용해서 정참조하는 객체를 가져오고 있습니다. 이렇게 하면 하나의 쿼리로 모든 데이터를 가져올 수 있습니다.

>>> for post in Post.objects.all().prefetch_related('comment_set'):
...     post.comment_set.all()
...
SELECT "blog_post"."id",
       "blog_post"."title",
       "blog_post"."content",
       "blog_post"."date_posted"
  FROM "blog_post"
Execution time: 0.001320s [Database: default]
SELECT "blog_comment"."id",
       "blog_comment"."post_id",
       "blog_comment"."name",
       "blog_comment"."content"
  FROM "blog_comment"
 WHERE "blog_comment"."post_id" IN (22, 23, 24, 25, 26)
Execution time: 0.000729s [Database: default]
<QuerySet [<Comment: Comment 0>]>
<QuerySet [<Comment: Comment 1>]>
<QuerySet [<Comment: Comment 2>]>
<QuerySet [<Comment: Comment 3>]>
<QuerySet [<Comment: Comment 4>]>

prefetch_related 또한 마찬가지입니다. select_related처럼 한 번에 모든 쿼리를 실행하지는 않지만 O(n)번이 아닌 O(1)번의 쿼리로 데이터를 가져옵니다.

conclusion

코드를 작성하다가 len() 또는 bool(), slicing을 이용하다가 중간에 쿼리가 실행되어 불필요하게 많은 쿼리가 실행될 때가 있습니다. 언제 쿼리가 실행되는지 파악해서 코드를 짜야 하고 Eager-loading을 잘 활용하여 쿼리를 줄일 수 있도록 해야 합니다.

0개의 댓글