오늘도 개발
[2차 프로젝트 내일의 집] 상품 상세 api - Django ORM 최적화하기 심화 본문
상품 상세를 담당하지는 않았지만 포스트 목록보다 더 복잡한 모델은 어떻게 최적화하면 좋을까 궁금해서 혼자 작업해보았다.
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)'TIL & 프로젝트 회고' 카테고리의 다른 글
| [2차 프로젝트 내일의 집] AWS s3로 글쓰기 api 구현하기 - 2. Django 세팅(boto3)과 코드 작성 (0) | 2022.08.08 |
|---|---|
| [2차 프로젝트 내일의 집] AWS s3로 글쓰기 api 구현하기 - 1. AWS 세팅(IAM 설정, S3 설정) (0) | 2022.08.08 |
| [2차 프로젝트 내일의 집] 포스트 목록 api - Django ORM 최적화하기 (0) | 2022.08.07 |
| [2차 프로젝트 내일의 집] SNS 로그인/회원가입 api에 Unit Test 해보기 (0) | 2022.08.05 |
| [2차 프로젝트 내일의 집] get_or_create 사용하기 (0) | 2022.08.03 |