SW개발/Django

[Django]Django ORM과 QuerySet의 특징 (LazyLoading, Caching, EagerLoading)

이번 포스팅에서는 장고를 사용하게되면 필연적으로 접할 수 밖에 없는 Django ORM QuerySet의 특징에 대해 알아보려고 한다.

그 중에서도 LazyLoading, Caching, EagerLoading에 대해 알아볼 것이다.

이를 위해서 ORM이 무엇이고 왜 사용하는지에 대해 간략하게 알아보고 넘어갈 것이다.

 

ORM 이란?

Object Relational Mapping (객체-관계 매핑)

  • 객체와 관계형 데이터베이스를 자동으로 매핑(연결) 해줌
  • 데이터베이스 ↔ Object (객체를 통해 데이터베이스의 필드를 다룸)

 

ORM을 사용하는 이유

ORM은 위에서 설명한 것과 같이 객체를 통해 데이터 베이스를 다룰 수 있는 기술을 의미한다.

ORM을 사용함으로써 얻을 수 있는 장점과 단점들은 아래와 같다.

 

장점

  • 객체 지향적인 코드를 통해 SQL문과 달리 프로그래밍적으로 접근이 가능함
  • 재사용 및 유지보수의 편리성이 증가함
  • DBMS에 대한 종속성이 줄어듬 -> 데이터베이스를 바꾸더라도 ORM 코드는 그대로 이용이 가능함

단점

  • 복잡한 SQL문을 구성하려고 할 경우 어려움이 존재함
  • 잘못된 사용으로 N+1 Problem을 야기할 수 있음

 

ORM의 개념에 대해서 위와 같이 짧게 알아보았다. 이 포스팅에서는 간단한 특징만을 다루었지만 실제로는 조금 더 복잡한 내용이 들어있다.

Django 웹 프레임워크 역시 ORM을 이용하고 있고, 그 중 가장 큰 특징은 QuerySet을 이용한다는 것이다.

 

QuerySet 이란?

쿼리셋은 전달받은 객체의 목록이라고 할 수 있다. 쿼리셋은 데이터베이스로부터 데이터를 읽고, 필터를 걸거나 정렬할 수 있다.

ORM과 Queryset의 특징에 대해 심층적으로 다룰 예정이므로 이정도의 설명만 하고 넘어갈 것이다.

 


QuerySet의 특징 1 - LazyLoading

쿼리셋은 정말 필요한 시점에 (데이터를 통한 연산 등) SQL문을 호출하는 LazyLoading이라는 특징을 가지고 있다.

개념적으로만 접근하면 이해하기 어렵기 때문에 간단하게 모델을 만들고 실제로 실행되는 SQL문을 보면서 이야기 하겠다.

 

models.py

from django.db import models

class Post(models.Model):
    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)

우선 다음과 같은 모델이 있다는 가정 하에 진행할 것이다.

실제로 SQL문이 호출되는 시점을 알고 싶기에 django-extension 중 하나인 shell_plus를 이용할 것이다.

 

django shell_plus 설치
pip install django-extensions

 

settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'mysite', # 앱 이름
    'django_extensions', # 장고 익스텐션 추가
]

 

shell_plus 실행
python manage.py shell_plus --print-sql
# --print-sql 옵션을 통해 실제로 생성되는 SQL문과, 수행한 시간을 출력해준다

 


LazyLoading 1 - 필요한 시점에만 SQL문을 호출한다
>>> post = Post.objects.all()
>>> 

위와 같은 ORM 구문을 작성하여도 아직까지는 실제로 SQL문이 호출되지는 않은 상태를 확인할 수 있다.

그렇다면 SQL문은 언제 호출되는 것일까?

>>> post.count()
SELECT COUNT(*) AS "__count"
  FROM "mysite_post"
Execution time: 0.000587s [Database: default]
19
>>> 

위의 실행 결과처럼 실제로 쿼리셋에 담겨있는 데이터를 이용하는 경우에 SQL문이 호출된다.

count()를 비롯해 쿼리셋에 접근하여 내제된 데이터를 이용하는 경우 등이 해당된다. 

 

이것이 바로 장고의 LazyLoading(지연 로딩) 이다.

또 다른 케이스를 통해 LazyLoading에 대해 더 알아보자.

 

LazyLoading 2 - 정말 필요한 만큼만 호출한다
>>> post[0]
SELECT "mysite_post"."id",
       "mysite_post"."title",
       "mysite_post"."content",
       "mysite_post"."created_at",
       "mysite_post"."updated_at"
  FROM "mysite_post"
 LIMIT 1
