Django ORM 최적화

2022. 5. 16. 17:46

1. Logging

  • ORM은 사용자의 의도에 맞는 SQL문을 생성하여 DB에 요청을 보내는 도구이다. 분명 편리하지만, ORM이 만들어주는 SQL문은 늘 최적화된 SQL문이라고 확신할 수 없다.
  • 특히 SQL문이 쓸데없이 DB에 요청을 많이 보낼 경우 성능적으로 굉장히 부정적인 효과가 발생함
  • 따라서 실제로 어떤 SQL문이 얼마나 DB로 보내지는지 확인이 필요하고, 이를 확인하기 위해 Logging을 사용한다.
  • 참고)DB의 성능이슈는 주로 데이터를 읽을 때 발생함. 다른 기능(Create, Update, Delete)에서는 잘 발생하지 않음. 따라서 대부분의 DB(Mysql, Oracle..)등은 읽기에 최적화된 상태로 나옴

 

How to Logging

1)서버에 의해 뷰가 호출될때마다 쿼리가 찍히도록 하는 방법

  • settings.py 에 다음을 입력
#settings.py
LOGGING = {
    'disable_existing_loggers': False,
    'version': 1,
    'handlers': {
        'console': {
            'class': 'logging.StreamHandler',
            'level': 'DEBUG',
        },
    },
    'loggers': {
        'django.db.backends': {
            'handlers': ['console'],
            'level': 'DEBUG',
            'propagate': False,
        },
    },
}

 

2)서버에 의해 뷰가 호출될때마다 발생한 쿼리가 파일에 저장되도록 하는 방법

  • settings.py 에 다음을 입력
  • 뷰가 호출될 경우, 설정한 이름대로 'debug.log'라는 파일에 SQL문이 로그로 나음
  • 'cat debug.log'라는 명령어로 파일의 내용을 확인이 가능하다
#settings.py

LOGGING = {
    'disable_existing_loggers': False,
    'version': 1,
    'formatters': {
         'verbose': {
            'format': '{asctime} {levelname} {message}',
            'style': '{'
        },
    },
    'handlers': {
        'console': {
            'class'     : 'logging.StreamHandler',
            'formatter' : 'verbose',
            'level'     : 'DEBUG',
        },
        'file': {
            'level'     : 'DEBUG',
            'class'     : 'logging.FileHandler',
            'formatter' : 'verbose',
            'filename'  : 'debug.log',
        },
    },
    'loggers': {
        'django.db.backends': {
            'handlers' : ['console','file'],
            'level'    : 'DEBUG',
            'propagate': False,
        },
    },
}

2. Lazy loading 

  • 장고의 ORM은 성능의 향상을 위해, queryset이 반환될 때마다 그 즉시 SQL문을 만들어 DB에 요청을 보내지 않는다.
  • 대신 반환된 객체의 'query'라는 속성에 SQL문을 저장해둘 뿐임
    • 아래의 코드에서, queryset이 선언되었을 때 DB로 sql문이 보내지는 게 아니라 queryset.query에 SQL문이 담겨져 있다가 SQL문이 평가되는 순간에 SQL문이 실행된다.
    • 즉, 아래의 코드만으로는 DB와 실질적으로 통신이 진행되지 않는다
queryset = Publisher.objects.all()
print(queryset.query) #쿼리셋이 저장되어 있다

잘 안보이긴 하지만 queryset.query가 출력되었다.

 

Queryset Evaluation

  • 그러면 저장된 SQL문은 언제 실행되는 걸까? 즉, 언제 실제로 DB에 요청을 보내 데이터에 관한 작업을 진행하는 걸까?
  • 장고는 SQL문이 직접 실행되는 순간을 '쿼리셋이 평가될 때'라고 말함. '쿼리셋이 평가될 때'의 예시는 다음과 같다
    • Slicing, Iteration, repr(), len(), list(), bool()
    • Iteration은 for문 , repr는 출력할 때, bool은 if문 등에서 빈 쿼리셋인지 판단할 때 정도로 생각하면 받아들이기 편하다.

 

Lazy loading

  • 이렇게 queryset이 생성될 때마다 바로바로 DB와 통신하지 않고, 평가될 때까지 기다렸다가 SQL문을 DB에 보내서 통신하는 ORM의 특징을 Lazyloading이라고 한다.
  • 참고)Lazyloading은 ORM뿐 아니라 CS전반에서 등장하는 개념임

3. Caching

  • 다음과 같이 될 경우, db를 2번 호출한다.
queryset = Publisher.objects.all()
queryset[2]
queryset[2]

 

  • 다음과 같은 경우에는 db를 한번만 호출한다
