장고 ORM 파헤치기 –  N+1 문제, 지연 로딩, 즉시 로딩

들어가며

이번 포스트에서는 “N+1 문제” 란 무엇이고 언제 발생하고 어떻게 해결해야 되는지 공부한 내용을 정리해보았습니다.

언제 “N+1 문제”가 발생하나요

전체 코멘트들에 대한 해당 글의 title를 출력하는 ORM을 실행한다고 가정해봅니다.

SQL로 작성하면 아래와 같이 작성할 수 있습니다.

SELECT c.content, a.title
FROM articles_comments c
JOIN articles_articles a
ON a.id = c.article_id 

이를 ORM으로 작성하면 아래와 같습니다.

@api_view(['GET'])
def check_sql(request):
    from django.db import connection
    comments = Comments.objects.all()
    for comment in comments:
        print(comment.article.title)
    print(connection.queries)
    return Response(status=status.HTTP_200_OK)
  • DRF에 대해 모르면 @api_view()? 는 뭐지 싶겠지만, 중요한 코드는 connection.queries입니다.
  • connection.queries은 Comments.objects.all() 쿼리셋을 for문으로 돌리는 동안 몇 번의 쿼리가 도는지를 확인해 보는 코드 입니다.

아래 결과가 코멘트에 해당하는 글제목을 요청하기 위해 실제 실행된 쿼리 리스트 입니다.

전체 결과는 생략을 했지만 위의 JOIN 처럼 한 번의 쿼리가 아닌 수 많은 쿼리가 실행 되었음을 확인 할 수 있습니다.

