Django ORM은 DB와 Python 객체를 mapping 해주는 강력한 기능을 지원한다.
이번 글은 Django ORM queryset 중, 여러 개의 model 을 처리할 때 유용하게 쓸 수 있는 select_related() 와 prefetch_related()에 대해 예제를 통해 알아본다.
간단 소개
query와 모든 내용을 확인하기에 앞서, Django에서 ORM 최적화로 쓰이는 select_related 와 prefetch_related 에 대해 간단히 알아보고 실습을 진행한다.
Django Docs — select_related
Django Docs — prefetch_related
Django ORM으로 데이터를 조회시 relation의 여부에 따라 추가로 DB 조회가 발생하는데,
select_related 와 prefetch_related 를 적절히 사용하면 보다 적은 SQL로 데이터를 가져올 수 있다.
select_related
# product-factory 가 FK로 relation이 정의되어 있다고 가정
# Normal ORM Query
# DB 조회
product = Product.objects.get(pk=1)
# 다시 DB를 조회하여 Product 객체에 연관된 데이터 조회(Factory)
factory = product.factory
# select_related Query
product = Product.objects.select_related("factory").get(pk=1)
# 이전 query에서 product.factory가 같이 조회되었기 때문에 DB를 조회하지 않는다
factory = product.factory
Foreign key relation (OneToOne, ManyToOne) 이 명시된 Query 실행 시 SELECT 구문에 DB 단에서 SQL JOIN 을 사용해 참조하여 미리 relation object 를 불러온다.
위 예시를 보면, product 조회 후, factory를 다시 조회하면 2번의 DB를 조회하게 된다. (lazy-loading)
select_related 를 적용하면, relation이 명시된 querySet 조회 시, Foreign key 로 명시된 relation 을 JOIN 문으로 같이 가져오기 때문에 추가의 DB query 가 발생하지 않는다.
select_related는 역참조하는 single-valued relation object: OnetoOne, ManytoOne 혹은 정참조 ForeignKey 관계에서 작동하며,
정참조 many relation: ManytoMany 혹은 OnetoMany 관계에서는 이를 피하도록 권장한다.
prefetch_related
select_related와 같이 DB로부터 데이터를 cache하고, 모든 relation에 대해 사용 가능하다.
select_related와 달리, SQL JOIN으로 DB 단에서 같이 가져오지 않고, Python 에서 joining을 진행한다.
정참조 multiple relation object: ManytoMany, OnetoMany 혹은 역참조 ForeignKey 의 객체를 불러오는 상황에서 큰 효과를 보이며,
GenericRelation과 GenericForeignKey 또한 지원한다. 하지만, 동일한 집합의 결과로만 제한하여 사용해야 한다.
즉, 하나의 ContentType에 제한된 경우만 GenericForeignKey를 참조하여 객체를 미리 가져올 수 있다.
(2개 이상의 ContentType에서는 불가)
prefetch_related는 main query가 실행된 다음, 추가로 실행되기 때문에, select_related 가 전반적으로 DB I/O 를 줄일 수 있는 방법으로 사용된다.
환경 준비
예시 모델링
공장: 제품을 생산 [이름,위치]
제품: 공장에서 생산된 제품[이름, 공장(FK)]
매장: 제품을 판매하는 곳 [이름,위치, 제품(M2M)]
# models.py
from django.db import models
class Factory(models.Model):
name = models.CharField(verbose_name="공장명", max_length=128)
location = models.CharField(verbose_name="위치", max_length=128)
def __str__(self) -> str:
return f"{self.pk}_{self.name}"
class Meta:
db_table = "factory"
class Product(models.Model):
name = models.CharField(verbose_name="제품명", max_length=128)
factory = models.ForeignKey(
Factory, on_delete=models.DO_NOTHING, related_name="product"
)
def __str__(self) -> str:
return f"{self.factory}_{self.name}"
class Meta:
db_table = "product"
class Market(models.Model):
name = models.CharField(verbose_name="시장명", max_length=128)
location = models.CharField(verbose_name="위치", max_length=128)
products = models.ManyToManyField(Product, related_name="market")
def __str__(self) -> str:
return f"{self.pk}_{self.name}"
class Meta:
db_table = "market"
공장 -> 제품 -> 시장 으로 연결되는 유통 과정에 필요한 데이터를 model 로 명시했다.
# command/load_data.py
from django.core.management.base import BaseCommand
from market.models import Product, Factory, Market
class Command(BaseCommand):
def handle(self, *args, **options):
# 기존 데이터 삭제 후 재생성
Market.objects.all().delete()
Product.objects.all().delete()
Factory.objects.all().delete()
# 공장 데이터 생성
factories = [
Factory(name=f"factory_{num}", location=f"somewhere_{num}")
for num in range(3)
]
Factory.objects.bulk_create(factories)
# 제품 데이터 생성
products = []
for factory in Factory.objects.all():
fc_products = [
Product(name=f"factory_{factory.id}_product_{num}", factory=factory)
for num in range(6)
]
products += fc_products
Product.objects.bulk_create(products)
# 매장 데이터 생성
markets = [Market(name=f"market_{num}") for num in range(2)]
Market.objects.bulk_create(markets)
# 매장에 상품 배치 데이터 생성
for market in Market.objects.all():
for factory in Factory.objects.all():
market_products = [
market.products.through(market_id=market.pk, product_id=p.pk)
for p in factory.product.all()
]
market.products.through.objects.bulk_create(market_products)
각 model 객체를 읽어오기 전에, 테스트 데이터를 생성할 수 있게, Django command 를 설정한다.
아래 커맨드를 실행하면 테스트 데이터가 생성된다.
$ python3 manage.py load_data
... 기존 데이터 삭제 및 새로운 테스트 데이터 생성
테스트 데이터
공장: 3개
제품: 각 공장별 6개의 제품
매장: 2개
모든 제품을 각 매장마다 등록
테스트 환경은 DRF + decorator 를 통한 쿼리 출력으로 진행한다.
DRF + decorator 를 통한 쿼리 출력
import functools
import logging
from django.db import connection, reset_queries
logger = logging.getLogger("django.db.backends")
logger.setLevel(logging.DEBUG)
logger.addHandler(logging.StreamHandler())
def query_debugger(func):
@functools.wraps(func)
def query_func(*args, **kwargs):
reset_queries()
start_query = connection.queries
result = func(*args, **kwargs)
end_query = connection.queries
logger.info(f"Number of queries: {len(end_query)-len(start_query)}")
s_query_str = "\n".join(map(str, start_query))
logger.info(f"[START_QUERY] {s_query_str}")
e_query_str = "\n".join(map(str, end_query))
logger.info(f"[END_QUERY] \n{e_query_str}")
return result
return query_func
DRF ViewSet을 호출하였을 때, 쿼리를 출력하기 위해,
ViewSet을 조회할 때 생성되는 쿼리를 확인할 수 있도록 별도의 decorator를 추가해주었다.
django.db.connection 실행 중 생성되는 총 query 갯수와 쿼리 내용을 출력한다.
# views.py
from rest_framework import viewsets
from market.models import Product, Factory, Market
from market.serializers import (
FactorySerializer,
MarketSerializer,
ProductWithFactorySerializer,
)
from query_test.debugger import query_debugger
class ProductViewSet(viewsets.ModelViewSet):
queryset = Product.objects.all()
serializer_class = ProductWithFactorySerializer
@query_debugger
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@query_debugger
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
class FactoryViewSet(viewsets.ModelViewSet):
queryset = Factory.objects.all()
serializer_class = FactorySerializer
@query_debugger
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@query_debugger
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
class MarketViewSet(viewsets.ModelViewSet):
queryset = Market.objects.all()
serializer_class = MarketSerializer
@query_debugger
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
@query_debugger
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
# serializers.py
from rest_framework import serializers
from market.models import Product, Factory, Market
class FactorySerializer(serializers.ModelSerializer):
class Meta:
model = Factory
fields = "__all__"
class ProductWithFactorySerializer(serializers.ModelSerializer):
factory = FactorySerializer(read_only=True)
class Meta:
model = Product
fields = ["pk", "name", "factory"]
class MarketSerializer(serializers.ModelSerializer):
products = ProductWithFactorySerializer(many=True, read_only=True)
class Meta:
model = Market
fields = "__all__"
이제 serializer와 view를 추가하고, 앞서 생성한 decorator를 적용해주자.
view 는 ModelViewSet을 상속받아 list 와 retrieve 조회 view 메소드만 명시해주고,
serializer 는 별도 처리 없이 각 model의 필드만 출력할 수 있게 설정해주었다.
FactorySerializer - 추가 조회 없음
ProductWithFactorySerializer - Factory 조회(ForeignKey)
MarketSerializer - Product 조회 & Factory 정보 조회(ManyToManyField)
이제 앞서 만든 debugger 를 decorator로 적용하여 조회하는 과정에서 query가 어떻게 발생하는지 확인할 수 있다.
본격적으로 각 상황에 발생하는 쿼리 내용과 갯수를 확인하고 이를 최적화해보자.
Factory 조회
# Factory list 조회
Number of queries: 1
[START_QUERY]
[END_QUERY]
{'sql': 'SELECT "factory"."id", "factory"."name", "factory"."location" FROM "factory"', 'time': '0.001'}
# Factory retrieve 조회
Number of queries: 1
[START_QUERY]
[END_QUERY]
{'sql': 'SELECT "factory"."id", "factory"."name", "factory"."location" FROM "factory" WHERE "factory"."id" = 1 LIMIT 21', 'time': '0.002'}
Factory는 별도의 relation이 명시되어 있지 않기에 list, retrieve 모두 하나의 쿼리로 조회가 가능하다.
Product 조회
Number of queries: 19
[START_QUERY]
[END_QUERY]
[1] {'sql': 'SELECT "product"."id", "product"."name", "product"."factory_id" FROM "product"', 'time': '0.001'}
[2] {'sql': 'SELECT "factory"."id", "factory"."name", "factory"."location" FROM "factory" WHERE "factory"."id" = 1 LIMIT 21', 'time': '0.000'}
[2] {'sql': 'SELECT "factory"."id", "factory"."name", "factory"."location" FROM "factory" WHERE "factory"."id" = 1 LIMIT 21', 'time': '0.000'}
...
{'sql': 'SELECT "factory"."id", "factory"."name", "factory"."location" FROM "factory" WHERE "factory"."id" = 2 LIMIT 21', 'time': '0.000'}
...
{'sql': 'SELECT "factory"."id", "factory"."name", "factory"."location" FROM "factory" WHERE "factory"."id" = 3 LIMIT 21', 'time': '0.000'}
Product는 Factory의 FK를 갖고 있으며, serializer에서 명시한대로 Factory의 정보를 같이 불러온다.
현재 쿼리는 ProductViewSet 의 queryset = Products.objects.all() 로 명시되어 있어,
[1] Product 정보 조회
[2] 각 Product와 relation이 명시된 Factory 조회 <- 문제 발생
위 테스트 데이터에 명시된 것과 같이 동일한 Factory 에서 생산된 Product 2개를 조회하는데,
product_1 - factory_1, product_2 - factory_1 ... 의 각 관계를 모두 불러오며, factory 정보 조회에서 중복이 발생한다.
테스트 데이터는 공장 3개, 제품 6개 존재하니
작업 [쿼리수]로 정리하면
Product list 조회[1] + 각 Product의 Factory 조회[6*3=18] = 19
총 19개의 query 가 발생한다.
여기서 문제 해결 방법은 각 Product 의 Factory 정보를 미리 가져오게 하면, 각 Product 조회마다 처리하지 않고, SQL JOIN을 통해 DB I/O 를 줄이는 것이다.
queryset을 Product.objects.select_related("factory").all() 로 변경하여 결과를 보자.
Number of queries: 1
[START_QUERY]
[END_QUERY]
{'sql': 'SELECT "product"."id", "product"."name", "product"."factory_id", "factory"."id", "factory"."name", "factory"."location" FROM "product" INNER JOIN "factory" ON ("product"."factory_id" = "factory"."id")', 'time': '0.002'}
Product의 정보를 불러올 때, Factory 정보를 JOIN 문으로 같이 가져오며
무려 19개의 query가 단 1줄의 query로 모든 정보를 조회한다 😳
Market 조회
Market 은 ManyToManyField를 통해 Product 와 ManyToMany 관계를 가진다.
실무에서는 ManyToManyField를 사용할때, 조심해야 할 부분은 별도 명시 없이는 Django 가 지정된 이름으로 M2M table을 생성하기 때문에 through 인자를 명시하여 꼭 별도의 model 로 관리하는게 좋다.
공장 3개
제품 6개
매장 2개
매장을 조회하면 serializer 에 명시된 내용에 따라 Market-Product-Factory 까지 전체 3개 model 을 조회한다.
MarketViewset의 queryset = Market.objects.all() 로 Market 목록조회 결과를 보자.
Number of queries: 39
[START_QUERY]
[END_QUERY]
[1] {'sql': 'SELECT "market"."id", "market"."name", "market"."location" FROM "market"', 'time': '0.001'}
[2] {'sql': 'SELECT "product"."id", "product"."name", "product"."factory_id" FROM "product" INNER JOIN "market_products" ON ("product"."id" = "market_products"."product_id") WHERE "market_products"."market_id" = 1', 'time': '0.001'}
[3] {'sql': 'SELECT "factory"."id", "factory"."name", "factory"."location" FROM "factory" WHERE "factory"."id" = 1 LIMIT 21', 'time': '0.000'}
...
{'sql': 'SELECT "factory"."id", "factory"."name", "factory"."location" FROM "factory" WHERE "factory"."id" = 3 LIMIT 21', 'time': '0.000'}
{'sql': 'SELECT "product"."id", "product"."name", "product"."factory_id" FROM "product" INNER JOIN "market_products" ON ("product"."id" = "market_products"."product_id") WHERE "market_products"."market_id" = 2', 'time': '0.000'}
{'sql': 'SELECT "factory"."id", "factory"."name", "factory"."location" FROM "factory" WHERE "factory"."id" = 1 LIMIT 21', 'time': '0.000'}
...
{'sql': 'SELECT "factory"."id", "factory"."name", "factory"."location" FROM "factory" WHERE "factory"."id" = 3 LIMIT 21', 'time': '0.000'}
[1] Market 목록 조회
[2] Market-Product 의 M2M relation이 명시된 Product 목록 조회
[3] 각 Product와 relation이 명시된 Factory 조회
위와 마찬가지로
작업 [쿼리수]로 정리하면
Market 목록조회[1] + Market-Product M2M 을 통한 Product 목록조회[1*2] + 각 Product의 Factory 조회[6*3*2=18] = 39
Product 조회까지는 2개의 Market 이 있기에 2배의 쿼리가 발생하고, Product 의 Factory 를 조회할 때도 동일하게 2배가 발생한다. 이는 위와 동일한 lazy loading 으로 인해 Product 조회 시 마찬가지로 각 Market 에 따라 별도의 중복 쿼리가 발생한다.
이러한 문제가 바로 ORM의 N+1 이슈이다.
위 관계는 ManyToOne, ManyToMany 관계를 띄고 있고, Market-Product-Factory 를 조회하려면 결국 3개의 테이블 조회가 이뤄져야 한다. 그럼 먼저 39개의 쿼리를 3개로 줄여보자.
MarketViewset.queryset을 Market.objects.prefetch_related(“products__factory”).all() 로 바꾸어 조회해보자.
products__factory 는 M2M 관계인 products의 factory(FK) 정보를 의미한다.
즉, 위 ORM으로 데이터를 가져올 때, Product의 Factory에 대한 정보를 같이 가져오겠다는 의미이다.
Number of queries: 3
[START_QUERY]
[END_QUERY]
{'sql': 'SELECT "market"."id", "market"."name", "market"."location" FROM "market"', 'time': '0.000'}
{'sql': 'SELECT ("market_products"."market_id") AS "_prefetch_related_val_market_id", "product"."id", "product"."name", "product"."factory_id" FROM "product" INNER JOIN "market_products" ON ("product"."id" = "market_products"."product_id") WHERE "market_products"."market_id" IN (1, 2)', 'time': '0.001'}
{'sql': 'SELECT "factory"."id", "factory"."name", "factory"."location" FROM "factory" WHERE "factory"."id" IN (1, 2, 3)', 'time': '0.000'}
단 3개의 query로 해결됐다! 위 query 내용을 해석하면 아래와 같다.
Market List 조회[1] + Market-Product M2M JOIN[1] + Factory List 조회[1] = 3
추가 query를 생성하여, ManyToMany table을 조회하고, 이를 통해 조회된 Factory 정보를 가지고 목록을 불러온다.
하나의 Model object마다 query를 생성하는게 아닌 적절한 JOIN과 WHERE 구문으로 정말 적은 query로 데이터를 볼 수 있는 것이다.
Django shell 을 통한 쿼리 출력
DRF 와 decorator를 사용한 설정처럼 별도 기능 명시 없이 바로 Django shell 로도 쿼리를 확인할 수 있다. (그럼 왜 DRF로 소개했냐...재밌잖아😁)
$ python manage.py shell_plus --print-sql
...
>>>
터미널에서 django shell 을 켜주면 해당 프로젝트의 model과 기능을 불러와 실행할 수 있다.
django-extensions 를 설치하면 shell_plus 기능을 사용할 수 있다. 별도로 model 을 import 하지 않아도 자동으로 사용할 수 있어 편리하다.
--print-sql 인자는 shell에서 실행하는 각 querySet 마다 DB query를 보여주는 설정이다.
# Factory list(목록)
$ factories = Factory.objects.all()
$ print(factories.query)
SELECT "factory"."id", "factory"."name", "factory"."location" FROM "factory"
# Factory retrieve(단일)
$ factory = Factory.objects.get(pk=1)
$ print(factory.query)
SELECT "factory"."id",
"factory"."name",
"factory"."location"
FROM "factory"
WHERE "factory"."id" = 1
LIMIT 21
Add-on
select_related 와 prefetch_related 는 같이 사용이 가능하다. 공식문서의 예제를 빌려와서 설명한다.
https://docs.djangoproject.com/en/2.2/ref/models/querysets/#prefetch-related
from django.db import models
class Topping(models.Model):
name = models.CharField(max_length=30)
class Pizza(models.Model):
name = models.CharField(max_length=50)
toppings = models.ManyToManyField(Topping)
class Restaurant(models.Model):
pizzas = models.ManyToManyField(Pizza, related_name='restaurants')
best_pizza = models.ForeignKey(Pizza, related_name='championed_by', on_delete=models.CASCADE)
Restaurant의 pizzas와 best_pizza는 같은 Model을 바라보지만 조회시 거치는 Model이 다르다.
>>> Restaurant.objects.prefetch_related('pizzas__toppings')
위 ORM query는 총 3개의 DB query를 생성한다.
Restaurant[1] + Pizzas[1] + Toppings[1]
>>> Restaurant.objects.prefetch_related('best_pizza__toppings')
마찬가지로 best_pizza 를 거쳐 topping 을 조회하면 3개의 query가 발생한다.
Restaurant[1] + best Pizza[1] + Toppings[1]
이에 select_related 를 같이 적용하면 아래와 같다.
>>> Restaurant.objects.select_related('best_pizza').prefetch_related('best_pizza__toppings')
ForeignKey로 연결된 best_pizza를 JOIN으로 같이 가져오고, toppings를 조회하면 총 2개의 query로 조회할 수 있다.
Restaurant&best Pizza(JOIN)[1] + Toppings[1]
Reference
QuerySet API reference | Django documentation | Django (djangoproject.com)
'Python > Django' 카테고리의 다른 글
Django Testcase (0) | 2022.04.24 |
---|