오히려 좋아..

상황이 나쁘게만 흘러가는 것 같을 때 외쳐보자.. .

궁금한 마음으로 포트폴리오 보기

Web Programming/Django

[Django] 장고 쿼리셋 파헤치기(Eager Loading)

junha6316 2020. 11. 27. 11:00

아래 내용은 김성렬님의 2020 Pycon-Korea Django ORM (QuerySet)구조와 원리 그리고 최적화전략을 정리해둔 내용입니다. 

 

https://www.youtube.com/watch?v=EZgLfDrUlrk

 

장고는 ORM(Object Relational Mapping)을 이용해 데이터를 관리한다. ORM은 데이터 베이스를 코드를 이용해 관리할 수 있도록 구현한 인터페이스로서 좀 더 쉽게 데이터베이스의 동작을 관리할 수 있도록 하는데 목적이 있다. 하지만 ORM은 편리함의 반대 급부로 성능상 손해를 본다. 이번 포스팅에서는 장고 ORM의 단점인 Lazy Loading 그리고 단점을 해결하기 위한 방법에 대해 알아보도록 하겠다.

 

1. ORM 단점:  Lazy-loading(지연 로딩)

지연 로딩이란 말 그대로 간단하게 말하면

 

""" 필요한 시점에서 쿼리를 날린다. 즉 해당 데이터가 필요하지 않으면 가져오지 않는다. """

 

* 잠깐  "쿼리를 날린다" 라는 표현을 처음 들어보는 사람을 위해 설명하자면 데이터베이스는 SQL을 이용해 조작하는데 SQL를 작성해 데이터베이스를 조작하는 행위를 쿼리를 날린다 라고 한다. 쉽게 그냥 DB로부터 데이터를 갖고 온다고 생각하면 된다.

 

만약 아래와 같이 작성한다고 해도 DB에 쿼리를 날려 데이터를 갖고 오지는 않는다.

rooms = models.Room.objects.all() 

실제로 필요한 순간 출력하거나 값을 저장하거나 참조할 때 해당 값을 로딩한다.

print(rooms)

rooms = list(rooms)
first_room = rooms[0]

장고 프로젝트 공식 홈페이지에 따르면 쿼리가 날아가는 시점은 아래와 같이 7가지 경우이다.

DB로 쿼리가 날라가는 7가지 경우

이쯤되었다면 똑똑한 사람은 지연 로딩이 어떤 문제를 갖고 오는지 궁금해질 것이다. 단점은 아래와 같다.

1.1 비효율성

불필요한 쿼리가 여러번 수행 될 수 있다. 캐싱해두고 쿼리를 사용하지 않으면 중복되는 쿼리가 지속적으로 발생하는데 아래와 같은 예가 있다. Cache를 항상 생각 할 것, 순서가 달라지면 SQL이 달라진다.

