728x90

명시적으로 api 작성하는것과 적절한 모델링을 통해 서버 개발하는것은 기본입니다. 그렇다면, 어떤 부분에서 성능을 개선할 수 있을까? 생각해봐야합니다.

 

추후 다른 주제로도 다루겠지만 이번 챕터에서는 쿼리에 대해 다뤄보겠습니다.

 

1. select_related와 prefetch_related(feat. N+1)

이건 많이 접했을것이라 생각듭니다. 먼저 select_related와 prefetch_related에 대해 알아보기전 django orm의 고질적인 문제인 N+1문제에 대해 알아봐야합니다.

from django.db import models


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


class Book(models.Model):
    title = models.CharField(max_length=100)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)


from .models import Book


def book_list(request):
    books = Book.objects.all()
    for book in books:
        author_name = book.author.name  # 작가 정보를 가져오기 위해 추가 쿼리 발생
    return render(request, 'book_list.html', {'books': books})

위와 같은 기본적인 작과와 도서에 대한 간단한 모델의 각 도서 목록의 작간의 이름을 가져오는 방식을 볼때의 경우,

- Book.objects.all() 도서목록을 가져오는 쿼리 1회

- book.author.name을 통해 n개의 작가 이름을 가져오는 쿼리 n회

이러한 비효율적인 쿼리가 발생하게 됩니다.

 

이러한 문제를 해결하기 위해 select_related를 활용할 수 있습니다.

select_related란, Foreinkey 혹은 OneToOneField와 같은 관계 필드에 대해 사용하며 데이터베이스에서 필요한 연관 객체를 추가로 로드하게 되어 위와같은 N+1 쿼리 문제를 해결할 수 있게 됩니다.

books = Book.objects.select_related('author').all()
for book in books:
    author_name = book.author.name  # 추가 쿼리 없이 author를 미리 로드

- Book.objects.select_related('author').all() 1회의 쿼리에서 미리 작가의 정보까지 로드

이후, 반복문의 경우 미리 로드되어 있는 author의 정보로 인해 추가적인 쿼리가 발생하지 않게 됩니다.

 

그렇다면, prefetch_related는 무엇일까요? 이또한, N+1문제를 해결할 수 있으며 select_related와는 다르게 ManyToManyField 및 reverse ForeignKey/OneToOneField 관계와 같은 역참조 필드에 사용됩니다.

class Category(models.Model):
    name = models.CharField(max_length=100)
    books = models.ManyToManyField(Book)


from .models import Category

# prefetch_related를 사용하지 않은 경우
categories = Category.objects.all()
for category in categories:
    books = category.books.all()  # N+1 쿼리 발생

# prefetch_related를 사용한 경우
categories = Category.objects.prefetch_related('books').all()
for category in categories:
    books = category.books.all()  # 추가 쿼리 없이 books를 미리 로드

- Category.objects.all() 카테고리 목록을 가져오는 쿼리 1회의 쿼리에서 미리 도서 목록의 정보까지 로드

이후, 추가 쿼리에서는 미리 로드 되어있는 books로 인해 추가적인 쿼리가 발생하지 않게됩니다.

 

물론, 이 메서드들을 때에 맞지 않게 사용할시 오히려 역효과를 나을수 있겠지만 잘 사용만 한다면 쿼리 성능을 극대화 할 수 있는 가장 효율좋은 메서드 입니다.

 

2. bulk_create, bulk_update

예시를 들어봅시다. name과 price로 이뤄진 Product 모델이 있고 각각의 데이터 100개를 저장해야합니다.

from django.db import models

class Product(models.Model):
    name = models.CharField(max_length=100)
    price = models.DecimalField(max_digits=10, decimal_places=2)


from .models import Product

products_to_insert = [
        Product(name='Product 1', price=10.99),
        Product(name='Product 2', price=20.99),
        Product(name='Product 3', price=15.99),
        ...
        Product(name='Product 100', price=14.99),
    ]

for product in products_to_insert:
    product.save()

위와 같이 저장할시, 당연히 문제 없이 저장이 될것 입니다. 하지만, 100개의 쿼리가 발생하며 이말은 즉, 100개의 트랜잭션의 작업이 발생한다는 것입니다.

Product.objects.bulk_create(products_to_insert)

 bulk_create를 사용하면 저장할 Product 스키마에 맞는 데이터를 리스트에 담아 전달해주면 하나의 트랜잭션내에서 100개의 데이터를 생성해낼 수 있습니다.

물론, 성능상 이점도 있겠지만 하나의 트랜잭션 내에서 실행되므로 일관성이 유지된다는 장점 또한 존재합니다.

 

bulk_update도 어렵지 않게 bulk_create와 동일한 방식으로 사용가능합니다.

products_to_update = [
        Product(name='Product 1', price=10.99),
        Product(name='Product 2', price=20.99),
        Product(name='Product 3', price=15.99),
        ...
        Product(name='Product 100', price=14.99),
    ]

