Backend Development

[DJango] Queryset Join과 쿼리 수 최적화

yuwal6 2022. 7. 13. 19:16

예전에 백엔드 팀원들에게 공유했던 내용인데 옮겨왔습니다.


이번 주제는 django orm에서 제공하는 join기능인 select_related, prefetch_related 입니다. 

모델 구조

class Post(models.Model):
    title = models.CharField(max_length=200)
    writer = models.CharField(max_length=100)
    content = models.TextField()
    date = models.DateField()

    def __str__(self):
        return f'{self.title}|{self.date}'


class Comment(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE)
    writer = models.CharField(max_length=100)
    content = models.CharField(max_length=200)
    date = models.DateField()

    def __str__(self):
        return f'{self.post.title}|{self.date}'

 

더미 데이터 생성

pip install django-seed
pip install psycopg2-binary -> 저는 의존성 에러가 나서 이것도 설치했습니다.
python manage.py seed {더미를 생성할 app name} –number={더미개수}

 

select_related

정방향 참조시(one to one, many to one)에 사용할 수 있습니다. SQL 레벨에서 join을 수행하기 때문에 두 개의 join 방법 모두 쓸 수 있다면 select_related를 쓰는 게 좋습니다. (하나의 쿼리가 너무 복잡하다면 prefetch_rerlated를 사용하는 게 나은 경우가 있다고합니다.) 위 관계에서는 comment를 주체로 사용할 경우 select_related를 사용할 수 있습니다. 이때 on_delete에 설정된 값에 따라서 cascade인 경우 inner join, set_null인 경우 left outer join이 수행됩니다. orm의 join은 항상 왼쪽이 주체이므로 right join은 없습니다 
참고로 select_related를 명시적으로 사용하지 않더라도 필요하다고 생각되는 경우 알아서 join을 합니다. 예를 들어 외래키가 걸린 테이블의 필드로 필터를 거는 경우 join을 해서 가져옵니다.하지만  이런 경우에도 명시적으로 select_related를 추가해주는 게 좋을 것 같습니다.

 

comment_queryset = Comment.objects.all() 
for comment in comment_queryset:
    print(comment.post.title)
 

위 코드의 쿼리 실행 횟수는 len(comment_queyset) + 1만큼 실행됩니다. comment를 가져올 때 post를 가져오지 않았으므로 post에 접근 할 때 마다 쿼리가 실행됩니다. 이때 select_related를 통해서 미리 데이터를 가져올 수 있습니다.

 

comment_queryset = Comment.objects.select_related('post').all()
for comment in comment_queryset:
    print(comment.post.title)

 

위 코드의 쿼리는 최초 한 번만 실행됩니다. 지금은 post가 바로 연결돼있지만, 연금서비스에서 Account를 통해 PersonalData를 얻기 위해서는
Account -> UserProfile -> AuthUser -> PersonalData 순서를 거쳐야 접근이 가능합니다. 이 때는 아래처럼 사용 가능합니다.
account_queryset.select_related('user_profile__user__personaldata')  # 중간 단계의 테이블도 모두 가져옴

 

prefetch_related

select_related 사용할  없는 경우들에 사용합니다. (역참조, 일대다 관계) N개의 쿼리를 실행한 다음 python 레벨에서 Join 수행합니다. prefetch_related가 범용성이 높아서 select_related 대신 사용할 수도 있지만 더 많은 쿼리가 실행되니 그럴 필요는 없습니다.
 
post_queryset = Post.objects.all()
for post in post_queryset:
    for comment in post.comment_set.all():
        print(comment)
 
위 코드도 len(post_queryset) + 1만큼 실행됩니다. post에 연결된 comment를 반복할 때 마다 접근하기 때문입니다. prefetch_related를 적용하면 아래와 같이 됩니다.
 
post_queryset = Post.objects.prefetch_related('comment_set').all()
for post in post_queryset:
    for comment in post.comment_set.all():
        print(comment)
 
 

Prefetch + to_attr

prefetch_related를 사용할 때는 Prefetch함수와 to_attr을 사용하는 게 좋습니다. 조금 고쳐볼게요.
 
post_queryset = Post.objects.prefetch_related(Prefetch('comment_set', to_attr='comments')).all()
for post in post_queryset:
    for comment in post.comments:
        print(comment)
 
위와 같이 사용할 경우 to_attr에 지정된 이름으로 캐싱된 데이터에 접근할 수 있습니다. 이때 post.comments의 타입은 related manager가 아닌 list가 됩니다. (워후) to_attr을 사용하지 않았을 때는 어떤 이유로 캐싱에 실패했어도 문제가 생기지 않습니다. 쿼리 실행 횟수를 봐야지만 문제를 확인할 수 있습니다. 하지만 to_attr을 사용한 경우 캐싱에 실패하면 해당 필드가 생성되지 않기 때문에 접근에 실패하고 바로 문제를 알 수 있습니다.
 

Prefetch + queryset + to_attr

좀 더 응용해서 Prefetch에 조건을 부여할 수 있습니다. 예를 들어서 2000년 이후로 생성된 댓글만 가져오고 싶은 경우 아래처럼 처리가 가능합니다.
 
post = Post.objects.prefetch_related(
    Prefetch('comment_set', queryset=Comment.objects.filter(date__year__gte='2000'), to_attr='comments')
).get(id=1)

print(post.comments)
 
output: [<Comment: Star for raise though land.|2009-05-08>, <Comment: Star for raise though land.|2016-10-21>]