company = list(Company.objects.prefetch_related("product_set").all()

company = company_list[0]
company.product_set.all()


company.product_set.filter(name="불닭볶음면")
#=> SQL 발생
fire_noodle_product_list = [product for product in company.product_set.all() if product.name =="불닭볶음면"]
#=> 캐쉬 활용

1.2 N+1 Problem

지연 로딩의 또 다른 문제는 바로 외래키관계에 있는 데이터를 참조해서 호출할 때 발생한다. lazy-loading은 일단 쿼리가 날라갈 때 참조 모델(외래키, 다대다 관계)의 데이터는 갖고 오지 않고 해당 모델이 갖고 있는 필드만을 갖고 온다. 그렇기 때문에 현재 모델에서 외래키 관계에 있는 모델을 호출할 때마다 쿼리가 날아가게 된다. 예를 들어 아래 코드를 보자. 만약 User 모델과 외래키 관계에 있는 Userinfo를 for문을 이용해 호출하면 for 문이 돌 때마다 queryset 안에 없는 데이터이므로 지속적으로 쿼리 발생한다.

def example(request):
    users = User.objects.all()
    
    for user in users:
    	user.userinfo # user와 userinfo는 1대1 관계
        
    return

이러한 문제를 해결하기 위해 다른 테이블에 있는 필요한 정보를 하나의 SQL문으로 갖고 올 수 있도록 Eager-Loading(즉시 로딩)을 사용한다.

 

2. Eager-Loading(즉시 로딩) : SQL 한번에 모든 걸 갖고오자!

즉시 로딩지연로딩과 반대되는 개념으로 필요할 때 하나씩 갖고 오는게 아니라 로딩시 필요한 걸 다 갖고 오는 방식이다. 

장고에서는 즉시 로딩을 select_related와 prefatch_related를 이용해 구현해 놓았는데 차이는 아래와 같다.

 

1. select_related => JOIN을 통해 데이터를 즉시 로딩, 역참조 불가

2. prepatch_related => 추가 쿼리 셋을 이용해 데이터를 갖고오고 애플리케이션 단에서 합쳐서 데이터를 반환한다, 역참조 가능

# A queryset
queryset= {
    Model.objects.prefetch_related(
    "b_model_set",
    "c_model"
    )}

from django.db.models import Prefetch

# B queryset
queryset ={
    Model.objects.
    prefetch_related{
    Prefatch(to_attr="b_model_set", queryset=BModel.objects.all()),
    Prefatch(to_attr="c_models", queryset=CModel.objects.all()),
    )

A queryset과 B query 동일함 

2.1 QuerySet 구성

하나의 쿼리셋은  1개의 쿼리(Main Query) + 0~N개 추가 쿼리셋

추가 쿼리셋은 어떻게 정해지는가?

=> prepatch_related에 의해서 결정된다. 

 

주요 변수

_result_cache : 호출된 데이터를 저장하는 변수/ 만약 찾는 데이터가 여기 없으면 추가 쿼리셋 수행

prefetch_related_lookups : 추가 쿼리셋의 타겟

iterable_class : QuertSet의 반환 타입 결정(default는 Model)

 

2.2 성능 평가

assertNumQueries() : 테스트 케이스를 작성해두면 API가 수정될 때 마다 달라지는 SQL 갯수를 체크해줘야 한다.(비효율적)

발표 내용을 보면 효율적으로 Test 코드를 작성하는 방법이 적혀있다.

 

3. QuerySet Kick!

아래는 발표에서 다룬 쿼리셋을 더 효율적으로 다루는 방법에 대한 내용이다.

 

1. prefatch_related()와 필터는 조심해서 사용

외래키에 대한 필터 뒤에 다시 prefatch_related를 사용하면 추가 쿼리가 발생한다. 예를 들어 아래와 같은 쿼리에서 첫번째 필터 구문에서 해당 외래키를 참조하기 위해 조인이 발생하고 prefetch구문 떄문에 추가 쿼리가 발생한다.

즉 웬만해선 prefetch를 먼저하고 filter를 다음에 하자!

Company.objects.filter(product__name="Junha")
               .prefetch_related("product_set", Prefetch(Product.objects.all())

 

2. queryset 캐시를 재활용하지 못하는 queryset 호출을 지양하자!

company = list(Company.objects.prefetch_related("product_set").all()

company = company_list[0]
company.product_set.all()


company.product_set.filter(name="불닭볶음면")
#=> SQL 발생
fire_noodle_product_list = [product for product in company.product_set.all() if product.name =="불닭볶음면"]
#=> 캐쉬 활용

 

3. 원하는 쿼리셋이 없다고 queryset을 포기하지 말 것!

raw queryset 역시 ORM 영역

from django.db.models.query import QuerySet, RawQuerySet

raw = Model.objects.raw("Native SQL")
qs = Model.objects.filter(~)

 

4. SubQuery Set이 발생할 때 조심할 것!

1. queryset 안에 queryset

2. exclude에서 역방향 참조 옵션(버그성 동작??)

정방향은 상관없음

  => prefatch_related(Prefatch())를 사용하는 방식으로 대체 

3. values(), values_list()를 사용하면 Eager Loading 옵션을 무시한다.

 

5. 쿼리셋을 사용하는 좋은 습관

all 뒤에 바로 인덱싱하지 않는게 좋다.

반드시 쿼리셋을 가져온 이후에 값이 있는지 확인하고 사용할 것

survey = Survey.objects.all()[0]

 

 

4. 실제 적용 방법

4. 1 django-debug-Toolbar

사실 처음 장고 ORM을 Eager Loading을 다루는 사람이라면 위의 과정이 어려울 수 있다. 그래서 내가 제안하고 싶은 부분은 장고 툴바를 이용하는 것이다.

https://django-debug-toolbar.readthedocs.io/en/latest/

 

Django Debug Toolbar — Django Debug Toolbar 3.2.2 documentation

© Copyright 2021, Django Debug Toolbar developers and contributors Revision 3b269358.

django-debug-toolbar.readthedocs.io

장고 디버그 툴바를 사용하면 아래 그림처럼 현재 페이지를 조회할때 서버상의 각종 지표를 볼 수 있는데 이중 SQL Query 부분을 보면 몇개의 쿼리가 어느정도의 시간을 갖고 발생했는지 볼 수 있다. 위에서 설명한 내용을 갖고 쿼리수를 가능한 줄이는 연습을 해보다보면 쿼리셋 최적화의 금방 익숙해질 것이다.

4-2. shell에서 확인하기

아래 명령어를 통해 확인할 수 있다.

from django.db import connection

# 원하는 쿼리 작성

print(connection.queries)

 

5. 생각해볼 점

EagerLoading을 이용해 매번 쿼리수를 줄이던 도중 문득 왜 쿼리 수를 줄여야 할까? 궁금했다. 처음 Eagerloading을 배웠을 때는 당연히 쿼리수가 줄면 데이터 베이스 접근 횟수가 줄고 따라 오버로딩이 줄겠다라는 생각으로 적용했고 쿼리 수를 줄였을 때 실제로 동작 시간도 감소했기 때문에 당연히 쿼리수를 줄이면 동작시간이 줄어든다라고 생각했다. 하지만 생각해보면 전체 쿼리는 하나의 트랜잭션으로 묶여 데이터베이스를 실행하고 하나의 트랜잭션은 하나의 커넥션만을 이용하기 때문에 쿼리수가 늘어난다고 해도 네트워크로 인한 오버헤드는 쿼리수가 1개인 경우나 10개인 경우가 차이가 그렇게 크지 않다. 그렇다면 쿼리 수가 늘어나면 왜 데이터 베이스 동작시간이 늘어날까?(물론 데이터 양이 늘어난다면 이야기가 달라진다.)

질문을 바꿔서 쿼리수를 줄이는게 항상 좋은 것인가? 라는 주제로 관련된 자료를 찾다가 아래 글을 찾았다.

https://dba.stackexchange.com/questions/76973/what-is-faster-one-big-query-or-many-small-queries

 

What is faster, one big query or many small queries?

I have been working for different companies, and I have noticed that some of them prefer to have views that will join a table with all its "relatives". But then in the application sometim...

dba.stackexchange.com

먼저 쿼리를 여러개 날릴지 조인되어 있는 쿼리를 하나 날릴지의 문제는 결국 조인을 어플리케이션 단에서 할지 아니면 데이터 베이스 단에서 할지 선택하는 문제와 동일하다. 이 문제에 대한 위 글의 의견을 정리해보면 아래와 같다. 좀 더 생각해보면 EagerLoading 전략중 select_related를 사용할지 prefetch_related를 사용할 지 결정하는 문제도 될 수 있을 것 같다.

 

작은 쿼리를 여러개 날릴 때의 이점(어플리케이션단에서 조인했을 때 장점)은

1. 캐싱에 유리함

2. 개별 쿼리를 동작시키는게 lock을 줄일 수 있음

3. 어플리케이션에서 조인하는게 데이터베이스 확장에 유리함

4. 개별 쿼리 자체가 더 효율적일 수 있음

5. 불필요한 row 접근을 막을 수 있음

6. 직접 구현한 해시조인이 더 효율적일 수 있음

 

큰 쿼리를 날릴 때의 장점

1. 파싱과 다수의 쿼리을 계획할 때 발생하는 오버헤드가 대부분 위에서 언급한 이점보다 크다.

2. 클라이언트에서 이러한 추가작업이 느림, 데이터베이스는 이러한 연산에 특화되어 있음

3. 클라이언트와 데이터 베이스 사이에 더 많은 데이터가 오고간다.

 

결론은 쿼리마다 다른 것 같은데 쿼리를 날렸을 때 어떤 식으로 동작하는지 확인해야 알 수 있을 것 같다.