Dev/Django

Django - QuerySet 활용하기(select_related & prefetch_related)

sincerely10 2020. 7. 26. 20:09
반응형

안녕하세요. 이번 포스트에서는 Django QuerySet 활용에 대해서 학습해보겠습니다.
본 목차 전에 QuerySet에 대해 간단하게 언급하겠습니다.

Django QuerySet은 제 블로그의 Django 이해하기 Part1. MTV Pattern의 하단에서도 언급하였습니다. BackEnd에서 FrontEnd로부터 받은 API의 request에 대해 엔드포인트 목적에 맞는 response 돌려주기 위해 데이터를 컨트롤하는 툴(Tool)이라고 정의할 수 있습니다.

이 QuerySet은 SQL 쿼리문과 100% 상응합니다. 이 쿼리셋을 쿼리로 보는 방법은 하단에서도 설명하겠습니다.

그리고 본 포스트 목적은 제목과 같이 QuerySet중에 selected_related와 prefetch_related를 활용하는 것입니다. 이 전에 제 포스트에서는 {Model_name}.objects.all(), {Model_name}.objects.get(조건), {Model_name}.objects.filter(조건)과 같은 한 가지의 모델에서만 적용할 수 있는 쿼리셋만을 활용하였는데, 이번 select_related, prefetch_related 쿼리셋은 바로 SQL의 join 기능으로 활용할 수 있는 쿼리셋입니다.

1. select_related

데이터 모델링을 통해 나온 스키마 구조에서 ForeignKey를 통한 참조는 필수적일 것입니다. 스타벅스 음료에 대해서 예시를 들어보겠습니다. 작성한 models.py 파일과 함께요.

 1 from django.db import models
 2
 3 class Menu(models.Model):
 4     name        =   models.CharField(max_length=45)
 5
 6     class Meta:
 7         db_table = 'menus'
 8
 9
10 class Category(models.Model):
11     menu        =   models.ForeignKey(Menu,on_delete=models.CASCADE)
12     name        =   models.CharField(max_length=45)
13
14     class Meta:
15         db_table = 'categories'
16
17
18 class Drink(models.Model):
19     category        =   models.ForeignKey(Category,on_delete=models.SET_NULL,null=True)
20     name            =   models.CharField(max_length=45)
21     nutrition       =   models.OneToOneField('Nutrition', on_delete = models.CASCADE)
22     allergy_drink   =   models.ManyToManyField('Allergy', through='AllergyDrink')
23
24     class Meta:
25         db_table = 'drinks'

스타벅스 홈페이지에 접속해 보시면 더 쉽게 이해하실 수 있습니다. Menu는 음료, 베이커리, 텀블러와 같은 대분류입니다. 그리고 Category는 Menu의 id를 참조해서 생성됩니다. 음료에 주스, 콜드브루 커피 등이 있는 형태를 의미합니다.
Drink는 category의 id를 참조한 음료 하나를 의미합니다. 콜드브루, 카페라떼 등이 있듯이 말이죠.

이제 select_related를 활용하기 위해 한 가지 가정을 해보겠습니다. 음료에서 각 음료가 어떤 카테고리에 속하는지 알고 싶다는 상황이 있습니다.

기존의 경우에는 각 Drink(음료)의 category_id를 찾고 그 category_id의 name으로 확인을 했습니다. 아래 코드에서 기존 방법을 확인해보겠습니다.

>>> drink_all = Drink.objects.all()
>>> print(drink_all.query)
SELECT `drinks`.`id`, `drinks`.`category_id`, `drinks`.`name`, `drinks`.`nutrition_id` FROM `drinks`
>>> for drink in drink_all:
...     print(Category.objects.get(id=drink.category_id).name)
콜드 브루
콜드 브루
콜드 브루
콜드 브루
콜드 브루
......(생략)

먼저 Drink class의 모든 객체를 받습니다. 이 쿼리셋은 쿼리의 확인을 .query를 붙여 확인이 가능합니다. 다만, 이미 객체나 변수로 받은 경우는 확인할 수 없습니다.

drinks 테이블 전체를 select 하고 나면 그 대상에 category_id만으로 join 과정을 거칩니다. 그리고 name 값을 받아 오는 것이죠. 쿼리로 표현한다면 아래와 같습니다.

SELECT `drinks`.`id`, `drinks`.`category_id`, `drinks`.`name`, `drinks`.`nutrition_id` FROM `drinks`
SELECT category.name from categories WHERE category_id=drinks.category_id

굳이 표현을 하면 위와 같이 두 번 쿼리가 수행될 것입니다. 이 경우라면 데이터베이스에 Connection을 두 번 맺어 쿼리를 수행하는 형태입니다. 대부분의 경우에는 당연히 시간적으로 더 소요될 수 밖에 없을 것 입니다.

