오늘도 개발

[2차 프로젝트 내일의 집] 상품 상세 api - Django ORM 최적화하기 심화 본문

TIL & 프로젝트 회고

[2차 프로젝트 내일의 집] 상품 상세 api - Django ORM 최적화하기 심화

Sueeeeeee 2022. 8. 8. 01:13

상품 상세를 담당하지는 않았지만 포스트 목록보다 더 복잡한 모델은 어떻게 최적화하면 좋을까 궁금해서 혼자 작업해보았다.
settings.py에 Logging 설정을 넣고, django shell에서 하나씩 쳐보면서 작업하니 편했다.
작업하면서 진짜 DB 쿼리 개수가 줄어나가는 걸 보니까 재밌고 신기했다.

0. 모델링

product 아이디가 1번인 상품의 정보를 전달해야 한다고 가정한다.
products 테이블은 brands 테이블과 second_categories 테이블을 정참조한다.
products 테이블의 second_category는 first_categories 테이블을 정참조한다.
products 테이블은 thumbnail_images 테이블과 product_options 테이블을 역참조한다.
products 테이블이 역참조하는 product_options 테이블은 sizes 테이블과 colors 테이블을 정참조한다.
products 테이블은 products 테이블과 다대다 관계이며 through 테이블은 additional_products이다.

class FirstCategory(models.Model):
    name = models.CharField(max_length=45)
    
class SecondCategory(models.Model):
    name           = models.CharField(max_length=45)
    first_category = models.ForeignKey(FirstCategory, on_delete=models.CASCADE)
    
class Color(models.Model):
    name = models.CharField(max_length=45)
    
class Size(models.Model):
    name = models.CharField(max_length=45)
    
class Brand(models.Model):
    name = models.CharField(max_length=45)
    
class Product(TimeStampModel):
    title              = models.CharField(max_length=45)
    main_image         = models.URLField(max_length=500)
    price              = models.DecimalField(decimal_places=3, max_digits=10)
    brand              = models.ForeignKey(Brand, on_delete=models.CASCADE)
    second_category    = models.ForeignKey(SecondCategory, on_delete=models.CASCADE)
    additional_product = models.ManyToManyField('self', through='AdditionalProduct')
    
class ProductOption(models.Model):
    additional_price = models.DecimalField(decimal_places=3, max_digits=10)
    stock            = models.IntegerField()
    product          = models.ForeignKey(Product, on_delete=models.CASCADE)
    size             = models.ForeignKey(Size, on_delete=models.CASCADE)
    color            = models.ForeignKey(Color, on_delete=models.CASCADE)
    
class AdditionalProduct(models.Model):
    product            = models.ForeignKey(Product, on_delete=models.CASCADE,related_name='original_products')
    additional_product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='linked_products')
    
class ThumbnailImage(models.Model):
    url     = models.URLField(max_length=500)
    product = models.ForeignKey(Product, on_delete=models.CASCADE)

1. 최적화를 하지 않는 경우

DB 호출 총 12번
1) DB 호출 - products 테이블 캐싱
2) DB 호출 - second_categories 테이블 가져옴
3) DB 호출 - first_categories 테이블 가져옴
4) DB 호출 - brand 테이블 가져옴
5) DB 호출 - thumbnail_images 테이블 가져옴
6) DB 호출 - additional_products 테이블 가져옴
7) DB 호출 - product_options 테이블 캐싱
8), 9) DB 호출 - sizes 테이블 가져옴(사이즈 옵션이 2개이므로 DB 2번 호출)
10), 11), 12) DB 호출 - colors 테이블 가져옴(컬러 옵션이 3개이므로 DB 3번 호출)

products = Product.objects.filter(id=product_id)

for product in products:
# 1) DB 호출 - products 테이블 캐싱
    print(product.id)
    print(product.second_category.name)
    # 2) DB 호출 - second_categories 테이블 가져옴
    
    print(product.second_category.first_category.name)
    # 3) DB 호출 - first_categories 테이블 가져옴
    
    print(product.brand.name)
    # 4) DB 호출 - brand 테이블 가져옴
    
    print(product.thumbnailimage_set.all())
    # 5) DB 호출 - thumbnail_images 테이블 가져옴
    
    print(product.additional_product.all())
    # 6) DB 호출 - additional_products 테이블 가져옴 

    for option in product.productoption_set.all():
    # 7) DB 호출 - product_options 테이블 캐싱 
    # 옵션이 5개 있다고 가정(사이즈 옵션 2개, 컬러 옵션 3개)
    
        print(option.size.name)
        # 8), 9) DB 호출 - sizes 테이블 가져옴(사이즈 옵션이 2개이므로 DB 2번 호출)
        
        print(option.color.name)
        # 10), 11), 12) DB 호출 - colors 테이블 가져옴(컬러 옵션이 3개이므로 DB 3번 호출)