Product.objects.bulk_update(products_to_update, ['price'])

bulk_create와 마찬가지로 스키마에 맞는 데이터를 리스트에 담아 전달 후, 업데이트할 데이터를 2번째 인자로 리스트에 담아 전달해주면 bulk_update가 완료됩니다.

 

추가로 insert_or_update 기능또한 존재합니다. 기본적으로 bulk_create 메서드를 이용하여 사용하는데 사용방식은 아래와 같습니다.

from django.db import models


class Product(models.Model):
    name = models.CharField(max_length=100)
    price = models.DecimalField(max_digits=10, decimal_places=2)
	quantity = models.PositiveIntegerField()
    date = models.DateField()
    
    class Meta:
        constraints = [
            models.UniqueConstraint(
                fields=["name", "date"],
                name="unique name and date",
            )
        ]


from .models import Product


product_data = [
        Product(name='Product 1', price=10.99, quantity=10, date='2021-01-03'),
        Product(name='Product 2', price=20.99, quantity=6, date='2022-01-03'),
        Product(name='Product 3', price=15.99, quantity=3, date='2023-01-03'),
        ...
        Product(name='Product 100', price=14.99, quantity=2, date='2023-05-03'),
    ]

Product.objects.bulk_create(
    product_data,
    update_conflicts=True,
    unique_fields=["name", "date"],
    update_fields=["quantity", "price"],
)

bulk_create 메서드에서 update_conflicts=True로 설정뒤 고유값으로 설정한 필드와 업데이트할 필드를 입력해줍니다.

주의사항의 경우, 기본적으로 unique하게 선언되어 있지 않던 컬럼의 경우, model단에서, constraint로 선언해주어야합니다.

 

위의경우, 기존에 name=Product 1, date:2021-01-03이였던 데이터가 있었다면 price와 quantity가 업데이트 되며, 이외의 데이터의 경우, 새롭게 생성되게 됩니다.

 

물론, bulk_create, bulk_update방식이 위와같이 가장 이상적으로 담겨진다면 문제가 없겠지만 어쩔수 없이 로직상 불가능한 경우엔 사용이 어려운 부분이 있어 상황에 맞게 사용해야함은 당연합니다.

 

개인적으로, bulk 기능을 잘이용하기위한 팁은 순서를 보장하는 자료구조와 dataclass를 사용하여 미리 데이터 셋을 정의하고 bulk 기능을 사용하는것이 유용하니 참고 해보시길 바랍니다.

 

3. cached_property

class Travel(BaseAdminModel):
    user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="여행 리스트 방장")
    members = models.ManyToManyField(User, through="Member", related_name="travels", verbose_name="여행 멤버들")
    title = models.CharField(max_length=255, verbose_name="여행 제목")
    color = models.CharField(max_length=255, verbose_name="여행 색상")
    start_date = models.DateField(verbose_name="여행 시작 날짜")
    end_date = models.DateField(verbose_name="여행 끝나는 날짜")
    description = models.CharField(max_length=13, null=True, verbose_name="여행 메모")
    currency = models.CharField(max_length=15, choices=CurrencyType.CHOICES, default=CurrencyType.USD)

    def __str__(self):
        return self.title

    @property
    def total_amount(self):
        return sum(self.billings.all().values_list('total_amount', flat=True))

위의 코드의 경우, 쿼리를 할때마다 total_amount 연산을 수행하게 됩니다.

 

django에서 제공하는 decorator인 cached_property까 존재하는데 처음 호출되었을때 property 함수 결과값을 캐싱해 둔 뒤 그 이후에는 캐싱된 결과를 리턴합니다. 즉, 쿼리를 할때마다 total_amount로직을 수행하는 것이 아닌 해당 메서드를 호출할 때 캐싱된 값을 리턴하게 되어 불필요한 연산을 줄이게 됩니다.

from django.utils.functional import cached_property 


@cached_property 
def total_amount(self):
    return sum(self.billings.all().values_list('total_amount', flat=True))

캐싱된 데이터는 모델 인스턴스가 살아있는 동안만 캐싱되며, 모델인스턴스의 생명이 끝나게 되면 함께 초기화 되게 됩니다. 캐싱된 데이터이기 때문에 모델인스턴스에서 최초 1회가 아닌 연산에 변화가 생기게 되더라도 변화가 적용되지 않기 때문에 이부분을 주의하여 사용한다면, 성능 개선에 굉장한 도움이 될것입니다.

 

 

orm에서 제공하는 몇몇 메서드들만 활용하더라도, 성능 개선을 할수 있음에 많은 도움을 받을 수 있었습니다. 기본적인 orm 메서드 활용 뿐만 아니라 다른 자원을 좀 더 공격적으로 활용해 서버 개선을 할수 있으니 다음 챕터에서는 다른 자원을 활용한 서버 성능 개선에 대해 알아보도록 하겠습니다.

728x90

+ Recent posts