[Django] N+1 쿼리 문제

요즘것들

요즘것들

2023년 08월 27일

프로젝트를 진행하면서 시간에 쫓겨 일단 구현만 했더니
django-debug-toolbar를 통해 sql을 분석해 보니 filter, all 등으로 가져온 거의 모든 쿼리셋을 N+1번 DB 히트를 발생시켰습니다. 리팩토링을 통해 쿼리 성능 개선을 하던 과정에서 공부하고 적용했던 것들을 복습하고자 포스팅합니다.

N+1 문제는 대부분 상황이 비슷하겠지만 각 프로젝트 마다의 모델링에 따라 해결법이 다를 수 있습니다. 따라서 제 포스팅이 솔루션이 되지 않을 수 있음을 미리 알려드립니다.

Lazy-Loading

django는 기본적으로 ORM이 Lazy-Loading으로 동작합니다.
Lazy-Loading은 all(), filter() 등 쿼리셋을 가져오는 메서드가 호출될 때 실제 DB를 히트하는 것이 아닌, 실제 쿼리셋이 필요한 시점에 DB에서 가져온다는 의미입니다.

examples = Example.objects.all()

예를 들어 위 코드처럼 단순하게 쿼리셋을 변수에 넣기만 한다면 DB히트는 일어나지 않습니다.

하지만 해당 변수를 출력하거나 serializer에 넣는 등 실제 DB의 데이터가 필요한 시점이 되어서야 DB히트가 발생합니다.

examples = Example.objects.all()
for example in examples:
	print(example.field1)
    ...

따라서 Row의 수가 많아진다면 히트수가 N만큼 증가할 겁니다.

Eager-Loading

eager-loading은 필요한 데이터를 사전에 가져와 lazy-loading에서 발생했던 row마다 쿼리를 날리는 비효율적인 DB 히트를 감소시켜줍니다. 아래의 메서드들은 eager-loading에서 사용되는 메서드들 입니다.

select_related

select_related는 ForeignKey, OneToOne처럼 정참조가 가능한 경우에 사용합니다.
예를 들어 다음과 같은 모델 관계를 가정해 보겠습니다.

from django.contrib.auth.models import AbstractUser

class Notice(models.Model):
	title = models.CharField(max_length=100)
    content = models.TextField()
    writer = models.ForeignKey("User", on_delete=models.CASCADE)
    
class User(AbstractUser):
	nickname = models.CharField(max_length=10)
    avatar = models.ImageField()

Notice 모델에서 User는 writer라는 ForeignKey 필드를 가지고 있기 때문에 정참조 관계입니다. 만약 select_related를 사용하지 않고 Notice모델의 객체 하나만 가져온다면 실제 쿼리는 다음과 같습니다.

notice = Notice.objects.get(pk=1)
print(notice)

# SQL
SELECT notice.id
	notice.title
    notice.content
    notice.writer
    FROM notice

그리고 django-debug-toolbar에서 해당 쿼리로 가져온 데이터를 조회해 본다면

notice.id -> 1
notice.title -> "예시 제목"
notice.content -> "예시 내용"
notice.writer -> 1

이렇게 조회되고 있습니다.

여기서 만약 notice.writer.nickname을 사용한다면?
위의 쿼리 뿐만 아니라 User모델에 대한 쿼리도 발생하게 되고, 해당 쿼리를 통해 writer의 nickname을 가져오게 됩니다.

notice = Notice.objects.get(pk=1)
print(notice.writer.nickname)

# SQL
SELECT notice.id
	notice.title
    notice.content
    notice.writer
    FROM notice
    
SELECT user.id
	user.nickname
    user.avatar
    ... # 기타 AbstractUser의 필드들
    FROM user
    WHERE user.id = 1

이러한 문제를 해결하기 위해 정참조 관계에 있는 모델에 대한 데이터를 미리 가져오게 하는 메서드가 select_related입니다.

notice = Notice.objects.get(pk=1).select_related("writer")
print(notice.writer.nickname)


# SQL
SELECT notice.id
	notice.title
    notice.content
    notice.writer
    user.id
    user.nickname
    user.avatar
    FROM notice
    INNER JOIN user
    	ON notice.writer = user.id

이처럼 필요한 정참조 관계의 필드의 테이블의 데이터를 INNER JOIN을 통해 미리 가져와 불필요한 DB 히트를 줄일 수 있게 됩니다.
예시에서 get메서드를 통해 인스턴스 하나로만 했지만, 실무에선 filter, all 등 많은 데이터를 가져와야 하는데 정참조 관계의 데이터를 serializer나 조회 등이 발생할 때마다 쿼리를 날린다면 DB히트로 인해 상황에 따라 엄청난 속도저하가 발생할 겁니다.

prefetch_related

prefetch_related는 정참조 뿐만 아니라 역참조(reverse relationship)에서도 동작합니다. select_related와의 차이점은 select_related는 JOIN 방식을 사용하는 것이고, prefetch_related는 WHERE ... in 을 사용하는 방식입니다.

또한 prefetch_related에 적힌 필드에 대해 개별 쿼리를 날린 후 python-level에서 데이터 조합을 하는 것이 특징입니다.

따라서 위에서 설명했던 모델로는 prefetch_related에 대한 자세한 설명이 어려워 모델 하나만 더 추가하겠습니다.

class NoticeComment(models.Model):
	notice = models.ForeignKey("Notice", related_name="comments, on_delete=models.CASCADE)
    writer = models.ForeignKey("User", on_delete=models.CASCADE)
    content = models.TextField()

NoticeComment모델은 Notice에 달리는 댓글을 의미하고, Notice 모델의 관점에선 1:N 관계에 있습니다. 이 상황에서 다음과 같은 코드를 가정해 보겠습니다.

notices = models.Notice.objects.prefetch_related("comments")

for notice in notices:
	print(notice.comments.all())

NoticeComment모델에서 Notice를 역참조할 때 'comments'로 하겠다고 미리 선언했기에 prefetch_related에 comments를 인자로 전달한 후 쿼리를 보게 되면 다음과 같습니다.

SELECT "mymodels_notice"."id",
       "mymodels_notice"."title",
       "mymodels_notice"."writer_id",
       "mymodels_notice"."content"
  FROM "mymodels_notice"
  
SELECT "mymodels_noticecomment"."id",
       "mymodels_noticecomment"."notice_id",
       "mymodels_noticecomment"."writer_id",
       "mymodels_noticecomment"."content"
  FROM "mymodels_noticecomment"
 WHERE "mymodels_noticecomment"."notice_id" IN (,,,,,,,,,)

만약 prefetch_related를 사용하지 않은 상태에서 notice.comments.all()을 한다면 notice 의 수만큼 쿼리가 증가하지만, prefetch_related 덕분에 쿼리가 더이상 증가하지 않습니다. 하지만 유의할 점은 prefetch_related가 where .. in .. 을 사용하기 때문에 상황에 따라 속도 저하가 발생할 수 있으니 각자의 상황에 맞게 사용해야 합니다.

출처
https://stackoverflow.com/questions/31237042/whats-the-difference-between-select-related-and-prefetch-related-in-django-orm
https://docs.djangoproject.com/en/4.1/ref/models/querysets/

개발

2023년 08월 29일

짝짝 하나 드립니다. 👏

우디

우디

2023년 08월 29일

멋진 글 감사합니다.