그렇다면 select_related는 정확히 어떤 효과를 가져오는 것일까요?
직접 수행해보겠습니다.

>>> drink_all=Drink.objects.select_related('category').all()
>>> for drink in drink_all:
...     print(drink.category.name)
...
콜드 브루
콜드 브루
콜드 브루
....
>>> print(drink_all.query)
SELECT `drinks`.`id`, `drinks`.`category_id`, `drinks`.`name`, `drinks`.`nutrition_id`, `categories`.`id`, `categories`.`menu_id`, `categories`.`name` FROM `drinks` LEFT OUTER JOIN `categories` ON (`drinks`.`category_id` = `categories`.`id`)

겉으로 보았을 때는 큰 차이가 없어 보입니다.  그러나 아래처럼 쿼리를 확인한 결과 select_related에서 쿼리가 하나로 나오는 것을 볼 수 있습니다. LEFT OUTER JOIN으로 가져올 수 있기 때문입니다.
즉, DB의 hit를 한 번만 하고도 결과 값을 가져올 수 있는 것입니다.

참조하실 점은 Drink class에서 ForeignKey(Category 객체) 삭제 시, Null로 변환하는 설정을 넣었기 때문에 JOIN이 안 되는 것을 방지하고자 LEFT OUTER JOIN으로 자동 수행됩니다.
만약 CASCADE 설정을 걸었으면, INNER JOIN으로 수행됩니다.

이 select_related는 ManyToOne 또는 ManyToMany의 정참조(Drink에서 Category 관련 정보를 찾고자 할 때) JOIN에 유리합니다.

2. prefetch_related

1번에서 select_related를 통한 JOIN을 확인했습니다. DB에 한 번만 접속해서 가져오는 방식이었습니다. 이번에는 prefetch_related입니다. 마찬가지로 상황을 가정해보겠습니다. 스타벅스 음료 카테고리 중 '브루드 커피'인 메뉴를 전부 확인하고자 합니다.

먼저 기존의 방식대로 Category 이름으로 해당 id를 찾고, 그 brewed coffee에 해당하는 id로 filter를 거쳐 brewed coffee의 drink id를 받아옵니다. 그리고 쿼리셋에서 각 커피의 이름을 출력하면 확인이 됩니다.

>>> brewed_coffee_id = Category.objects.get(name="브루드 커피")
>>> brewed_coffe_list = Drink.objects.filter(category_id=brewed_coffee_id)
>>> for coffee in brewed_coffe_list:
...     print(coffee.name)
...
아이스 커피
오늘의 커피

SELECT id FROM categories where name="브루드 커피"
>>> print(brewed_coffe_list.query)
SELECT `drinks`.`id`, `drinks`.`category_id`, `drinks`.`name`, `drinks`.`nutrition_id` FROM `drinks` WHERE `drinks`.`category_id` = 2

SQL 쿼리로 나타내면 위와 같이 크게 두 단계로 볼 수 있습니다. "브루드 커피"에 해당하는 id를 구하고 그 커피를 select 합니다.

마찬가지로 두 번의 쿼리가 실행되는 것을 확인할 수 있습니다. 이번에는 prefetch_related로 확인한 코드와 결과입니다.

>>> prefetch_drink = Category.objects.prefetch_related('drink_set').get(name="브루드 커피")
>>> drink_data = [ {'drink_name' : drink.name } for drink in list(prefetch_drink.drink_set.all())]
>>> drink_data
[{'drink_name': '아이스 커피'}, {'drink_name': '오늘의 커피'}]

SELECT "categories"."id", "categories"."name", "categories"."menu_id" FROM "categories" WHERE "categories"."id" = 2
SELECT "drinks"."id", "drinks"."name", "drinks"."menu_id", "drinks"."category_id", "drinks"."nutrition_id" FROM "drinks" WHERE "drinks"."category_id"

위와 같이 prefetch는 수행 시 두 번의 쿼리를 수행해서 python에서 join을 합니다.
select_related와는 반대로 역참조 관계(카테고리를 통해 음료의 정보를 참조할 때) 유용하게 사용됩니다.

여기까지가 Django QuerySet의 select_related와 prefetch_related 였습니다. 둘의 경우 이럴 때 이렇게 사용하시라고 정확히 말하긴 어려운 것 같습니다. 그 내용을 더 깊게 다루고 싶지만, 아직 완벽히 이해하고 작성하지 못해 서툰 게 느껴지네요. 추후에 성능개선에 관한 내용을 보강하도록 하겠습니다.

반응형