[Basic Python] N+1 문제
N+1
장고에서 데이터를 불러오는 방식은 ORM의 Lazy-Loading을 기본으로 하고 있다. 이는 데이터가 필요한 시점에, 알맞은 쿼리를 최적화 해서 DB에서 갖고 온다. 일반적으론 이 최적화가 잘 작동해서, 알아서 최소한의 쿼리만 날리지만 몇몇 경우에서 N번 쿼리를 더 만들어서 N+1 문제가 발생한다.
모델 내에서 (DB) 원하는 정보가 없는 경우 N번에 걸쳐 반복하며 쿼리를 날리게 되는 경우다. 또 다른 예로는, N개의 데이터에 대해 명시하지 않은 필드에 접근할 때도 처음에 쿼리를 날리고, 이후에 필드가 없다 보니 N번에 걸쳐 추가적인 쿼리를 날리는 문제가 있다.
selected_realted()
먼저 아래와 같이 블로그 기능에 대한 간단한 형태의 3가지 테이블이 있을 경우 Post는 블로그 포스트를 의미하고, Tag는 블로그의 태그, Comment는 댓글을 의미한다. 한 블로그에 댓글이 여러 개 있을 수 있고(Post:Comment는 1:N), post는 여러 개의 Tag를 가질 수 있고, Tag도 여러 개의 post를 가질 수 있다.(Post:Tag N:M 관계)
1) 1:N 관계에서 N+1 문제
for comment in Comment.objects.all(): print(comment.post.title)
Comment를 반복하며 post의 title을 출력을 해보면 처음에 comment 테이블을 조회한 후, 총 5개의 코멘트에 대해 5번 post테이블을 조회하는 쿼리 로그를 볼 수 있다. 여기서 문제는 Comment와 Post 테이블을 한 번 조인하는 쿼리만 날리면 됐는데, 포스트 개수(5개)만큼 쿼리를 생성한다. (총 6번 쿼리)
(아래처럼 실행한 코드에 대해서 쿼리가 나오게 설정하는 방법: [Django] SQL 쿼리 로그 확인하기 (feat. 콘솔 창))
2) select_related()로 해결
for comment in Comment.objects.all().select_related("post"): print(comment.post.title)
위에서 쿼리를 1번만 생성하면 되었는데, 6번 날린 문제를 select_related를 통해 해결할 수 있다. select_related()는 주로 정방향 참조에서 사용 한다. 즉, 1:1 관계나 1:N 관계 중 N(Foreign Key를 정의하는 쪽)이 사용한다. Comment와 Post라는 두 테이블이 있을 때, select_related를 사용하면, Inner Join을 함으로 써 쿼리를 1번만 날리게 된다.
아래 결과를 보면 Comment 테이블과 Post 테이블을 조인하는 쿼리 1개만 생성한다. 그리고, 포스트 5개의 제목이 잘 출력된다. 즉, select_related()를 활용하면 Lazy_Loading이 아니라 Eager_Loading을 통해 미리 1번만 쿼리를 날려서 N+1 문제를 해결할 수 있다.
3) N:M 관계에서 N+1 문제
for post in Post.objects.all(): print(post.title, [tag.name for tag in post.tag_set.all()])
다음으론, Post의 객체를 반복하며, post에 속한 모든 태그들(tag_set)을 리스트 컴프리헨션으로 반복하여 출력할 때 Post와 Tag는 ManytoMany의 관계다. 아래 결과를 보면 5개의 포스팅에 대해, 6개의 쿼리를 날리는 것을 볼 수 있다.
먼저 Post 테이블을 조회하고 -> 5번에 걸쳐 tag 테이블과 tag_set 테이블을 INNER JOIN 하여 post id를 찾는 쿼리를 생성한다.
여기서 Post 테이블을 조회한 뒤, 찾은 post_id들을 tag 테이블과 tag_set 테이블을 조인한 테이블에서 필터링하면 2번 만 쿼리를 날려도 되는데 6개의 쿼리를 날리는 비효율이 발생한다.
4) prefetch_related()로 해결
for post in Post.objects.all().prefetch_related("tag_set"): print(post.title, [tag.name for tag in post.tag_set.all()])
prefetch_related()는 주로 역방향 참조에서 사용 한다. 즉, M:N 관계나 1:N 관계 중 1(Foreign Key를 정의하지 않은 쪽)이 사용한다. prefetch_related와 select_related의 차이점은 prefetch_related는 select_related 처럼 조인을 하는게 아니라, 필터링을 한다는 점 이다.
즉, Post에서 id를 쭉 불러오고, 이를 tag에서 필터링한다는 점이 select_related와의 차이점 이다. 다만, 여기서 tag에서 post_id로 필터링 하려면, tag와 tag_set이 조인된 테이블이 필요하기 때문에 조인이 일어난다.
코드의 결과를 보면, post 테이블을 조회하고, tag와 tag_set 테이블을 조인하는 2번의 쿼리만 실행됨을 볼 수 있다. 역시 Lazy_Loading이 아니라 Eager_Loading을 통해 미리 2번만 쿼리를 날려서 N+1 문제를 해결 한 한다.
Lazy Loading 과 Eager Loading
Django ORM에서 “Lazy Loading”과 “Eager Loading”은 데이터베이스와 관련된 데이터를 어떻게 로드하고 처리하는지에 대한 중요한 개념이다.
-
Lazy Loading (지연 로딩):
- “Lazy Loading”은 필요한 순간까지 데이터를 로드하지 않는 방식이다. 즉, 데이터에 접근하는 시점에서 해당 데이터를 로드한다.
- Django ORM에서는 관계된 객체나 필드에 대한 데이터를 불러올 때 기본적으로 Lazy Loading이 적용된다. 이는 필드나 관계된 객체에 접근하기 전까지 데이터베이스 쿼리가 실행되지 않는다는 의미한다.
- Lazy Loading은 데이터를 효율적으로 가져오지만, 쿼리 N+1 문제를 유발할 수 있다. 이는 관계된 객체를 여러 번 가져와야 할 때 성능 문제를 일으킬 수 있는 상황이다.
예시:
# Lazy Loading
author = Author.objects.get(pk=1)
books = author.book_set.all() # books를 접근할 때 쿼리 실행
# 쿼리 N+1 문제 발생
for author in Author.objects.all():
books = author.book_set.all() # 각 저자마다 books를 가져올 때마다 쿼리 실행
-
Eager Loading (즉시 로딩):
- “Eager Loading”은 필요한 데이터를 가능한 한 빠르게 미리 로드하는 방식이다. Django에서는
select_related()
와prefetch_related()
메서드를 사용하여 Eager Loading을 수행할 수 있다. select_related()
는 ForeignKey와 OneToOneField 관계에 대한 Eager Loading을 제공하며, 관련된 객체를 조인을 통해 미리 로드한다.prefetch_related()
는 ManyToManyField와 GenericForeignKey와 같은 다대다 관계에 대한 Eager Loading을 제공하며, 추가 쿼리를 실행하여 관련 데이터를 로드한다.- Eager Loading을 사용하면 쿼리 N+1 문제를 방지하고 성능을 향상시킬 수 있다.
- “Eager Loading”은 필요한 데이터를 가능한 한 빠르게 미리 로드하는 방식이다. Django에서는
# Eager Loading (select_related)
author = Author.objects.select_related('book').get(pk=1)
books = author.book # 이미 로드된 데이터 사용
# Eager Loading (prefetch_related)
authors = Author.objects.prefetch_related('book_set').all()
for author in authors:
books = author.book_set.all() # 이미 로드된 데이터 사용
GenericForeignKey
GenericForeignKey
는 Django 모델에서 사용되는 특별한 관계 타입 중 하나로, 다른 모델과의 다대다 관계를 다루는 데 사용된다. GenericForeignKey
를 사용하면 모델 간의 관계를 유연하게 설정하고 동일한 관계 필드를 여러 모델과 연결할 수 있다.
GenericForeignKey
는 일반적으로 다음과 같은 상황에서 유용하게 사용된다:
-
다수의 모델 간에 다대다 관계가 있을 때: 여러 모델이 하나 이상의 모델과 관련되어 있고, 해당 관계를 유연하게 설정하려는 경우.
-
컨텐츠 타입(Content Type)에 따른 다른 모델과의 관계: 예를 들어, 댓글(comment) 모델이 다른 모델의 게시물(post), 이미지(image), 댓글(comment) 등과 관련되어야 할 때.
GenericForeignKey
를 사용하려면 다음 세 가지 필드를 가진 모델이 필요하다:
-
content_type
: 관련된 모델의 타입을 지정하는 필드로, Django의ContentType
모델과 연결된다. 이 필드를 통해 어떤 모델과 관련되는지 식별합니다. -
object_id
: 관련된 모델의 인스턴스의 식별자를 저장하는 필드로, 일반적으로 정수형 필드다. -
content_object
: 실제로 관련된 모델의 인스턴스를 참조하는 제네릭 필드로,content_type
과object_id
를 사용하여 관련된 모델의 인스턴스를 가져온다.
다음은 GenericForeignKey
를 사용하는 예시다. 이 예시에서는 댓글(comment) 모델이 다른 모델(게시물, 이미지 등)과 관련된다:
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
class Comment(models.Model):
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
text = models.TextField()
# 다른 모델
class Post(models.Model):
title = models.CharField(max_length=200)
# 다른 필드들...
class Image(models.Model):
caption = models.CharField(max_length=200)
# 다른 필드들...
이런 식으로 GenericForeignKey
를 사용하면 Comment
모델이 여러 다른 모델과 관련될 수 있으며, 각 관련 모델에 대한 정보는 content_type
과 object_id
를 사용하여 저장되고 가져올 수 있다. 이를 통해 모델 간의 유연한 관계를 설정할 수 있다.
댓글남기기