2. select_related()만 사용하는 경우

Product는 Brand, SecondCategory를 정참조한다.
따라서 다음과 같이 select_related()를 연결하면
products, brands, second_category를 한 번에 DB에서 inner join해서 가져온 뒤 캐싱할 수 있다.

products = Product.objects.filter(id=1)\
           .select_related('brand')\
           .select_related('second_category')
           
# select_related(‘brand’, ‘second_category’)로 작성해도 결과는 똑같다

하지만 이렇게 하면 second_category가 정참조하는 first_category까지 가져올 수 없다.
first_category까지 inner join해서 가져오려면 다음과 같이 작성하면 된다.

products = Product.objects.filter(id=1)\
           .select_related('brand')\
           .select_related('second_category__first_category')

위의 코드는 다음과 같은 SQL 쿼리를 생성한다.

SELECT `products`.`id`, `products`.`created_at`, `products`.`updated_at`, `products`.`title`, `products`.`main_image`, `products`.`price`, `products`.`brand_id`, `products`.`second_category_id`, `brands`.`id`, `brands`.`name`, `second_categories`.`id`, `second_categories`.`name`, `second_categories`.`first_category_id`, `first_categories`.`id`, `first_categories`.`name` 
FROM `products` 
INNER JOIN `brands` ON (`products`.`brand_id` = `brands`.`id`) 
INNER JOIN `second_categories` ON (`products`.`second_category_id` = `second_categories`.`id`) 
INNER JOIN `first_categories` ON (`second_categories`.`first_category_id` = `first_categories`.`id`) WHERE `products`.`id` = 1;


정리하자면 위의 코드는 DB를 총 9회 호출한다.
최적화 전보다 3번이나 DB 호출 횟수가 줄었다.

1) DB 호출 - products, brands, second_categories, first_categories 테이블 inner join해서 캐싱
2) DB 호출 - thumbnail_images 테이블 가져옴
3) DB 호출 - additional_products 테이블 가져옴
4) DB 호출 - product_options 테이블 캐싱
5), 6) DB 호출 - sizes 테이블 가져옴(사이즈 옵션이 2개이므로 DB 2번 호출)
7), 8), 9) DB 호출 - colors 테이블 가져옴(컬러 옵션이 3개이므로 DB 3번 호출)

products = Product.objects.filter(id=1)\
           .select_related('brand')\
           .select_related('second_category__first_category')

for product in products:
# 1) DB 호출 - products, brands, second_categories, first_categories 테이블 inner join해서 캐싱
    print(product.id)
    print(product.second_category.name)
    print(product.second_category.first_category.name)
    print(product.brand.name)
    
    print(product.thumbnailimage_set.all())
    # 2) DB 호출 - thumbnail_images 테이블 가져옴
    
    print(product.additional_product.all())
    # 3) DB 호출 - additional_products 테이블 가져옴 

    for option in product.productoption_set.all():
    # 4) DB 호출 - product_options 테이블 캐싱 
    # 옵션이 5개 있다고 가정(사이즈 옵션 2개, 컬러 옵션 3개)
    
        print(option.size.name)
        # 5), 6) DB 호출 - sizes 테이블 가져옴(사이즈 옵션이 2개이므로 DB 2번 호출)
        
        print(option.color.name)
        # 7), 8), 9) DB 호출 - colors 테이블 가져옴(컬러 옵션이 3개이므로 DB 3번 호출)

 

3. prefetch_related()도 사용하는 경우

Product는 ThumbnailImage, ProductOption을 역참조한다.
Product는 AdditionalProduct와 다대다 관계이다.
이제 이 세 개의 테이블을 prefetch_related()로 불러와서 캐싱해보자.

products = Product.objects.filter(id=1)\
.select_related('brand')\
.select_related('second_category__first_category')\
.prefetch_related('thumbnailimage_set')\
.prefetch_related('additional_product')\ 
.prefetch_related('productoption_set')\

위의 코드는 우선 brands, second_categories, first_categories를 inner join해서 가져온 뒤 캐싱한다.
그 다음 thumbnail_images를 가져와서 캐싱하고, product_options를 가져와서 캐싱하고, linked_products를 가져와서 캐싱한다.
위 코드는 다음과 같은 SQL 쿼리를 생성한다.