Execution time: 0.000937s [Database: default]
<Post: Post object (1)>
>>> 

post 쿼리셋에서 첫 번째의 결과 값(row)만 가져오고 싶을 경우에는 위와 같은 코드를 작성할 수 있다.

수행 되는 SQL문에서 LIMIT 1이라는 옵션이 걸려있는 점을 주목해야 한다.

즉, 정말 필요한 만큼만 SQL문을 호출하는 것을 알 수 있다.

 

이렇듯 장고의 ORM은 정말 필요한 시점에 정말 필요한 만큼만 호출하는 특징을 지니고 있다. 가볍게 생각해보자면 정말 똑똑한 ORM이라는 생각이 든다. 하지만, 주의해야 될 점이 있는데 이를 간과하게되면 여러 가지의 문제를 겪을 수 있다.

 

Django ORM 주의할 점
  • LazyLoading 이라는 특성 때문에 여러 개의 쿼리셋이 한번에 합쳐 실행 되면 매우 느리게 동작할 수 있다
  • LazyLoading 2의 특성 때문에 이미 알고 있는 값도 다시 한번 호출이 일어날 수 있다 (N+1 Problem)

대표적으로 위와 같은 문제점들이 존재하지만 이를 해결하는 것에 대한 구체적인 내용은 추후의 포스팅에서 다룰 예정이다.

 


QuerySet의 특징 2 - Caching

쿼리셋은 호출한 SQL에 대한 결과를 캐싱하여 저장하는 특징을 가지고 있다.

>>> post = Post.objects.all()
>>> post[0]

SELECT "mysite_post"."id",
       "mysite_post"."title",
       "mysite_post"."content",
       "mysite_post"."created_at",
       "mysite_post"."updated_at"
  FROM "mysite_post"
 LIMIT 1
Execution time: 0.000158s [Database: default]
<Post: Post object (1)>

>>> 
>>> list(post)

SELECT "mysite_post"."id",
       "mysite_post"."title",
       "mysite_post"."content",
       "mysite_post"."created_at",
       "mysite_post"."updated_at"
  FROM "mysite_post"
Execution time: 0.000156s [Database: default]
[<Post: Post object (1)>, <Post: Post object (2)>, <Post: Post object (3)>, 
<Post: Post object (4)>, <Post: Post object (5)>, <Post: Post object (6)>, 
<Post: Post object (7)>, <Post: Post object (8)>, <Post: Post object (9)>, 
<Post: Post object (10)>, <Post: Post object (11)>, <Post: Post object (12)>, 
<Post: Post object (13)>, <Post: Post object (14)>, <Post: Post object (15)>, 
<Post: Post object (16)>, <Post: Post object (17)>, <Post: Post object (18)>, 
<Post: Post object (19)>]
>>> 

post의 모든 row를 쿼리셋에 가져오기 위해 all() 연산을 수행하였다.

그 후 첫 번째의 결과를 출력하기 위한 post[0] 연산을 수행하였고 뒤이어 리스트로 바꿔주는 작업을 진행하였다.

SQL 호출 결과에서도 볼 수 있듯이 중복(유사)된 쿼리가 2번 호출 되는 점에 주목하여야 한다. (불필요한 SQL 호출)

>>> post = post.objects.all()
>>> list(post) # all() 조회 결과 저장

SELECT "mysite_post"."id",
       "mysite_post"."title",
       "mysite_post"."content",
       "mysite_post"."created_at",
       "mysite_post"."updated_at"
  FROM "mysite_post"
Execution time: 0.000215s [Database: default]
[<Post: Post object (1)>, <Post: Post object (2)>, <Post: Post object (3)>,
<Post: Post object (4)>, <Post: Post object (5)>, <Post: Post object (6)>, 
<Post: Post object (7)>, <Post: Post object (8)>, <Post: Post object (9)>, 
<Post: Post object (10)>, <Post: Post object (11)>, <Post: Post object (12)>, 
<Post: Post object (13)>, <Post: Post object (14)>, <Post: Post object (15)>, 
<Post: Post object (16)>, <Post: Post object (17)>, <Post: Post object (18)>, 
<Post: Post object (19)>]

>>> post[0] # 저장된 결과 재사용
<Post: Post object (1)>
>>> 