queryset = Publisher.objects.all()
list(queryset)
queryset[2]
queryset[2]

 

  • 다음과 같은 경우에는 db를 3번 호출한다
queryset = Publisher.objects.all()
queryset[2]
queryset[2]
list(queryset)

 

  • 이렇게 비슷한 코드들마다 db와의 통신횟수가 다른 경우는 장고의 캐싱기능때문임
    • queryset이 평가되는 경우, 그 쿼리들을 캐싱해둔다.
    • 물론 'queryset이 평가된다 = 캐싱된다'라고 생각할 수는 없다. 심지어 캐싱의 명확한 기준조차도 장고가 공식적으로 공개하지 않앗음.
    • 다만 소스코드를 보면, 캐시는 result_cahce라는 곳에 저장되고 해당 값이 있으면 cache에서, 해당 값이 없으면 db에 요청을 하여 데이터를 가져온다고 볼 수 있음 (추가적인 정보를 알고 싶으면 장고소스코드에서 Queryset클래스를 찾아보자!)


4. Eager loading :select_related , prefetch_related

  • 역참조를 사용하는 아래의 코드는 겉으로 보기에는 깔끔하지만 SQL의 요청이라는 측면에서는 굉장히 큰 문제가 발생한다.
  • 참고로 아래에서 사용되는 Movie테이블에는 객체의 총 개수는 5개임
class ProductListView(View):

    def get(self,requests):
        result = [{
            'id'           : movie.id,
            'name'         : movie.name,
            'stillcut_url' : movie.movieimage_set.all().first().stillcut_url
            }
        for movie in Movie.objects.all()]        
        return JsonResponse({"result":result},status = 200)
  • 해당 view를 실행시키고 sql문을 찎어보면 다음과 같다.

  • 처음에 Movie테이블에 존재하는 모든 객체들의 id와 이름을 가져오기 위한 SQL문 + Movie테이블에 존재하는 N개(여기선 5개)객체와 역참조된 대상을 가져오기 위한 SQL문 N개 = N+1개
  • 총 N+1개의  SQL문이 실행된다. 데이터의 수가 5-6개일때는 상관없지만, 일반적으로 몇십만개씩 데이터가 들어가는 경우 실제로 몇분에 걸친시간이 소요될 수도 있다.
  • 이러한 ORM의 고질적인 문제를 N+1이슈라고 한다

 

Eager loading

  • 이러한 문제를 select_related 혹은 prefetch_related를 사용한 eager loading을 통해 해결할 수 있다.
  • eager loading(즉시로딩)이란 연관관계에 있는 데이터까지 한번에 조회해 오는 기능
  • 위의 코드에서 prefetch_related를 사용해 movieimage_set을 가져온 것 외에는 모두 동일함
  • 질문)아래의 코드에서 .first()로 가져오면 왜 sql문이 또 요청되는 거임?
class ProductListView(View):

def get(self,requests):
        
        movies_list = Movie.objects.all().prefetch_related('movieimage_set')
        result = [{
            'id'          :movie.id,
            'name'        :movie.name,
            'stillcut_url':[movie.stillcut_url for movie in movie.movieimage_set.all()][0]
        # 'stillcut_url':movie.movieimage_set.all().first().stillcut_url, #이때는 또 호출을 여러번함..도대체 왜?
        } for movie in movies_list]
                
        return JsonResponse({"result":result},status = 200)
  • prefetch_related하나 추가했는데 해당 코드의 sql로그를 찍어보면 DB에 보낸 SQL문이 6번에서 2번으로 줄은 것. 만약 데이터의 개수가 훨씬 더 많았더라면 분명이 유의미한 차의가 났을 것이다.

 

Model

  • 앞으로 eager loading을 알아보기 위해 사용하는 모델은 다음과 같다. 해당 모델을 바탕으로 select_related와 prefetch_related를 알아보자.
    • Book을 중심으로, Book과 Publisher는 1:N의 관계를 맺고 있으며
    • Book과 Store는 M:N의 관계를 맺고 있다.
from django.db import models

class Publisher(models.Model):
    name = models.CharField(max_length=255)
    
    class Meta:
        db_table = 'publishers'

    def __str__(self):
        return self.name

class Book(models.Model):
    name = models.CharField(max_length=255)
    price = models.IntegerField(default=0)
    publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE)
    
    class Meta:
        db_table = 'books'

    def __str__(self):
        return self.name

class Store(models.Model):
    name = models.CharField(max_length=255)
    books = models.ManyToManyField(Book)
    class Meta:
        db_table = 'stores'

    def __str__(self):
        return self.name

 

