[DB]N+1문제

Jay·2022년 12월 27일
0

Django ORM

Django에서는 Django ORM을 통해서 DB에 CRUD와 같은 동작들을 실행시킬 수 있다. 이러한 동작을 실행시키기 위해 DB에 쿼리를 동작시켜 데이터를 조회하거나 조작하게 된다.
데이터를 DB에서 조회하기 위해서는 데이터를 조회하는 쿼리 DB에서 실행시켜 데이터를 가져와야 한다. 데이터를 가져오는 방식에는 Lazy Loading, Eager Loading 두가지가 존재한다.

Lazy Loading

데이터가 사용되거나 조작되는 시점에 쿼리를 실행시켜 데이터를 DB로부터 가져오는 방식이다.

user_queryset = User.objects.all()

위의 코드는 User모델의 모든 데이터들을 가져오는 코드가 아니다. 데이터들을 가져오기 위해 SQL로 매핑된 query들을 반환하는 명령어이다. 실질적으로 해당 query가 실행되는 시점은 데이터가 사용/조작되는 시점에 해당 쿼리를 DB에서 실행시켜 데이터를 가져오는 것이다.

user_first = user_queryset.first()
# 첫번째 유저를 사용하려는 순간에 query를 실행시켜 DB로부터 데이터를 가져왔다.
(0.000) SELECT `users_user`.`id`, `users_user`.`password`, `users_user`.`last_lo<User: 40 user17@user.com>, <User: 41 user18@user.com>, <User: 43 user20@user.com>, '...(remaining elements truncatgin`, `users_user`.`email`, `users_user`.`username`, `users_user`.`is_staff`, `users_user`.`created_at`, `users_user`.`deleted_at` FROM `users_user` WHERE `users_user`.`deleted_at` IS NULL ORDER BY `users_user`.`id` ASC LIMIT 1; args=(); algin`, `users_user`.`email`, `users_user`.`username`, `users_user`.`is_staff`, `users_user`.`created_at`, `users_useias=default  

이와 같이 데이터를 미리 가져오지 않고 사용하는 시점에 DB로부터 데이터를 가져오는 방식을 Lazy Loading 방식이라고 한다. Lazy Loading 방식을 사용하면 아래와 같은 경우에 query를 실행시킨다고 한다.

  • Iteration : 반복문에 queryset을 넣어 사용하는 경우, 해당 queryset에 해당하는 데이터들을 query를 통해 가져온다.
for user in User.objects.all():
	print(user.email)
    
(0.000) SELECT `users_user`.`id`, `users_user`.`password`, `users_user`.`last_login`, `users_user`.`email`, `users_user`.`username`, `users_user`.`is_staff`, `users_user`.`created_at`, `users_user`.`deleted_at` FROM `users_user` WHERE `users_user`.`deleted_at` IS NULL; args=(); alias=default
  • Slicing : queryset은 인덱스를 사용하여 slicing이 가능하다. index를 사용하여 slicing하는 경우 query를 통해 해당하는 데이터들을 가져온다.
users = User.objects.all() # 쿼리 실행 X
user_first = users[0]  # 쿼리 실행
(0.000) SELECT `users_user`.`id`, `users_user`.`password`, `users_user`.`last_login`, `users_user`.`email`, `users_user`.`username`, `users_user`.`is_staff`, `users_user`.`created_at`, `users_user`.`deleted_at` FROM `users_user` WHERE `users_user`.`deleted_at` IS NULL LIMIT 1; args=(); alias=default
  • Pickling/Caching
  • repr() : queryset에 repr메소드를 사용하여 문자열로 출력할 때 해당하는 쿼리가 실행된다.
users = User.objects.all() # 쿼리 실행 X
print(users) # 쿼리 실행 
(0.000) SELECT `users_user`.`id`, `users_user`.`password`, `users_user`.`last_login`, `users_user`.`email`, `users_user`.`username`, `users_user`.`is_staff`, `users_user`.`created_at`, `users_user`.`deleted_at` FROM `users_user` WHERE `users_user`.`deleted_at` IS NULL LIMIT 21; args=(); alias=default
  • len() : queryset의 길이를 구하는 len() 함수를 사용할 때 query 실행된다.
len(users)
(0.000) SELECT `users_user`.`id`, `users_user`.`password`, `users_user`.`last_login`, `users_user`.`email`, `users_user`.`username`, `users_user`.`is_staff`, `users_user`.`created_at`, `users_user`.`deleted_at` FROM `users_user` WHERE `users_user`.`deleted_at` IS NULL; args=(); alias=default
  • list() : queryset을 리스트로 변환할 시 query가 실행된다.
user_list = list(User.objects.all())
(0.000) SELECT `users_user`.`id`, `users_user`.`password`, `users_user`.`last_login`, `users_user`.`email`, `users_user`.`username`, `users_user`.`is_staff`, `users_user`.`created_at`, `users_user`.`deleted_at` FROM `users_user` WHERE `users_user`.`deleted_at` IS NULL; args=(); alias=default


Eager Loading

Eager Loading 방식은 사용되는 앞으로 사용될 데이터들을 미리 가져오는 방식이다. Lazy Loading 방식은 데이터가 사용되는 시점에 필요한 만큼의 데이터만 가져왔기 때문에 query의 발생 횟수가 많고 반복적이다. Eager Loading은 하나의 쿼리로 미리 여러개의 데이터를 가져올 수 있기 때문에 query의 실행 횟수를 줄일 수 있다. 데이터를 가져오는 방식은 selected_related와 prefetch_related 방식이 존재한다.

select_related(*fields)

ForeignKey로 관계된 데이터를 join을 통해 즉시 가져오는 방법이다. 1:1관계에서 역참조하는 single object나, 1:N 관계에서 정참조하는 데이터를 가져올 수 있다. 처음 쿼리를 실행할 때 추가적인 연관데이터도 선택하여 가져온다.