이러한 문제점을 해결하기 위해 QuerySet의 특징인 Caching을 이용할 수 있게 하여야 한다. 쿼리셋은 호출한 결과를 캐싱하여 저장하고 있기 때문에 지금과 같은 단순한 경우에는 간단히 순서를 바꿔주는 것 만으로도 SQL 중복 호출을 방지할 수 있게 된다.

 

쿼리셋의 캐싱을 이용할 수 있도록 비즈니스 로직을 구성하는 것이 성능 측면에서 중요할 수 있다.

 


Queryset의 특징 3 - Eager Loading (feat. N+1 Problem)

쿼리셋은 기본적으로 Lazy Loading 전략을 이용한다. 하지만 SQL로 한번에 많은 데이터를 가져오려는 경우가 있을 것이다.

이를 지원하기 위해서 쿼리셋에서는 Eager Loading (즉시 로딩) 이라는 전략이 존재한다.

select_related(), prefretch_related() 메서드를 통해 Eager Loading을 사용할 수 있다.

이러한 전략을 사용하지 않았을 때 (Lazy Loading) 생기는 N+1 Problem을 구현해보기 위해 model을 하나 추가하였다.

 

models.py

from django.db import models

class Post(models.Model):
    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)

    
class Blog(models.Model):
    admin = models.CharField(max_length=50)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    post_title = models.ForeignKey(Post, on_delete=models.CASCADE)

하나의 Blog에 여러 개의 Post가 있는 1 : N 관계의 Blog 모델을 추가적으로 선언하였다.

>>> blog = Blog.objects.all()
>>> 
>>> for b in blog:
...     b.post_title
... 
SELECT "mysite_blog"."id",
       "mysite_blog"."admin",
       "mysite_blog"."created_at",
       "mysite_blog"."updated_at",
       "mysite_blog"."post_title_id"
  FROM "mysite_blog"
Execution time: 0.000213s [Database: default]
SELECT "mysite_post"."id",
       "mysite_post"."title",
       "mysite_post"."content",
       "mysite_post"."created_at",
       "mysite_post"."updated_at"
  FROM "mysite_post"
 WHERE "mysite_post"."id" = 20
 LIMIT 21
Execution time: 0.000132s [Database: default]
<Post: Post object (20)>
SELECT "mysite_post"."id",
       "mysite_post"."title",
       "mysite_post"."content",
       "mysite_post"."created_at",
       "mysite_post"."updated_at"
  FROM "mysite_post"
 WHERE "mysite_post"."id" = 20
 LIMIT 21
 
 ... 생략

위와 같은 상황이 대표적으로 N+1 Problem의 예시이다. 모든 Blog를 조회하기 위한 SQL이 1번 수행되고, 그 안에서 Post의 정보를 매번 조회하기 위해 select * from post 의 SQL문이 N번 호출되게 된다.

이러한 문제가 발생하는 이유는 모든 Blog의 정보를 SQL로 한 번에 가져 왔다고 할 지라도, post_title의 정보를 가져오지는 않았으므로 이를 가져오기 위한 SQL문이 N번 호출 되는 것이다.

 

이러한 방식은 한눈에 봐도 매우 비 효율적인 것을 느낄 수 있으며, 데이터가 많으면 많을수록 성능 악화는 심해질 것이다. 문제를 해결하기 위해 Eager Loading 전략을 사용하면 된다. 이것에 대한 구체적인 내용은 추후의 포스팅에서 다뤄볼 것이다.

 

필요한 정보를 미리 불러오는(Eager Loading) 방법을 통해 N+1 Problem 문제를 해결할 수 있다.

 

지금까지 장고의 ORM 과 Queryset의 특징들에 대해 알아보았습니다. 장고를 처음 접하게 되는 경우 ORM이 생각하는 것과는 다른 방향으로 SQL문이 호출될 가능성이 높기 때문에 shell_plus를 활용하면서 실제로 호출되는 SQL문을 보면서 공부해보는 것이 중요하다고 생각합니다.

저 역시도 간단한 ORM은 쉽게 만들어 낼 수 있지만 여러 가지의 관계가 얽힌 복잡한 ORM은 만들기가 어려운 것 같습니다.

다음 포스팅에는 앞서 다뤄보았던 문제들을 어떻게 해결할 수 있는 방법에 대한 심층적인 내용들을 다뤄볼 예정입니다.

 

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

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

 

na86421/Django-ORM-Queryset

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

github.com

 

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

 

728x90