Django에서 성능을 저하시키는 문제들 ⚠️
Source: Dev.to
위에 제공된 소스 링크 외에 번역할 텍스트를 알려주시면 한국어로 번역해 드리겠습니다.
목차 📑
- Consultas N+1
- Falta de índices en la base de datos
- Evaluación prematura del QuerySet
- Cargar datos innecesarios (overfetching)
- Falta de
select_relatedyprefetch_related - Uso incorrecto de
count() - Un mal uso de devolver lo mismo que
count() - No usar transacciones correctamente
- No usar bulk operations
- Uso eficiente de
annotate() - No usar Django Debug Toolbar
- No usar Django Silk para profiling
- Linkografía
N+1 쿼리 🐌
데이터베이스와 상호작용하는 애플리케이션에서 흔히 발생하는 성능 병목 현상입니다. 애플리케이션이 단일 쿼리로 얻을 수 있는 데이터를 얻기 위해 N개의 추가 쿼리를 실행할 때 발생합니다. 이로 인해 총 N + 1개의 쿼리가 실행되어, 1개만 실행될 경우에 비해 성능이 크게 저하되며, 특히 데이터셋이 커질수록 그 영향이 크게 나타납니다.
예시
for post in Post.objects.all():
print(post.author.name)
이 코드는 posts에 대해 1개의 쿼리와 저자에 대해 N개의 쿼리를 생성합니다.
Django에서는 select_related 메서드를 추가하여 해결합니다:
posts = Post.objects.select_related("author")
for post in posts:
print(post.author.name)
데이터베이스 인덱스 부족 📚
인덱스는 테이블에서 정보 검색을 최적화하여 모든 레코드를 순회하지 않아도 더 빠른 쿼리를 가능하게 합니다. 이는 대용량 데이터 애플리케이션에서 가장 흔한 문제 중 하나입니다.
User.objects.filter(email="test@gmail.com")
인덱스가 없으면 테이블 전체를 스캔하게 됩니다. 프로덕션 환경에서 수백만 행을 처리할 경우 성능이 크게 저하됩니다.
Django ORM에서 인덱스를 추가하려면 db_index=True를 사용해야 합니다:
class User(models.Model):
email = models.EmailField(db_index=True)
QuerySet의 조기 평가 ⚡
QuerySet은 lazy하지만, 많은 사람들이 잘못 평가합니다:
qs = User.objects.all()
if len(qs) > 0:
...
올바른 사용 방법은 exists()로 존재 여부를 확인하는 것입니다:
if qs.exists():
...
exists() 메서드는 객체 존재와 관련된 검색에 유용합니다; QuerySet에 결과가 있으면 True, 없으면 False를 반환합니다. 가능한 가장 간단하고 빠른 방식으로 쿼리를 실행하지만, 이후에 객체에 접근해야 할 경우에는 적합하지 않습니다.
불필요한 데이터 로드 (overfetching) 📦
쿼리를 수행할 때 필요한 필드만 가져오는 것이 좋습니다.
과도하게 로드하는 예시:
users = User.objects.all()
id와 함께 이름만 가져오기:
-
only()사용 (모델 인스턴스 반환):User.objects.only("name") -
values()사용 (딕셔너리 반환):User.objects.values("name") -
values_list()사용 (튜플 리스트 또는 평면 값 반환):User.objects.values_list("name", flat=True)
select_related와 prefetch_related 부족 🔗
관련 객체에 접근하면 N+1 쿼리 문제가 발생할 수 있습니다. Django는 이를 해결하고 성능을 향상시키기 위해 select_related()와 prefetch_related()를 제공합니다.
select_related()
JOIN을 사용하여 단일 SQL 쿼리로 관련 객체를 가져옵니다. ForeignKey와 OneToOneField 관계에 더 적합합니다.
Post.objects.select_related("author")
prefetch_related()
별도의 쿼리를 실행하고 결과를 Python에서 결합합니다. ManyToMany, 역방향 ForeignKey 및 역방향 OneToOne 관계에 더 적합합니다.
books = Book.objects.prefetch_related('authors')
for book in books:
print(book.title)
for author in book.authors.all():
print(author.name)
Prefetch를 활용한 고급 사용
필터링, 정렬 또는 사용자 정의 속성에 할당해야 할 때는 Prefetch 객체를 사용합니다.
from django.db.models import Prefetch
books = Book.objects.prefetch_related(
Prefetch(
"authors",
queryset=Author.objects.filter(active=True)
)
)
for book in books:
for author in book.authors.all():
print(author.name)
count()의 잘못된 사용 🔢
count() 메서드는 정수를 반환하며, 이는 QuerySet과 일치하는 데이터베이스 객체의 개수를 나타냅니다.
User.objects.count()
참고: 레코드가 존재하는지만 확인하면 될 경우,
count()보다exists()가 더 효율적입니다.exists()는 모든 결과를 세지 않기 때문에 성능이 좋습니다.
count()와 같은 결과를 반환하는 잘못된 사용
len(User.objects.all())
count()는 데이터베이스에서 SELECT COUNT(*)를 직접 실행하고, len()은 모든 객체를 메모리로 로드합니다.
트랜잭션을 올바르게 사용하지 않음 🔒
Django는 기본적으로 autocommit을 사용합니다. 이는 각 .save(), .create() 또는 .update() 연산이 별개의 트랜잭션으로 실행된다는 의미입니다.
이는 두 가지 중요한 결과를 초래합니다:
- 여러 커밋으로 인한 오버헤드 증가.
- 복잡한 작업 중간에 오류가 발생하면 일관성 문제가 발생할 위험.
일반적인 문제
for item in items:
item.processed = True
item.save()
Django는 각 반복마다 1 UPDATE = 1 COMMIT을 실행합니다. 객체가 10 000개라면 10 000개의 커밋이 발생하여 불필요한 오버헤드와 전체 실행 시간이 증가합니다.
transaction.atomic()을 사용한 해결책
from django.db import transaction
with transaction.atomic():
for item in items:
item.processed = True
item.save()
이제 Django는 10 000 UPDATE = 1개의 COMMIT만 실행합니다 (쿼리 수는 줄어들지 않지만).
bulk 작업 사용 금지 🚀
bulk_create()는 제공된 객체 리스트를 효율적으로 데이터베이스에 삽입하고, 생성된 객체들을 리스트로 반환합니다. 반환된 리스트는 제공된 순서와 동일합니다:
users = [
User(username="user1"),
User(username="user2"),
]
User.objects.bulk_create(users)
annotate() 효율적인 사용 📊
annotate()는 SQL 집계를 사용하여 QuerySet의 각 객체에 계산된 정보를 추가할 수 있게 해 주며, 불필요한 추가 쿼리를 방지합니다.
일반적인 문제
posts = Post.objects.all()
for post in posts:
print(post.title, post.comments.count())
이 경우 발생하는 쿼리:
- 게시물에 대한 1개의 쿼리.
- 댓글 수를 세기 위한 N개의 추가 쿼리 (N+1 문제).
annotate()를 활용한 해결법
from django.db.models import Count
posts = Post.objects.annotate(comment_count=Count("comments"))
for post in posts:
print(post.title, post.comment_count)
카운트가 데이터베이스에서 수행되므로 단일 SQL 쿼리만 실행됩니다.
장점
- 쿼리 수를 줄입니다.
- 데이터베이스 엔진의 최적화를 활용합니다.
- 대용량 데이터에서 성능이 크게 향상됩니다.
유용한 기타 함수
from django.db.models import Count, Sum, Avg, Max, Min
User.objects.annotate(
post_count=Count("posts"),
avg_score=Avg("posts__score")
)
Django Debug Toolbar 사용 금지 🔎
django-debug-toolbar는 실시간으로 애플리케이션 성능에 대한 상세 정보를 제공하며, 다음과 같은 기능이 있습니다:
- 실행된 SQL 쿼리 수.
- 각 쿼리의 실행 시간.
- 중복된 쿼리.
- 전체 응답 시간.
- 캐시 사용량.
이를 통해 다음과 같은 문제를 감지할 수 있습니다:
- N+1 쿼리.
- 불필요한 쿼리.
- 느린 쿼리.
- 인덱스 부족.
Django Silk을 프로파일링에 사용하지 마세요 🔬
측정 없이 최적화하는 것은 가장 흔한 실수 중 하나입니다. django-silk은 애플리케이션의 실제 성능을 분석할 수 있는 프로파일링 도구입니다. Django Debug Toolbar와 달리 Django Silk은:
- 요청 히스토리를 저장합니다.
- 쿼리를 상세히 분석할 수 있습니다.
- 정확한 실행 시간을 표시합니다.
- 파이썬 함수의 프로파일링을 지원합니다.