Table of contents
들어가기전
이 챕터에서는 장고의 ‘N + 1 Problem’ 에 대해서 다룰 예정입니다.
N+1 Problem란?
N+1 Problem은 쿼리 1번으로 N건의 데이터를 가져왔는데 원하는 데이터를 얻기 위해 이 N건의 데이터를 데이터 수 만큼 반복해서 2차적으로 쿼리를 수행하는 문제다.
이 문제의 해결방식으로 Eager-Loading방식이 있다.
Eager-Loading방식은 사전에 쓸 데이터를 포함하여 쿼리를 날리기 때문에 비효율적으로 늘어나는 쿼리 요청을 방지할 수 있다.
Django ORM에서 Eager-Loading을 하는 방법으로는 두가지가있다.
select_related : foreign-key , one-to-one 처럼 single-valued relationships에서만 사용이 가능하다. SQL의 JOIN을 사용하는 방법이다.
prefetch_related : foreign-key , one-to-one 뿐만 아니라 many-to-many , many-to-one 등 모든 relationships에서 사용 가능하다. SQL의 WHERE … IN 구문을 사용하는 방법이다.
모델 구성
class User(AbstractBaseUser):
id = models.UUIDField(default=uuid.uuid4, editable=False, auto_created=True, unique=True, primary_key=True)
email = models.EmailField(_('email address'), unique=True)
class Profile(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, primary_key=True)
name = models.CharField(_('name'), max_length=30, blank=True, help_text='이름')
nickname = models.CharField(max_length=30, null=True, blank=True, help_text='별명')
phone = models.CharField(max_length=20, null=True, blank=True, help_text='휴대폰 번호')
N + 1 Problem
- 같은 모델안에서의 N+1 문제
# Post를 선언하는 시점에는 SQL이 호출되지 않음
post_query = Post.objects.all()
# Post query 연산 후 리스트를 변수에 담아서 캐싱으로 사용한다.
post_list = list(post_query)
# 이후 캐싱한 데이터를 통해서 데이터를 재가공하여도 SQL문이 실행되지 않는다.
EX)
from django.forms.models import model_to_dict
post_dict_list = [model_to_dict(post, fields=('id', 'email', 'context', 'created_at')) for post in post_list)]
- 1:N 관계에서 N+1 문제
# Query Test
for i in Profile.objects.all():
print(i.user.id)
# 결과
[DEBUG 2023-02-27 19:14:06,262 pid: 11] (0.010) SELECT `users_user_profile`.`user_id`, `users_user_profile`.`avatar_id`, `users_user_profile`.`name`, `users_user_profile`.`nickname`, `users_user_profile`.`phone`, `users_user_profile`.`mobile_carrier`, `users_user_profile`.`address`, `users_user_profile`.`birth_date`, `users_user_profile`.`gender_code`, `users_user_profile`.`ci`, `users_user_profile`.`ci_hash` FROM `users_user_profile`; args=()
[DEBUG 2023-02-27 19:14:06,416 pid: 11] (0.009) SELECT `users_user`.`id`, `users_user`.`password`, `users_user`.`last_login`, `users_user`.`is_superuser`, `users_user`.`email`, `users_user`.`is_staff`, `users_user`.`is_active`, `users_user`.`date_joined`, `users_user`.`is_online`, `users_user`.`last_password_change`, `users_user`.`site_id`, `users_user`.`deactivated_at` FROM `users_user` WHERE `users_user`.`id` = '14acddec4483493dbcdfdd5ef6680104' LIMIT 21; args=('14acddec4483493dbcdfdd5ef6680104',)
[DEBUG 2023-02-27 19:14:06,427 pid: 11] (0.006) SELECT `users_user`.`id`, `users_user`.`password`, `users_user`.`last_login`, `users_user`.`is_superuser`, `users_user`.`email`, `users_user`.`is_staff`, `users_user`.`is_active`, `users_user`.`date_joined`, `users_user`.`is_online`, `users_user`.`last_password_change`, `users_user`.`site_id`, `users_user`.`deactivated_at` FROM `users_user` WHERE `users_user`.`id` = '6effdc5b860e439ababe3392c240eecc' LIMIT 21; args=('6effdc5b860e439ababe3392c240eecc',)
[DEBUG 2023-02-27 19:14:06,439 pid: 11] (0.007) SELECT `users_user`.`id`, `users_user`.`password`, `users_user`.`last_login`, `users_user`.`is_superuser`, `users_user`.`email`, `users_user`.`is_staff`, `users_user`.`is_active`, `users_user`.`date_joined`, `users_user`.`is_online`, `users_user`.`last_password_change`, `users_user`.`site_id`, `users_user`.`deactivated_at` FROM `users_user` WHERE `users_user`.`id` = '7ef2f90301da48d09d32f54d9b7c3194' LIMIT 21; args=('7ef2f90301da48d09d32f54d9b7c3194',)
[DEBUG 2023-02-27 19:14:06,450 pid: 11] (0.006) SELECT `users_user`.`id`, `users_user`.`password`, `users_user`.`last_login`, `users_user`.`is_superuser`, `users_user`.`email`, `users_user`.`is_staff`, `users_user`.`is_active`, `users_user`.`date_joined`, `users_user`.`is_online`, `users_user`.`last_password_change`, `users_user`.`site_id`, `users_user`.`deactivated_at` FROM `users_user` WHERE `users_user`.`id` = 'a9e45326bc0e42b98cfe20e250dd7f2a' LIMIT 21; args=('a9e45326bc0e42b98cfe20e250dd7f2a',)
[DEBUG 2023-02-27 19:14:06,462 pid: 11] (0.006) SELECT `users_user`.`id`, `users_user`.`password`, `users_user`.`last_login`, `users_user`.`is_superuser`, `users_user`.`email`, `users_user`.`is_staff`, `users_user`.`is_active`, `users_user`.`date_joined`, `users_user`.`is_online`, `users_user`.`last_password_change`, `users_user`.`site_id`, `users_user`.`deactivated_at` FROM `users_user` WHERE `users_user`.`id` = 'aec0ee5153d64ed9bf232999d78b05df' LIMIT 21; args=('aec0ee5153d64ed9bf232999d78b05df',)
[DEBUG 2023-02-27 19:14:06,473 pid: 11] (0.007) SELECT `users_user`.`id`, `users_user`.`password`, `users_user`.`last_login`, `users_user`.`is_superuser`, `users_user`.`email`, `users_user`.`is_staff`, `users_user`.`is_active`, `users_user`.`date_joined`, `users_user`.`is_online`, `users_user`.`last_password_change`, `users_user`.`site_id`, `users_user`.`deactivated_at` FROM `users_user` WHERE `users_user`.`id` = 'b4c68794da02403aadf7002b0559242e' LIMIT 21; args=('b4c68794da02403aadf7002b0559242e',)
[DEBUG 2023-02-27 19:14:06,486 pid: 11] (0.007) SELECT `users_user`.`id`, `users_user`.`password`, `users_user`.`last_login`, `users_user`.`is_superuser`, `users_user`.`email`, `users_user`.`is_staff`, `users_user`.`is_active`, `users_user`.`date_joined`, `users_user`.`is_online`, `users_user`.`last_password_change`, `users_user`.`site_id`, `users_user`.`deactivated_at` FROM `users_user` WHERE `users_user`.`id` = 'b9e38eb5676c46e3b11b74928e081b9d' LIMIT 21; args=('b9e38eb5676c46e3b11b74928e081b9d',)
[DEBUG 2023-02-27 19:14:06,498 pid: 11] (0.006) SELECT `users_user`.`id`, `users_user`.`password`, `users_user`.`last_login`, `users_user`.`is_superuser`, `users_user`.`email`, `users_user`.`is_staff`, `users_user`.`is_active`, `users_user`.`date_joined`, `users_user`.`is_online`, `users_user`.`last_password_change`, `users_user`.`site_id`, `users_user`.`deactivated_at` FROM `users_user` WHERE `users_user`.`id` = 'edf713138edf4007b4fa9d002d8c9053' LIMIT 21; args=('edf713138edf4007b4fa9d002d8c9053',)
# 문제점
Profile 반복하며 user의 id을 출력해 보겠습니다.
처음에 Profile 테이블을 조회하고, 이어서 8번 User 테이블을 조회하는 쿼리 로그를 볼 수 있습니다.
여기서 문제는 Profile와 User 테이블을 한 번 조인하는 쿼리만 날리면 됐는데,
Profile 개수만큼 쿼리를 더 날린 것 입니다.
# select_related()로 해결
for i in Profile.objects.all().select_related('user'):
print(i.user.id)
# 결과
[DEBUG 2023-02-27 19:16:10,113 pid: 11] (0.028) SELECT `users_user_profile`.`user_id`, `users_user_profile`.`avatar_id`, `users_user_profile`.`name`, `users_user_profile`.`nickname`, `users_user_profile`.`phone`, `users_user_profile`.`mobile_carrier`, `users_user_profile`.`address`, `users_user_profile`.`birth_date`, `users_user_profile`.`gender_code`, `users_user_profile`.`ci`, `users_user_profile`.`ci_hash`, `users_user`.`id`, `users_user`.`password`, `users_user`.`last_login`, `users_user`.`is_superuser`, `users_user`.`email`, `users_user`.`is_staff`, `users_user`.`is_active`, `users_user`.`date_joined`, `users_user`.`is_online`, `users_user`.`last_password_change`, `users_user`.`site_id`, `users_user`.`deactivated_at` FROM `users_user_profile` INNER JOIN `users_user` ON (`users_user_profile`.`user_id` = `users_user`.`id`); args=()
# 해결
select_related()는 주로 정방향 참조에서 사용합니다.
즉, 1:1 관계나 1:N 관계 중 N(Foreign Key를 정의하는 쪽)이 사용합니다.
위 코드에서 select_related()만 추가하여 실행해보겠습니다.
아래 결과를 보면 Profile 테이블과 User 테이블을 조인하는 쿼리 1개만 날렸습니다.
즉, select_related()를 활용하면 Lazy_Loading이 아니라 Eager_Loading을 통해 미리 1번만 쿼리를 날려서 N+1 문제를 해결이 가능합니다.
- N:M 관계에서 N+1 문제
# Query Test
for post in Post.objects.all():
print(post.title, [tag.name for tag in post.tag_set.all()])
# 문제
Post와 Tag는 ManytoMany의 관계입니다.
먼저 Post 테이블을 조회하고 -> tag 테이블과 tag_set 테이블을 INNER JOIN 하는 쿼리를 날리게 됩니다
# prefetch_related()로 해결
for post in Post.objects.all().prefetch_related("tag_set"):
print(post.title, [tag.name for tag in post.tag_set.all()])
# 설명
prefetch_related()는 주로 역방향 참조에서 사용합니다.
즉, M:N 관계나 1:N 관계 중 1(Foreign Key를 정의하지 않은 쪽)이 사용합니다.
위 코드에서 prefetch_related()를 추가해보겠습니다.
결과를 보면, post 테이블을 조회하고, tag와 tag_set 테이블을 조인하는 2번의 쿼리만 실행됨을 볼 수 있습니다.
역시 Lazy_Loading이 아니라 Eager_Loading을 통해 미리 2번만 쿼리를 날려서 N+1 문제를 해결한 것입니다.