기술정리/Django

Django 테이블 조회 n + 1 문제 해결하기

bingual 2024. 4. 9. 02:57
반응형

n + 1 문제란?

  • 하나의 쿼리로 n개의 객체를 검색한다.
  • 그 후에 이 n개의 객체 각각에 대해 추가적인 쿼리가 실행된다. 이 추가적인 쿼리는 객체에 연결된 관계를 가져오거나 추가 정보를 가져오기 위한 것이다.

관계 필드는 모델을 조회할 때 자동으로 미리 조회되지 않고, 필드에 접근할 때 로딩된다. 이것을 지연 로딩 Lazy Loading이라 하고 반대의 경우에는 즉시 로딩 Eager Loading이라 한다.

 

django 내에서 기본 쿼리 동작은 Lazy Loading으로 수행되며 Eager Loading 동작을 위해서는 select_related, prefetch_related을 사용할 수 있다.

 

django 내에서 n + 1 문제를 발견하기 위해서는 조회 코드를 작성하고 SQL에 대한 디버그를 수행하는 것이다.

섣부른 최적화로 사용하지 않는 관계에 대해서 즉시 로딩을 수행하는 것은 오히려 성능 저하를 가져오기 때문에 n + 1 문제를 발견하였을 때 적용하는 게 정석이다.

 

 

n + 1 문제 예시

models.py

# accounts
class User(AbstractUser):
    class Meta:
        ordering = ["-date_joined"]
    ...


# blog
class TimestampedModel(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True


class Post(TimestampedModel):
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="blog_post_set",
        related_query_name="blog_post",
    )
    title = models.CharField(max_length=100)
    ...

 

 

현재 정의된 모델은 위와 같다. 생략된 필드 부분이 있지만 실습에 사용되는 필드들만 표기했다.

 

 

shell_plus

In [1]: from blog.models import Post

In [2]: post_qs = Post.objects.all()[:3]

In [3]: for post in post_qs:
   ...:     print(post.pk, post.title, post.author)
   ...:
SELECT *
  FROM "blog_post"
 LIMIT 3

Execution time: 0.001017s [Database: default]
SELECT *
  FROM "accounts_user"
 WHERE "accounts_user"."id" = 1
 LIMIT 21

Execution time: 0.000714s [Database: default]
4 양식 건강한 요리 auto-okoQnOpJ
SELECT *
  FROM "accounts_user"
 WHERE "accounts_user"."id" = 5
 LIMIT 21

Execution time: 0.001009s [Database: default]
5 양식 건강한 요리 user1
SELECT *
  FROM "accounts_user"
 WHERE "accounts_user"."id" = 4
 LIMIT 21

Execution time: 0.001025s [Database: default]
6 양식 가족과 함께 qwer

 

  • 포스팅 목록 중 3개의 레코드를 가져와서 외래키로 연결된 User 필드를 조회했다.
  • blog 테이블의 모든 레코드를 조회하였고 이어서 post_qs의 크기만큼 User 필드에 대해 조회를 한다. 즉 n + 1 문제가 발생한다.

 

select_related을 이용한 처리 예시

shell_plus

In [1]: from blog.models import Post

In [2]: post_qs = Post.objects.all()[:3]

In [3]: post_qs = post_qs.select_related("author")

In [4]: for post in post_qs:
   ...:     print(post.pk, post.title, post.author)
   ...:
SELECT *
  FROM "blog_post"
 INNER JOIN "accounts_user"
    ON ("blog_post"."author_id" = "accounts_user"."id")
 LIMIT 3

Execution time: 0.003513s [Database: default]
4 양식 건강한 요리 auto-okoQnOpJ
5 양식 건강한 요리 user1
6 양식 가족과 함께 qwer

 

  • select_related는 외래키 참조 모델에서만 사용 가능하다.
  • select_related를 이용하면 외래키까지 JOIN 쿼리로 한 번에 조회한 뒤에 순회한다.
  • 같은 외래키가 많이 참조된 관계인 경우, 결과 데이터셋에 중복된 데이터가 많이 반복된다.
  • 위에 결과와 같이 n + 1 문제가 해결된 것을 확인할 수 있다.

 

prefetch_related을 이용한 처리 예시

shell_plus

In [1]: from blog.models import Post

In [2]: post_qs = Post.objects.all()[:3]

In [3]: post_qs = post_qs.prefetch_related("author")

In [4]: for post in post_qs:
   ...:     print(post.pk, post.title, post.author)
   ...:
SELECT *
  FROM "blog_post"
 LIMIT 3

Execution time: 0.002034s [Database: default]
SELECT *
  FROM "accounts_user"
 WHERE "accounts_user"."id" IN (1, 4, 5)
 ORDER BY "accounts_user"."date_joined" DESC

Execution time: 0.002254s [Database: default]
4 양식 건강한 요리 auto-okoQnOpJ
5 양식 건강한 요리 user1
6 양식 가족과 함께 qwer

 

  • prefetch_related는 모든 관계에 사용 가능하다.
  • prefetch_related는 모든 포스팅 레코드를 조회하고 이후 추가 쿼리를 통해 유저 레코드를 순회한다.
  • 같은 외래키가 많이 참조된 관계인 경우, prefetch_related에서 전송 데이터양이 작을 수 있다.
  • 위에 결과와 같이 n + 1 문제가 해결된 것을 확인할 수 있다.

 

django_debug_toolbar로 문제 발견 및 처리

views.py

def post_list(request):
    post_qs = Post.objects.all()
    return render(request, "blog/post_list.html", {"post_list": post_qs})

 

 

post_list.html

 

n + 1 문제가 발생한 것을 확인할 수 있다.

 

 

문제 해결

views.py

def post_list(request):
    post_qs = Post.objects.all()
    post_qs = post_qs.prefetch_related("author") # 추가
    return render(request, "blog/post_list.html", {"post_list": post_qs})

 

 

post_list.html

 

n + 1 문제가 해결된 것을 확인할 수 있다.

 

위의 과정은 간단한 예시이지만 여러 관계 테이블이 얽혀서 복잡한 쿼리셋을 수행하는 페이지라면  n + 1 문제를 발견하고 그것을 해결하는 로직을 작성하는 것은 힘들 것이다. 따라서 해당 문제를 발견하고 해결하는 경험을 많이 해봐야 할 것 같다.