# Hits the database.
e = Entry.objects.get(id=5)

# Hits the database again to get the related Blog object.
b = e.blog

위와 같은 경우, Entry 객체가 정참조하고 있는 blog 객체를 사용하기 위해 추가적으로 query를 실행시켜야 했다.

# Hits the database.
e = Entry.objects.select_related('blog').get(id=5)

# Doesn't hit the database, because e.blog has been prepopulated
# in the previous query.
b = e.blog

하지만 selected_related를 사용해서 사용하여 사용할 blog 데이터를 미리 함께 가져와서 query의 실행 횟수를 줄일 수 있다.

prefetch_related(*lookups)

추가적인 쿼리를 통해 관계된 데이터를 가져오는 방법이다. prefetch_related는 join을 사용하지 않고 데이터의 관계를 바탕으로 DB를 조회하여 데이터를 가져온다. M:N관계와 같이 multiple objects를 정참조하는 관계나, 1:N 관계의 1과 같이 역참조하는 관계에서 사용이 가능하다.

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)

Topping과 Pizza는 M:N관계이다. 여러개의 객체를 정참조하는 관계에서 prefetch_related를 사용할 수 있다.

Pizza.objects.all().prefetch_related('toppings')



N+1 문제

Django ORM은 기본적으로 Lazy Loading 방식을 사용한다. N개의 데이터가 각각 참조하는 데이터를 사용하는 경우, 추가적으로 N개의 쿼리를 추가적으로 실행하여 1개의 Main query와 N개의 참조 데이터를 가져오는 query를 실행하여 총 N+1개의 query를 실행하게 된다. 이런 경우 많은 데이터의 개수가 많을 수록 query의 실행 횟수도 증가하여 서능에 커다란 영향을 미치게 된다.

Eager Loading 적용

Eager Loading 방식을 사용하여 참조 데이터를 가져오기 위한 추가 쿼리의 실행을 방지할 수 있다.

class User(models.Model):
	username = models.CharField(max_length=50, unique=True, null=False)
    ...

class Game(models.Model):
    host = models.ForeignKey(User, on_delete=models.PROTECT)
    ...

위와 같이 Game의 host필드가 User 모델을 정참조하고 있다.

game_queryset = Game.all_objects.all()

for g in game_queryset:
	print(g.host)
(0.000) SELECT `games_game`.`id`, `games_game`.`host_id`, `games_game`.`invitation`, `games_game`.`min_invitation`, `games_game`.`player`, `games_game`.`start_datetime`, `games_game`.`end_datetime`, `games_game`.`address`, `games_game`.`fee`, `games_game`.`info` FROM `games_game` ORDER BY `games_game`.`start_datetime` DESC; args=(); alias=default
(0.000) SELECT `users_user`.`id`, `users_user`.`password`, `users_user`.`last_login`, `users_user`.`email`, `users_user`.`username`, `users_user`.`is_staff`, `users_user`.`created_at`, `users_user`.`deleted_at` FROM `users_user` WHERE `users_user`.`id` = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT `users_user`.`id`, `users_user`.`password`, `users_user`.`last_login`, `users_user`.`email`, `users_user`.`username`, `users_user`.`is_staff`, `users_user`.`created_at`, `users_user`.`deleted_at` FROM `users_user` WHERE `users_user`.`id` = 40 LIMIT 21; args=(40,); alias=default
(0.000) SELECT `users_user`.`id`, `users_user`.`password`, `users_user`.`last_login`, `users_user`.`email`, `users_user`.`username`, `users_user`.`is_staff`, `users_user`.`created_at`, `users_user`.`deleted_at` FROM `users_user` WHERE `users_user`.`id` = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT `users_user`.`id`, `users_user`.`password`, `users_user`.`last_login`, `users_user`.`email`, `users_user`.`username`, `users_user`.`is_staff`, `users_user`.`created_at`, `users_user`.`deleted_at` FROM `users_user` WHERE `users_user`.`id` = 1 LIMIT 21; args=(1,); alias=default
...

game 객체에서 정참조하고 있는 host 객체에 접근하기 위해서는 각각의 host를 가져오는 query를 추가적으로 실행하여 host(user) 데이터를 가져와야 한다. 따라서 총 N+1개의 쿼리가 실행되었다.

game_queryset = Game.all_objects.select_related('host').all() 
for g in game_queryset:
	print(g.host)

(0.000) SELECT `games_game`.`id`, `games_game`.`host_id`, `games_game`.`invitation`, `games_game`.`min_invitation`, `games_game`.`player`, `games_game`.`start_datetime`, `games_game`.`end_datetime`, `games_game`.`address`, `games_game`.`fee`, `games_game`.`info`, `users_user`.`id`, `users_user`.`password`, `users_user`.`last_login`, `users_user`.`email`, `users_user`.`username`, `users_user`.`is_staff`, `users_user`.`created_at`, `users_user`.`deleted_at` FROM `games_game` INNER JOIN `users_user` ON (`games_game`.`host_id` = `users_user`.`id`) ORDER BY `games_game`.`start_datetime` DESC; args=(); alias=default

Game 테이블의 host 필드가 User 테이블을 정참조하고 있기 때문에 select_related를 사용하여 Eager Loading하였다. 위의 방식과는 달리 하나의 쿼리에서 join을 통해 참조된 데이터들을 한번에 가져오기 때문에 쿼리의 개수를 줄일 수 있었다.



reference

https://docs.djangoproject.com/en/3.1/ref/models/querysets/#when-querysets-are-evaluated
https://docs.djangoproject.com/en/4.1/ref/models/querysets/
https://leffept.tistory.com/312

0개의 댓글