[{'sql': 'SELECT "articles_comments"."id", "articles_comments"."article_id", "articles_comments"."content", "articles_comments"."created_at", "articles_comments"."updated_at" FROM "articles_comments"', 'time': '0.000'}, {'sql': 'SELECT "articles_articles"."id", "articles_articles"."title", "articles_articles"."content", "articles_articles"."created_at", "articles_articles"."updated_at" FROM "articles_articles" WHERE "articles_articles"."id" = 2 LIMIT 21', 'time': '0.000'}, {'sql': 'SELECT "articles_articles"."id", "articles_articles"."title", "articles_articles"."content", "articles_articles"."created_at", "articles_articles"."updated_at" FROM "articles_articles" WHERE "articles_articles"."id" = 9 LIMIT 21', 'time': '0.000'}, {'sql': 'SELECT "articles_articles"."id", "articles_articles"."title", "articles_articles"."content", "articles_articles"."created_at", "articles_articles"."updated_at" FROM "articles_articles" WHERE "articles_articles"."id" = 5 LIMIT 21', 'time': '0.000'}, {'sql': 'SELECT "articles_articles"."id", "articles_articles"."title", "articles_articles"."content", "articles_articles"."created_at", "articles_articles"."updated_at" FROM "articles_articles" WHERE "articles_articles"."id" = 10 LIMIT 21', 'time': '0.000'}, {'sql': 'SELECT "articles_articles"."id", "articles_articles"."title", "articles_articles"."content", "articles_articles"."created_at", "articles_articles"."updated_at" FROM "articles_articles" WHERE ...
...

이렇게 여러번의 쿼리 실행은 곧장 DB와 django 사이 통신 비용으로 이어지게 됩니다.

왜 이런 결과가 나타나게 되는 걸까요?

지연 로딩 (Lazy Loading)

ORM이 실행되면 바로 SQL로 변환되어 쿼리되는 것이 아닌 해당 데이터가 실제로 사용될 때 쿼리를 진행합니다.

아래 코드를 확인해 봅니다.

comments = Comment.objects.all()
for comment in comments:
    print(f"{comment.id}의 글제목")
    print(f"{comment.article.title}")

여기서 실제 쿼리는 아래와 같이 발생합니다

  • comments = Comment.objects.all() : 쿼리 예약
  • for comment in comments: : comments 조회 쿼리 발생
  • print(f”{comment.id}의 글제목”) : 쿼리하지 않음 (이미 쿼리한 데이터를 사용)
  • print(f”{comment.article.title}”) : 해당 comment의 article id 조회 쿼리 실행

이것을 지연 로딩 (Lazy Loading) 이라고 합니다.

이런 지연 로딩 방식은 원래 불필요한 쿼리를 방지하여 필요한 데이터만 쿼리하도록 하기 위해 탄생한 것 입니다.

모든 데이터를 한 번에 로드하지 않고 필요한 경우에만 쿼리를 하므로 메모리 사용을 줄일 수 있는거죠.

N+1 문제

N+1 문제는 위와 같이 ORM과 DB와의 상호작용에서 발생할 수 있는 문제인데요.

이는 데이터베이스에서 개체 컬렉션을 검색한 다음 각 개체에 대한 연관 필드에 액세스해야 할 때 발생합니다.

초기 쿼리(N)

먼저 데이터베이스를 쿼리하여 개체 컬렉션을 검색합니다. 이를 “N” 개체라고 부르겠습니다. 예를 들어 블로그 게시물 목록을 가져올 수 있습니다.

추가 쿼리 (+1)

이제 이러한 “N”개 개체 각각에 대해 관련 필드 또는 속성에 액세스해야 합니다. 이를 위해서는 데이터베이스에 대한 추가 쿼리가 필요합니다.

예를 들어, 각 블로그 게시물에 작성자가 있는 경우 각 게시물에 대한 작성자 세부정보를 검색해야 할 수 있습니다.

결과 쿼리(N+1)

결과적으로 필요한 모든 데이터를 가져오기 위한 하나의 쿼리 대신 “N+1” 쿼리를 수행하게 됩니다.

개체 컬렉션을 가져오는 초기 쿼리 하나와 관련 데이터를 가져오는 각 개체에 대한 추가 쿼리 하나입니다.

이로 인해 특히 “N”이 큰 경우 데이터베이스 쿼리 수가 크게 증가할 수 있습니다.

N+1 문제가 주는 영향

“N+1 문제”로 인해 다수의 개별 쿼리가 데이터베이스로 전송됩니다.

초기 쿼리 결과(N)의 각 개체에 대해 관련 데이터(+1)를 가져오기 위해 추가 쿼리가 수행됩니다.

즉, 개체 수가 증가하면 데이터베이스 쿼리 수가 선형적으로 증가하여 데이터베이스 부하가 높아집니다.

그리고 이는 곧 애플리케이션의 응답성에 영향을 미칩니다.

N+1 문제가 발생할 수 있는 시나리오

위에서도 언급하였지만 일반적으로 반복적으로 관련 필드에 엑세스 하거나 연관 관계을 탐색할 때 “N+1 문제”가 발생합니다.

views에서 발생

for article in Articles.objects.all():
    print(article.author.name)  # N+1 issue if author data is not prefetched

templates에서 발생

{% for article in articles %}
    {{ article.author.name }}  <!-- N+1 issue if author data is not prefetched -->
{% endfor %}

serializers에서 발생

class ArticleSerializer(serializers.ModelSerializer):
    author_name = serializers.CharField(source='author.name')  # N+1 issue if author data is not prefetched

    class Meta:
        model = Post
        fields = ['title', 'author_name']

N+1 문제 감지할 수 있는 툴

N+1 문제를 감지하는 방법은 위의 예제처럼 sql_plus에서 connetion 클래스에서 queries 클래스를 사용하는 방법 외에도 유용한 방법들이 있습니다.

Django Debug Toolbar

Django 디버그 도구 모음은 SQL 쿼리를 포함하여 Django 요청 실행에 대한 자세한 정보를 제공하는 널리 사용되는 개발 도구입니다. 실행된 쿼리 수와 실행 시간을 표시하여 잠재적인 N+1 문제를 강조할 수 있습니다

django-queryinspect

django-queryinspect는 데이터베이스 쿼리를 기록하고 검사하는 Django 애플리케이션용 미들웨어입니다.

요청 중에 실행된 쿼리를 기록하고 잠재적인 최적화 영역을 강조 표시함으로써 N+1 문제를 비롯한 비효율적인 쿼리를 식별하는 데 도움이 될 수 있습니다.

특정 임계값을 초과하거나 특정 기준을 충족하는 쿼리를 기록하도록 구성할 수 있습니다.

django-silk

django-silk는 데이터베이스 쿼리를 포함하여 요청-응답 주기를 기록하고 분석하는 Django 애플리케이션용 프로파일링 도구입니다.

상세한 프로파일링 정보와 시각화를 제공하여 N+1 쿼리를 포함한 성능 병목 현상을 식별할 수 있습니다. 이를 사용하여 최적화가 필요한 코드베이스 영역을 찾아낼 수 있습니다.

django-silk 설치 및 사용법

즉시 로딩 (Eager Loading) : N+1 문제 해결법

“Eager Loading(이저 로딩)”은 데이터베이스 쿼리를 실행할 때 관련된 객체 또는 데이터를 한 번의 쿼리로 미리 로딩하는 개념입니다.

이는 prefetch_related() 혹은 select_related 메서드를 사용하여 수행됩니다.

prefetch_related()

장고는 prefetch_related() 쿼리셋 메서드를 제공합니다.

many-to-many 또는 역참조 관계에서 주로 사용합니다.

내부적으로 두번의 쿼리를 사용하여 동작합니다.

prefetch_related()를 사용하여 즉시 로딩을 수행하는 다음 예제를 확인해주세요.

from myapp.models import Author, Book

# 모든 책과 저자를 한 번의 쿼리로 검색
books = Book.objects.prefetch_related('author').all()

for book in books:
    print(book.title)
    print(book.author.name)  # 관련된 저자 객체가 이미 로드됨

select_related()

N+1 문제를 방지하고 데이터베이스 왕복을 줄이기 위해 주로 ForeignKeyOneToOneField 관계에 정방향 참조를 할때 사용됩니다.

selected_related()은 데이터베이스 쿼리에서 JOIN 작업을 수행하여 단일 쿼리에서 기본 개체와 함께 관련 개체를 검색함으로써 N+1 문제를 해결합니다.

selected_related()를 사용하는 예제는 다음을 확인해주세요.

comments = Comment.objects.all().select_related("article")
for comment in comments:
    print(comment.article.title)

article과 comment의 관계는 1:N의 관계이기 때문에

select_related() 를 사용하였습니다.

Comment.objects.all().select_related("article")에서 all()을 생략 가능합니다.

실제 API로 요청하기 위해 아래 코드로 view를 작성해보겠습니다.

@api_view(["GET"])
def check_sql(request):
    from django.db import connection

    comments = Comment.objects.all().select_related("article")
    for comment in comments:
        print(comment.article.title)

    print()
    print("-" * 30)
    print(connection.queries)

    return Response()

select_related() 메서드를 사용하여 쿼리 결과에 포함되어야 하는 관련 개체를 지정할 수 있습니다.

ForeignKey 및 OneToOneField 관계의 이름을 인자로 입력합니다.

실제 어떻게 쿼리가 요청되었는지 connection.queries 로 출력된 결과를 확인해보면 아래와 같습니다.

Comment.objects.all().select_related("article") 로 쿼리 요청한 경우

[{'sql': 'SELECT "articles_comments"."id", "articles_comments"."article_id", "articles_comments"."content", "articles_comments"."created_at", "articles_comments"."updated_at", "articles_articles"."id", "articles_articles"."title", "articles_articles"."content", "articles_articles"."created_at", "articles_articles"."updated_at" FROM "articles_comments" INNER JOIN "articles_articles" ON ("articles_comments"."article_id" = "articles_articles"."id")', 'time': '0.000'}]

Comments.objects.all() 로 쿼리 요청한 경우

[{'sql': 'SELECT "articles_comments"."id", "articles_comments"."article_id", "articles_comments"."content", "articles_comments"."created_at", "articles_comments"."updated_at" FROM "articles_comments"', 'time': '0.000'}, {'sql': 'SELECT "articles_articles"."id", "articles_articles"."title", "articles_articles"."content", "articles_articles"."created_at", "articles_articles"."updated_at" FROM "articles_articles" WHERE "articles_articles"."id" = 2 LIMIT 21', 'time': '0.000'}, {'sql': 'SELECT "articles_articles"."id", "articles_articles"."title", "articles_articles"."content", "articles_articles"."created_at", "articles_articles"."updated_at" FROM "articles_articles" WHERE "articles_articles"."id" = 9 LIMIT 21', 'time': '0.000'}, {'sql': 'SELECT "articles_articles"."id", "articles_articles"."title", "articles_articles"."content", "articles_articles"."created_at", "articles_articles"."updated_at" FROM "articles_articles" WHERE "articles_articles"."id" = 5 LIMIT 21', 'time': '0.000'}, {'sql': 'SELECT "articles_articles"."id", "articles_articles"."title", "articles_articles"."content", "articles_articles"."created_at", "articles_articles"."updated_at" FROM "articles_articles" WHERE "articles_articles"."id" = 10 LIMIT 21', 'time': '0.000'}, {'sql': 'SELECT "articles_articles"."id", "articles_articles"."title", "articles_articles"."content", "articles_articles"."created_at", "articles_articles"."updated_at" FROM "articles_articles" WHERE ...
...

위의 포스트 서두에서 보여드렸던 지연 로딩 결과와 비교해봤을 때 SQL 요청이 훨씬 줄어든 것을 확인 할 수 있습니다.

즉시 로딩 사용시 주의사항

메모리 사용량

즉시 로드는 잠재적으로 메모리 사용량을 증가시킬 수 있으며, 특히 관련 개체가 많은 대규모 데이터 세트를 가져올 때 더욱 그렇습니다. 관련된 모든 객체를 미리 가져오므로 기본 객체와 함께 메모리에 저장됩니다. 이로 인해 특히 관련 개체가 크거나 많은 경우 메모리 소비가 증가할 수 있습니다.

쿼리 복잡성

즉시 로드에는 JOIN 작업을 통해 더 복잡한 SQL 쿼리를 생성하여 기본 개체와 함께 관련 개체를 검색하는 작업이 포함됩니다. 이는 데이터베이스 왕복을 줄여 쿼리 성능을 향상시킬 수 있지만 SQL 쿼리가 더 길고 복잡해질 수도 있습니다. 경우에 따라 지나치게 복잡한 쿼리는 데이터베이스 성능에 영향을 미치거나 데이터베이스 엔진에 의해 부과된 제한 사항에 직면할 수 있습니다.

오버페치

즉시 로딩은 애플리케이션 코드에서 실제로 사용되는지 여부에 관계없이 select_관련() 또는 prefetch_관련() 메소드에 지정된 모든 관련 객체를 검색합니다. 이로 인해 불필요한 데이터가 데이터베이스에서 검색되어 네트워크를 통해 전송되어 추가 리소스를 소비하는 오버페치가 발생할 수 있습니다.

ForeignKey 대 ManyToManyField

select_관련()은 ForeignKey 및 OneToOneField 관계에 적합하지만 ManyToManyField 관계에는 작동하지 않습니다. ManyToManyField 관계의 경우 prefetch_관련()을 대신 사용해야 합니다. ManyToManyField 관계에 select_관련()을 사용하려고 하면 오류가 발생합니다.

데이터베이스 성능에 미치는 영향

즉시 로드는 데이터베이스 왕복을 줄여 성능을 향상시킬 수 있지만, 특히 복잡한 쿼리나 대규모 데이터 세트의 경우 항상 가장 효율적인 접근 방식은 아닐 수 있습니다. 최적의 성능을 보장하려면 애플리케이션의 특정 요구 사항과 데이터베이스의 특성을 신중하게 고려해야 합니다.

업데이트 및 삭제

Eager 로딩은 Django의 ORM에서 업데이트 및 삭제가 수행되는 방식에 영향을 주지 않습니다. 개체를 업데이트하거나 삭제할 때 데이터 무결성을 보장하려면 관계를 적절하게 관리해야 합니다. 즉시 로드는 쿼리 중 관련 개체 검색에만 영향을 미칩니다.

참고하면 좋은 글

Optimizing Django Rest Framework – fix the n+1 problem! (ahmadsalah.com)

Leave a Comment

목차