본문 바로가기
Python/Django

[Django] select_related & prefetch_related

by wiggleji 2022. 4. 24.
Image from unsplash

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)]
Model relation

# 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 조회

3개의 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 조회

Product & Factory 내역 조회
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_relatedprefetch_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