SELECT `products`.`id`, `products`.`created_at`, `products`.`updated_at`, `products`.`title`, `products`.`main_image`, `products`.`price`, `products`.`brand_id`, `products`.`second_category_id`, `brands`.`id`, `brands`.`name`, `second_categories`.`id`, `second_categories`.`name`, `second_categories`.`first_category_id`, `first_categories`.`id`, `first_categories`.`name` 
FROM `products` 
INNER JOIN `brands` ON (`products`.`brand_id` = `brands`.`id`) 
INNER JOIN `second_categories` ON (`products`.`second_category_id` = `second_categories`.`id`) 
INNER JOIN `first_categories` ON (`second_categories`.`first_category_id` = `first_categories`.`id`) WHERE `products`.`id` = 1;

SELECT `thumbnail_images`.`id`, `thumbnail_images`.`url`, `thumbnail_images`.`product_id` 
FROM `thumbnail_images` WHERE `thumbnail_images`.`product_id` IN (1);

SELECT `product_options`.`id`, `product_options`.`additional_price`, `product_options`.`stock`, `product_options`.`product_id`, `product_options`.`size_id`, `product_options`.`color_id` 
FROM `product_options` WHERE `product_options`.`product_id` IN (1);

SELECT `additional_products`.`id`, `additional_products`.`product_id`, `additional_products`.`additional_product_id` 
FROM `additional_products` WHERE `additional_products`.`additional_product_id` IN (1);

정리하자면 위의 코드는 DB를 총 9회 호출한다.

1) DB 호출 - products, brands, second_categories, first_categories 테이블 inner join해서 캐싱
2) DB 호출 - thumbnail_images 테이블 가져와서 캐싱
3) DB 호출 - product_options 테이블 가져와서 캐싱
4) DB 호출 - additional_products 테이블 가져와서 캐싱
5), 6) DB 호출 - sizes 테이블 가져옴(사이즈 옵션이 2개이므로 DB 2번 호출)
7), 8), 9) DB 호출 - colors 테이블 가져옴(컬러 옵션이 3개이므로 DB 3번 호출)

for product in products:
# 1) DB 호출 - products, brands, second_categories, first_categories 테이블 inner join해서 캐싱
# 2) DB 호출 - thumbnail_images 테이블 가져와서 캐싱
# 3) DB 호출 - product_options 테이블 가져와서 캐싱
# 4) DB 호출 - additional_products 테이블 가져와서 캐싱

    print(product.id)
    print(product.second_category.name)
    print(product.second_category.first_category.name)
    print(product.brand.name)
    print(product.thumbnailimage_set.all())
    print(product.additional_product.all())

    for option in product.productoption_set.all():
    # 옵션이 5개 있다고 가정(사이즈 옵션 2개, 컬러 옵션 3개)
    
        print(option.size.name)
        # 5), 6) DB 호출 - sizes 테이블 가져옴(사이즈 옵션이 2개이므로 DB 2번 호출)
        
        print(option.color.name)
        # 7), 8), 9) DB 호출 - colors 테이블 가져옴(컬러 옵션이 3개이므로 DB 3번 호출)

prefetch_related()를 썼는데도 이전과 DB 호출 횟수는 차이가 크지 않다.
그렇다면 prefetch_related()는 쓰나마나인 것 아닌가 할 수도 있겠지만 그렇지 않다.

일단 이 코드에서 호출 횟수가 차이나지 않는 것은 products에 들어있는 오브젝트가 하나밖에 없기 때문이다.
만약 products에 10개의 오브젝트가 들어있는데
prefetch_related()를 쓰지 않으면 DB를 81번 호출하지만
위와 같이 prefetch_related()를 쓴다면 호출 횟수가 44회로 줄어든다.

또 prefetch_related()는 테이블을 join해서 가져오지 않기 때문에
DB 호출 횟수가 늘어나는 것 처럼 보일 수 있지만,
각 테이블을 가져와서 장고에서 join하기 때문에 쓰지 않았을 때보다 성능이 낫다.

4. prefetch_related() 속 테이블이 참조하는 테이블도 캐싱하기

위의 코드는 여기서 더 최적화할 수 있다.
product_options 테이블이 정참조하는 sizes 테이블과 colors 테이블까지 캐싱해보자.

첫번째 방법 - prefetch_related()의 인자에 바로 참조하는 테이블 넣기

productoption_set__color, product_set__size로 colors 테이블과 sizes 테이블을 캐싱할 수 있다.

