Django ORM에서 N+1 문제, 왜 이렇게 자주 언급될까요?

Django ORM을 사용하다 보면 "N+1 문제"라는 말을 자주 듣게 됩니다. 하지만 직접 경험하지 않으면 이 문제가 얼마나 심각한지 체감하기 어렵습니다.

간단히 말하면, "한 번만 실행하면 될 쿼리가 예상보다 훨씬 많이 실행되는 문제"입니다. 그 결과, 페이지 로딩 속도가 현저히 느려질 수 있으며, 데이터가 많아질수록 성능 저하가 심각해집니다. 특히 모델 간 관계가 있는 데이터를 조회할 때 발생하는 경우가 많기 때문에, ORM을 사용할 때 반드시 이해하고 있어야 합니다.

💡 N+1 문제, 쉽게 이해해봅시다

예를 들어 블로그 시스템을 만든다고 가정해보겠습니다. 각 저자(Author)는 여러 개의 게시물(Post)을 작성할 수 있습니다. Django 모델로 표현하면 다음과 같습니다.

from django.db import models

class Author(models.Model):
    name = models.CharField(max_length=100)

class Post(models.Model):
    title = models.CharField(max_length=255)
    content = models.TextField()
    author = models.ForeignKey(Author, on_delete=models.CASCADE)

저자(Author)는 여러 개의 게시물(Post)을 작성할 수 있으므로 1:N 관계입니다.

이제 모든 저자의 이름과 그가 작성한 게시물의 제목을 출력하는 기능을 구현한다고 가정해보겠습니다. 일반적으로 아래와 같이 작성할 가능성이 높습니다.

⚠️ N+1 문제가 발생하는 코드

authors = Author.objects.all()  # (1) 첫 번째 쿼리 (Author 테이블 조회)

for author in authors:
    print(author.post_set.all())  # (N) 각 Author의 Post를 개별 조회

한 번 실행해보면 예상보다 로딩이 오래 걸릴 수 있습니다. 데이터가 많지 않다면 티가 나지 않을 수도 있지만, 만약 저자가 100명, 게시물이 500개 이상이라면? 이제 슬슬 문제가 눈에 보이기 시작할 것입니다.

🧐 실제 실행되는 SQL 쿼리

SELECT * FROM author;  -- 1번 실행
SELECT * FROM post WHERE author_id = 1;  -- N번 실행
SELECT * FROM post WHERE author_id = 2;
SELECT * FROM post WHERE author_id = 3;
...

이처럼 저자가 많아질수록 쿼리 수도 기하급수적으로 증가합니다. 이것이 바로 N+1 문제입니다.

🚀 N+1 문제는 어떤 상황에서 자주 발생할까요?

사실 이 문제는 Django ORM을 잘 사용하든 못 사용하든, 두 개 이상의 모델이 연관된 데이터를 조회할 때는 거의 반드시 신경 써야 하는 문제입니다. 특히 다음과 같은 경우에서 자주 발생합니다.

1️⃣ 템플릿에서 반복문 안에서 데이터를 참조하는 경우

{% for author in authors %}
    

{{ author.name }}

    {% for post in author.post_set.all %}
  • {{ post.title }}
  • {% endfor %}
{% endfor %}

템플릿에서 반복문을 돌며 데이터에 접근하는 경우, 각 객체마다 추가적인 쿼리가 발생하면서 성능이 저하됩니다.

2️⃣ 뷰 함수에서 반복문 내에서 관련 모델 참조

def blog_view(request):
    authors = Author.objects.all()  # 첫 번째 쿼리 (Author 조회)
    
    author_list = []
    for author in authors:
        author_list.append({
            'name': author.name,
            'posts': author.post_set.all()  # 추가적인 N개의 쿼리 발생
        })
    
    return render(request, 'blog.html', {'authors': author_list})

템플릿이 아닌 뷰 함수에서 데이터를 가공하는 경우에도 동일한 문제가 발생할 수 있습니다.

💡 직접 겪었던 N+1 문제

예전에 get_absolute_url()이라는 커스텀 메서드를 만든 적이 있었습니다. 모델의 여러 필드를 조합하여 URL을 생성하는 함수였는데, 성능이 느려지는 문제를 겪고 나서야 원인을 찾았습니다.

class Post(models.Model):
    title = models.CharField(max_length=255)
    slug = models.SlugField(unique=True)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)

    def get_absolute_url(self):
        return f"/author/{self.author.name}/{self.slug}/"

이 메서드를 호출할 때마다 self.author.name이 실행되며, N개의 추가적인 쿼리가 발생하고 있었습니다. 이 문제를 해결하기까지 꽤 오랜 시간이 걸렸습니다.

✅ 정리: N+1 문제를 반드시 이해해야 하는 이유

  • Django ORM을 사용하는 한, 이 문제는 언제든지 발생할 수 있습니다.
  • 페이지 속도가 느려진다면, ORM이 예상보다 많은 쿼리를 실행하고 있을 가능성이 높습니다.
  • 템플릿, 뷰 함수, 모델 메서드 어디에서든 발생할 수 있습니다.
  • 데이터가 많아질수록 성능 저하가 심각해질 수 있습니다.
  • ORM이 자동으로 최적화를 해주지는 않으므로, 실행되는 쿼리를 직접 확인하고 최적화해야 합니다.

다음 글에서는 select_relatedprefetch_related를 사용해 N+1 문제를 해결하는 방법을 다루겠습니다.

N+1 Query Problem in Django ORM