Select_related

  • 그러면 먼저 select_related에 대해 알아보자
  • select_related는 1:1의 관계 혹은 1:N의 관계에서 N이 사용할 수 있다. 즉 정참조의 경우에 사용이 가능하다.
  • 위의 예시에서는 Book이 Publisher에게 접근할 때가 정참조이므로, Book에서 Publisher로 접근해보려 한다.
  • 우선 select_related를 쓰지 않는 경우이다.
class BooksWithAllMethodView(View):
    @query_debugger
    def get(self, request):

        queryset = Book.objects.all()
        books    = []

        # QuerySet이 평가(Evaluation)될 때, N + 1 Problems 발생
        # 모든 book을 조회하는 SQL 1번 실행
        # book 하나당 publisher를 매번 조회하는 SQL N번 실행
        for book in queryset:
            books.append({
                'id': book.id,
                'name': book.name,
                'publisher': book.publisher.name # book.publisher에 접근, 캐싱되지 않은 데이터이므로 query 발생
                }
            )

        return JsonResponse({'books_with_all_method' : books }, status=200)

  • book마다 publisher를 조회하므로, 엄청나게 많은 SQL 요청이 실행되었음을 알 수 있다. 길어진 시간은 덤

 

  • 그럼 이제 select_related를 한번 사용해보자.
  • 위의 예시에서 달라진 거라곤 select_related를 사용해 publisher테이블을 가져오는 것 뿐임
  • 참조할 테이블 클래스명을 소문자로 적어주면 된다.
  • 추가)언더스코어(books__store_name)도 사용이 가능하다
class BooksWithSelectRelatedView(View):
    @query_debugger
    def get(self, request):
        queryset = Book.objects.all().select_related("publisher")
        print("queryset.query에 저장된 SQL문 :: ", queryset.query)

        books = []

        for book in queryset:
            books.append({
                'id': book.id,
                'name': book.name,
                'publisher': book.publisher.name
                }
            )

        return JsonResponse({'books_with_all_method' : books }, status=200)

  • 요청한 쿼리와 요청을 처리하는데 요구되는 시간이 유의미할 정도로 줄어든 것을 확인할 수 있다.

 

  • select_related를 활용할 경우, 요청하는 SQL문이 극단적으로 줄어드는 원리는 무엇일까? 쿼리셋을 찍어보면 알 수 있다.

  • 즉 select_related는 JOIN을 활용하여 필요한 다른 테이블의 데이터를 한번에 같이 가져오기 때문에, 따로 SQL문을 요청하여 데이터를 다시 가져올 필요가 없는 것임

 

Prefetch_related

  • 이제 Prefetch_related에 대해 알아보자
  • Prefetch_related는 역참조의 경우이거나, manytomany필드의 관계에서 사용한다.(즉 참조되는 객체의 수가 1개가 아닐때임)
  • select_related와 다르게, JOIN기능을 사용하지 않는다.
class StoresWithAllMethodView(View):
    @query_debugger
    def get(self, request):
        print(f'Store에서 Book Instance에 접근하는 경우 <역참조>')
        queryset = Store.objects.all()
        stores   = []

        for store in queryset:
            books = [book.name for book in store.books.all()]
            stores.append({
                'id': store.id,
                'name': store.name,
                'books': books
                }
            )

        return JsonResponse({'stores_with_all_method' : stores }, status=200)

  • 역참조/M:M처럼 많은 객체를 참조하기 위하여 innerjoin구문이 사용된 SQL문을 엄청나게 많이 생성하는 것을 확인할 수 있다.

 

  • 그럼 이제 prefetch_related를 사용해보자
  • 역시 참조할 테이블 클래스명을 소문자로 적어주면 된다. 만약 manytomany필드나 related_name을 사용하지 않았을 경우, '테이블명_set'과 같이 적어주면 된다 ex)MovieImage 클래스  > 'movieimage_set'