products = Product.objects.filter(id=1)\
.select_related('brand')\
.select_related('second_category__first_category')\
.prefetch_related('thumbnailimage_set')\
.prefetch_related('additional_product')\
.prefetch_related('productoption_set__color', 'productoption_set__size')

위의 코드는 우선 brands, second_categories, first_categories를 inner join해서 가져온 뒤 캐싱한다.
그 다음 thumbnail_images를 가져와서 캐싱하고, product_options를 가져와서 캐싱하고, linked_products를 가져와서 캐싱한다.
그 다음 linked_products가 참조하는 colors를 가져와서 캐싱하고, linked_products가 참조하는 sizes를 가져와서 캐싱한다.

SELECT `products`.`id`, `products`.`created_at`, `products`.`updated_at`, `products`.`title`, `products`.`main_image`, `products`.`price`, `products`.`brand_id`, `products`.`second_category_id`, `brands`.`id`, `brands`.`name`, `second_categories`.`id`, `second_categories`.`name`, `second_categories`.`first_category_id`, `first_categories`.`id`, `first_categories`.`name` 
FROM `products` 
INNER JOIN `brands` ON (`products`.`brand_id` = `brands`.`id`) 
INNER JOIN `second_categories` ON (`products`.`second_category_id` = `second_categories`.`id`) 
INNER JOIN `first_categories` ON (`second_categories`.`first_category_id` = `first_categories`.`id`) WHERE `products`.`id` = 1;

SELECT `thumbnail_images`.`id`, `thumbnail_images`.`url`, `thumbnail_images`.`product_id` 
FROM `thumbnail_images` WHERE `thumbnail_images`.`product_id` IN (1);

SELECT `additional_products`.`id`, `additional_products`.`product_id`, `additional_products`.`additional_product_id` 
FROM `additional_products` WHERE `additional_products`.`additional_product_id` IN (1);

SELECT `product_options`.`id`, `product_options`.`additional_price`, `product_options`.`stock`, `product_options`.`product_id`, `product_options`.`size_id`, `product_options`.`color_id` 
FROM `product_options` WHERE `product_options`.`product_id` IN (1);

SELECT `colors`.`id`, `colors`.`name` 
FROM `colors` WHERE `colors`.`id` IN (1, 2, 3);

SELECT `sizes`.`id`, `sizes`.`name` 
FROM `sizes` WHERE `sizes`.`id` IN (1, 2, 3);

이제 위의 코드는 products 안에 들어있는 오브젝트가 100개라도, 각 오브젝트의 옵션 개수가 100개라도
DB는 항상 6번만 호출한다.

for product in products:
# 1) DB 호출 - products, brands, second_categories, first_categories 테이블 inner join해서 캐싱
# 2) DB 호출 - thumbnail_images 테이블 가져와서 캐싱
# 3) DB 호출 - product_options 테이블 가져와서 캐싱
# 4) DB 호출 - additional_products 테이블 가져와서 캐싱
# 5) DB 호출 - sizes 테이블 가져와서 캐싱
# 6) DB 호출 - colors 테이블 가져와서 캐싱

    print(product.id)
    print(product.second_category.name)
    print(product.second_category.first_category.name)
    print(product.brand.name)
    print(product.thumbnailimage_set.all())
    print(product.additional_product.all())

    for option in product.productoption_set.all():
        print(option.size.name)
        print(option.color.name)

 

두번째 방법 - Prefetch 오브젝트 사용하기

다음화에서...

5.  api에 적용하기

lass ProductDetailView(View):
    def get(self, request, product_id):
        products = Product.objects.filter(id=1)\
        .select_related('brand')\
        .select_related('second_category__first_category')\
        .prefetch_related('thumbnailimage_set')\
        .prefetch_related('additional_product')\
        .prefetch_related('productoption_set__color', 'productoption_set__size')

        result=[{
            'first_category': [{
                'first_category_id'  : product.second_category.first_category.id,
                'first_category_name': product.second_category.first_category.name
                }],
            'second_category' : [{
                'second_category_id'  : product.second_category.id,
                'second_category_name': product.second_category.name
                }],
            'product_id'         : product.id,
            'brand'              : product.brand.name,
            'title'              : product.title,
            'price'              : product.price,
            'thumbnail_images'   : [thumbnailimage.url for thumbnailimage in product.thumbnailimage_set.all()],
            'additional_products': [additional_product.title for additional_product in product.additional_product.all()],
            'product_options'    : [{
                'size_option'     : productoption.size.name,
                'color_option'    : productoption.color.name,
                'additional_price': productoption.additional_price
                } for productoption in product.productoption_set.all()]
            }for product in products]

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