class StoresWithPrefetchRelatedView(View):
    @query_debugger
    def get(self, request):
        queryset = Store.objects.all().prefetch_related("books")
        print("queryset.query에 저장된 SQL문 :: ", queryset.query)
        print("final after queryset._result_cache :: ", queryset._result_cache)
        print("final after queryset._prefetch_related_lookups :: ", queryset._prefetch_related_lookups)

        stores = []

        for store in queryset:
            books = [book.name for book in store.books.all()]
            stores.append({
                'id': store.id,
                'name': store.name,
                'books': books
            })

        print("!!!! result_cache :: ", queryset._result_cache)

        return JsonResponse({'stores_with_prefetch_related' : stores }, status=200)

  • 쿼리셋이 두개로 줄어든 것을 확인할 수 있다. 요청처리 시간도 함께 줄어든 것은 덤
  • select_related와 달리 쿼리셋이 두개인 이유는 prefetch_related의 작동방식 때문. 질문)아래의 이해가 맞는지
    • innerjoin을 사용하는 select_related와 다르게, prefetch_related는 필요한 데이터를 얻기 위한 추가쿼리를 하나 더 발생시킨다. 그래서 발생된 쿼리의 개수가 총 2개가 되는 것
    • 이 추가쿼리는 '쿼리셋._prefetch_related_lookups'에 저장되었다가 필요할 때 요청이 보내지며, 데이터 in [1,2,3,4] 와 같은 형식임. 특정 조건에 포함되는 모든 데이터를 같이 가져와라~
    • 그러면 prefetch_related_lookups요청을 통해 얻어진 데이터들이 캐싱이되어서 resulted_cache에 저장됨.
    • 저장된 캐시만으로 필요한 데이터를 반환하므로, 더이상 데이터를 db에 요청하지 않는다
    • 참고)참고로 원래 쿼리는 '쿼리셋.query'에 저장됨

 

Prefetch

  • prefetch_related를 사용해도 여전히 많은 쿼리요청을 보내는 경우가 있다
class StoresWithPrefetchNoneObjectView(View):
    @query_debugger
    def get(self, request):
        queryset = Store.objects.all().prefetch_related("books")

        stores = []

        for store in queryset:
            total_books    = [book.name for book in store.books.all()]
            filtered_books = [book.name for book in store.books.filter(name='Book9991')]
            stores.append({
                'id'          : store.id,
                'name'        : store.name,
                'total_books' : total_books,
                'filterd_books' : filtered_books
            })

        return JsonResponse({'stores_with_prefetch_related' : stores }, status=200)

  • 1000번이 넘는 쿼리를 요청한다. prefetch_related를 사용하지 않을 때와 동일한 결과
  • books.all()이 아니라, books.filter가 추가되었을 때 위와 같은 결과가 반환되므로, 결국 filter라는 조건에 의해 위와 같은 결과가 나오는 것
  • 이러한 문제를 해결하기 위해, 장고에는 prefetch라는 기능이 있다.

 

  • Prefetch인자를 사용해서, db로부터 미리 데이터를 받아 캐싱해둘 수 있음(
    • 질문-캐싱맞음? 캐싱해둬서, 나중에 더이상 데이터를 요청하지 않을 수 있는 것?
  • 아래에서 사용된 두번의 Prefetch는 각각 다음을 의미함
    • Book테이블의 모든 책들을 가져와서 total_books라는 이름으로 캐싱해둬라
    • 이름이 book9991인 책들을 모두 가져와서, filtered_books라는 이름으로 캐싱해둬라 
  • 데이터를 미리 캐싱해서 가지고 있기 때문에, 매번 sql문을 발생시켜 db에 요청할 필요가 없음
  • 질문)결국 캐싱의 문제인지? 혹은 캐싱되었다고 인식하는 것이 문제인지?
class StoresWithPrefetchObjectView(View):
    @query_debugger
    def get(self, request):
        queryset = Store.objects.prefetch_related(
            Prefetch('books', queryset=Book.objects.all(), to_attr='total_books'),
            #모든 책을 가져와서, total_books라는 이름으로 caching해놔라
            Prefetch('books', queryset=Book.objects.filter(name='Book9991'), to_attr='filtered_books'),
            #이름이 book9991이라는 애를 가져와서 filtered_book이라는 애에 저장을 해놔라 라는 뜻
            
        )

        stores = []

        for store in queryset:
            total_books    = [book.name for book in store.total_books]
            filtered_books = [book.name for book in store.filtered_books]
            stores.append({
                'id'          : store.id,
                'name'        : store.name,
                'total_books' : total_books,
                'filterd_books' : filtered_books
            })

        return JsonResponse({'stores_with_prefetch_related' : stores }, status=200)

  • 쿼리가 세개로 확 줄었음을 확인할 수 있다. Store정보를 가져오는 쿼리 하나, prefetch로 가져오는 각각의 쿼리 두개 합쳐서 총 세개가 되는 것임 

 

5. 염두해둘 부분

  • 결국 이런 orm의 최적화나 db요청의 처리시간문제는 단일요청이 아니라, 복합 요청(여러개의 요청이 한번에 들어오는 경우)에 눈덩이처럼 불어난다.
  • 따라서 기본적으로 ORM최적화는 기본이고, 결국은 Nignx를 사용한 비동기 처리라든지 django에 내장된 멀티프로세싱 등으로 지식을 확장시켜나가야 한다ㅠㅠ..

BELATED ARTICLES

more