Python 및 Django를 사용한 성능 테스트 및 최적화 가이드
게시 됨: 2022-03-11Donald Knuth는 "성급한 최적화가 모든 악의 근원"이라고 말했습니다. 그러나 일반적으로 부하가 높은 성숙한 프로젝트에서 불가피하게 최적화해야 할 때가 옵니다. 이 기사에서는 웹 프로젝트의 코드를 최적화하는 5가지 일반적인 방법에 대해 이야기하고 싶습니다. Django를 사용할 것이지만 원칙은 다른 프레임워크 및 언어에서도 유사해야 합니다. 이 기사에서는 이러한 방법을 사용하여 쿼리 응답 시간을 77초에서 3.7초로 줄이겠습니다.
예제 코드는 내가 작업한 실제 프로젝트에서 수정되었으며 성능 최적화 기술을 보여줍니다. 따라하고 결과를 직접 확인하려는 경우 GitHub에서 초기 상태의 코드를 가져와 기사를 따라가면서 해당 변경을 수행할 수 있습니다. 일부 타사 패키지는 아직 Python 3에서 사용할 수 없기 때문에 Python 2를 사용하겠습니다.
우리의 응용 프로그램 소개
우리의 웹 프로젝트는 단순히 국가별 부동산 제안을 추적합니다. 따라서 두 가지 모델만 있습니다.
# houses/models.py from utils.hash import Hasher class HashableModel(models.Model): """Provide a hash property for models.""" class Meta: abstract = True @property def hash(self): return Hasher.from_model(self) class Country(HashableModel): """Represent a country in which the house is positioned.""" name = models.CharField(max_length=30) def __unicode__(self): return self.name class House(HashableModel): """Represent a house with its characteristics.""" # Relations country = models.ForeignKey(Country, related_name='houses') # Attributes address = models.CharField(max_length=255) sq_meters = models.PositiveIntegerField() kitchen_sq_meters = models.PositiveSmallIntegerField() nr_bedrooms = models.PositiveSmallIntegerField() nr_bathrooms = models.PositiveSmallIntegerField() nr_floors = models.PositiveSmallIntegerField(default=1) year_built = models.PositiveIntegerField(null=True, blank=True) house_color_outside = models.CharField(max_length=20) distance_to_nearest_kindergarten = models.PositiveIntegerField(null=True, blank=True) distance_to_nearest_school = models.PositiveIntegerField(null=True, blank=True) distance_to_nearest_hospital = models.PositiveIntegerField(null=True, blank=True) has_cellar = models.BooleanField(default=False) has_pool = models.BooleanField(default=False) has_garage = models.BooleanField(default=False) price = models.PositiveIntegerField() def __unicode__(self): return '{} {}'.format(self.country, self.address)
추상 HashableModel
은 인스턴스의 기본 키와 모델의 콘텐츠 유형을 포함하는 hash
속성을 상속하는 모든 모델을 제공합니다. 이렇게 하면 인스턴스 ID와 같은 민감한 데이터를 해시로 대체하여 숨깁니다. 프로젝트에 여러 모델이 있고 해시를 해제하고 다른 클래스의 다른 모델 인스턴스로 수행할 작업을 결정하는 중앙 집중식 장소가 필요한 경우에도 유용할 수 있습니다. 소규모 프로젝트의 경우 해싱 없이 처리할 수 있으므로 해싱이 실제로 필요하지 않지만 일부 최적화 기술을 시연하는 데 도움이 되므로 그대로 두겠습니다.
다음은 Hasher
클래스입니다.
# utils/hash.py import basehash class Hasher(object): @classmethod def from_model(cls, obj, klass=None): if obj.pk is None: return None return cls.make_hash(obj.pk, klass if klass is not None else obj) @classmethod def make_hash(cls, object_pk, klass): base36 = basehash.base36() content_type = ContentType.objects.get_for_model(klass, for_concrete_model=False) return base36.hash('%(contenttype_pk)03d%(object_pk)06d' % { 'contenttype_pk': content_type.pk, 'object_pk': object_pk }) @classmethod def parse_hash(cls, obj_hash): base36 = basehash.base36() unhashed = '%09d' % base36.unhash(obj_hash) contenttype_pk = int(unhashed[:-6]) object_pk = int(unhashed[-6:]) return contenttype_pk, object_pk @classmethod def to_object_pk(cls, obj_hash): return cls.parse_hash(obj_hash)[1]
API 끝점을 통해 이 데이터를 제공하고 싶기 때문에 Django REST Framework를 설치하고 다음 직렬 변환기 및 보기를 정의합니다.
# houses/serializers.py class HouseSerializer(serializers.ModelSerializer): """Serialize a `houses.House` instance.""" id = serializers.ReadOnlyField(source="hash") country = serializers.ReadOnlyField(source="country.hash") class Meta: model = House fields = ( 'id', 'address', 'country', 'sq_meters', 'price' )
# houses/views.py class HouseListAPIView(ListAPIView): model = House serializer_class = HouseSerializer country = None def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country) return queryset def list(self, request, *args, **kwargs): # Skipping validation code for brevity country = self.request.GET.get("country") self.country = Hasher.to_object_pk(country) queryset = self.get_queryset() serializer = self.serializer_class(queryset, many=True) return Response(serializer.data)
이제 데이터베이스를 일부 데이터( factory-boy
를 사용하여 생성된 총 100,000개의 주택 인스턴스: 한 국가에 대해 50,000개, 다른 국가에 대해 40,000개, 제3 국가에 대해 10,000개)를 채우고 앱의 성능을 테스트할 준비가 되었습니다.
성능 최적화는 측정에 관한 모든 것입니다
프로젝트에서 측정할 수 있는 몇 가지 사항이 있습니다.
- 실행 시간
- 코드 줄 수
- 함수 호출 수
- 할당된 메모리
- 등.
그러나 이들 모두가 우리 프로젝트의 성과를 측정하는 데 관련이 있는 것은 아닙니다. 일반적으로 말해서 가장 중요한 두 가지 주요 메트릭이 있습니다. 실행 시간과 필요한 메모리 양입니다.
웹 프로젝트에서 응답 시간 (서버가 일부 사용자의 작업에 의해 생성된 요청을 수신하고 처리하고 결과를 다시 보내는 데 필요한 시간)은 일반적으로 사용자가 기다리는 동안 지루하지 않도록 하기 때문에 가장 중요한 메트릭입니다. 응답을 위해 브라우저에서 다른 탭으로 전환합니다.
프로그래밍에서 프로젝트 성과를 분석하는 것을 프로파일링이라고 합니다. API 엔드포인트의 성능을 프로파일링하기 위해 Silk 패키지를 사용할 것입니다. 그것을 설치하고 /api/v1/houses/?country=5T22RI
호출(50,000개의 집 항목이 있는 국가에 해당하는 해시)을 만든 후 다음을 얻습니다.
200 GET
/api/v1/하우스/
전체 77292ms
쿼리 시 15854ms
50004 쿼리
전체 응답 시간은 77초이며 이 중 16초는 총 50,000개의 쿼리가 생성된 데이터베이스의 쿼리에 소요됩니다. 엄청난 숫자로 개선의 여지가 많으므로 시작하겠습니다.
1. 데이터베이스 쿼리 최적화
성능 최적화에 대한 가장 빈번한 팁 중 하나는 데이터베이스 쿼리가 최적화되었는지 확인하는 것입니다. 이 경우도 예외는 아닙니다. 또한 쿼리에 대해 몇 가지 작업을 수행하여 응답 시간을 최적화할 수 있습니다.
1.1 모든 데이터를 한 번에 제공
이 50,000개의 쿼리가 무엇인지 자세히 살펴보면 모두 houses_country
테이블에 대한 중복 쿼리임을 알 수 있습니다.
200 GET
/api/v1/하우스/
전체 77292ms
쿼리 시 15854ms
50004 쿼리
~에 | 테이블 | 조인 | 실행 시간(ms) |
---|---|---|---|
+0:01:15.874374 | "집_나라" | 0 | 0.176 |
+0:01:15.873304 | "집_나라" | 0 | 0.218 |
+0:01:15.872225 | "집_나라" | 0 | 0.218 |
+0:01:15.871155 | "집_나라" | 0 | 0.198 |
+0:01:15.870099 | "집_나라" | 0 | 0.173 |
+0:01:15.869050 | "집_나라" | 0 | 0.197 |
+0:01:15.867877 | "집_나라" | 0 | 0.221 |
+0:01:15.866807 | "집_나라" | 0 | 0.203 |
+0:01:15.865646 | "집_나라" | 0 | 0.211 |
+0:01:15.864562 | "집_나라" | 0 | 0.209 |
+0:01:15.863511 | "집_나라" | 0 | 0.181 |
+0:01:15.862435 | "집_나라" | 0 | 0.228 |
+0:01:15.861413 | "집_나라" | 0 | 0.174 |
이 문제의 원인은 Django에서 쿼리 세트가 게으르다 는 사실입니다. 이는 쿼리 세트가 평가되지 않고 실제로 데이터를 가져와야 할 때까지 데이터베이스에 도달하지 않음을 의미합니다. 동시에 사용자가 지시한 데이터만 가져오고 추가 데이터가 필요한 경우 후속 요청을 합니다.
그것이 바로 우리의 경우에 일어난 일입니다. House.objects.filter(country=country)
를 통해 쿼리 세트를 가져올 때 Django는 주어진 국가의 모든 주택 목록을 가져옵니다. 그러나 house
인스턴스를 직렬화할 때 HouseSerializer
는 직렬 변환기의 country
필드를 계산하기 위해 집의 country
인스턴스가 필요합니다. 국가 데이터가 쿼리 세트에 없기 때문에 django는 해당 데이터를 가져오기 위해 추가 요청을 합니다. 그리고 쿼리 세트의 모든 집에 대해 그렇게 합니다. 총 50,000번입니다.
하지만 해결 방법은 매우 간단합니다. 직렬화에 필요한 모든 데이터를 추출하기 위해 쿼리 세트에서 select_related()
메서드를 사용할 수 있습니다. 따라서 get_queryset
은 다음과 같습니다.
def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country).select_related('country') return queryset
이것이 성능에 어떤 영향을 미쳤는지 살펴보겠습니다.
200 GET
/api/v1/하우스/
전체 35979ms
쿼리 시 102ms
4개의 쿼리
전체 응답 시간은 36초로 떨어졌고 데이터베이스에서 보낸 시간은 단 4개의 쿼리로 ~100ms입니다! 좋은 소식입니다. 하지만 더 많은 일을 할 수 있습니다.
1.2 관련 데이터만 제공
기본적으로 Django는 데이터베이스에서 모든 필드를 추출합니다. 그러나 많은 열과 행이 있는 거대한 테이블이 있는 경우 Django에 추출할 특정 필드를 알려주어 전혀 사용되지 않을 정보를 얻는 데 시간을 소비하지 않도록 하는 것이 좋습니다. 우리의 경우 직렬화를 위해 5개의 필드만 필요하지만 17개의 필드가 있습니다. 데이터베이스에서 추출할 필드를 정확히 지정하여 응답 시간을 더욱 단축하는 것이 좋습니다.
Django에는 이것을 정확히 수행하기 위한 defer()
및 only()
쿼리 세트 메소드가 있습니다. 첫 번째 항목은 로드 하지 않을 필드를 지정하고 두 번째 항목은 로드할 필드 만 지정합니다.

def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country)\ .select_related('country')\ .only('id', 'address', 'country', 'sq_meters', 'price') return queryset
이렇게 하면 쿼리에 소요되는 시간이 반으로 줄어듭니다. 이는 좋은 일이지만 50ms는 그리 많지 않습니다. 전체 시간도 약간 줄어들었지만 줄일 수 있는 공간이 더 있습니다.
200 GET
/api/v1/houses/
전체 33111ms
쿼리 시 52ms
4개의 쿼리
2. 코드 최적화
데이터베이스 쿼리를 무한정 최적화할 수는 없으며 마지막 결과가 이를 보여줍니다. 쿼리에 소요되는 시간을 가상으로 0으로 줄이더라도 응답을 받기 위해 30분을 기다려야 하는 현실에 직면하게 됩니다. 이제 다른 최적화 수준인 비즈니스 로직 으로 전환할 때입니다.
2.1 코드 단순화
때때로 타사 패키지에는 간단한 작업에 대한 많은 오버헤드가 있습니다. 그러한 예 중 하나는 직렬화된 하우스 인스턴스를 반환하는 작업입니다.
Django REST Framework는 즉시 사용할 수 있는 유용한 기능이 많이 포함된 훌륭합니다. 그러나 현재 우리의 주요 목표는 응답 시간을 줄이는 것이므로 최적화를 위한 훌륭한 후보입니다. 특히 직렬화된 객체가 매우 단순하다는 점에서 그렇습니다.
이를 위해 사용자 지정 직렬 변환기를 작성해 보겠습니다. 간단하게 유지하기 위해 작업을 수행하는 단일 정적 메서드가 있습니다. 실제로 직렬 변환기를 서로 바꿔서 사용할 수 있도록 동일한 클래스 및 메서드 서명을 원할 수 있습니다.
# houses/serializers.py class HousePlainSerializer(object): """ Serializes a House queryset consisting of dicts with the following keys: 'id', 'address', 'country', 'sq_meters', 'price'. """ @staticmethod def serialize_data(queryset): """ Return a list of hashed objects from the given queryset. """ return [ { 'id': Hasher.from_pk_and_class(entry['id'], House), 'address': entry['address'], 'country': Hasher.from_pk_and_class(entry['country'], Country), 'sq_meters': entry['sq_meters'], 'price': entry['price'] } for entry in queryset ] # houses/views.py class HouseListAPIView(ListAPIView): model = House serializer_class = HouseSerializer plain_serializer_class = HousePlainSerializer # <-- added custom serializer country = None def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country) return queryset def list(self, request, *args, **kwargs): # Skipping validation code for brevity country = self.request.GET.get("country") self.country = Hasher.to_object_pk(country) queryset = self.get_queryset() data = self.plain_serializer_class.serialize_data(queryset) # <-- serialize return Response(data)
200 GET
/api/v1/houses/
전체 17312ms
쿼리 시 38ms
4개의 쿼리
지금이 더 좋아 보입니다. DRF 직렬 변환기 코드를 사용하지 않았기 때문에 응답 시간이 거의 절반으로 단축되었습니다.
또 다른 측정 가능한 결과(요청/응답 주기 동안 이루어진 총 함수 호출 수)는 15,859,427 호출(위의 섹션 1.2에서 이루어진 요청에서)에서 9,257,469 호출로 떨어졌습니다. 이는 모든 함수 호출의 약 1/3이 Django REST Framework에서 수행되었음을 의미합니다.
2.2 타사 패키지 업데이트/대체
위에서 설명한 최적화 기술은 가장 일반적이며 철저한 분석과 생각 없이는 할 수 있는 것입니다. 그러나 17초는 여전히 꽤 길게 느껴집니다. 이 숫자를 줄이려면 코드를 더 자세히 살펴보고 내부에서 일어나는 일을 분석해야 합니다. 즉, 코드를 프로파일링해야 합니다.
내장 Python 프로파일러를 사용하여 직접 프로파일링을 수행하거나 이에 대한 일부 타사 패키지(내장 Python 프로파일러 사용)를 사용할 수 있습니다. 우리는 이미 silk
를 사용하고 있기 때문에 코드를 프로파일링하고 더 시각화할 수 있는 바이너리 프로파일 파일을 생성할 수 있습니다. 바이너리 프로필을 통찰력 있는 시각화로 변환하는 여러 시각화 패키지가 있습니다. 저는 snakeviz
패키지를 사용할 것입니다.
다음은 보기의 디스패치 메서드에 연결된 위에서 마지막 요청의 바이너리 프로필을 시각화한 것입니다.
위에서 아래로 파일 이름, 줄 번호와 함께 메서드/함수 이름 및 해당 메서드에서 소요된 해당 누적 시간을 표시하는 호출 스택입니다. 이제 해시(보라색의 __init__.py
및 primes.py
직사각형) 계산에 가장 많은 시간을 할애한다는 것을 더 쉽게 알 수 있습니다.
현재 이것은 우리 코드의 주요 성능 병목 현상이지만 동시에 실제 코드가 아니라 타사 패키지입니다.
이러한 상황에서 우리가 할 수 있는 일은 제한적입니다.
- 새 버전의 패키지를 확인하십시오(더 나은 성능을 갖기를 바랍니다).
- 필요한 작업을 더 잘 수행하는 다른 패키지를 찾으십시오.
- 현재 사용하는 패키지의 성능을 능가하는 자체 구현을 작성하십시오.
운 좋게도 해싱을 담당하는 최신 버전의 basehash
패키지가 있습니다. 코드는 v.2.1.0을 사용하지만 v.3.0.4가 있습니다. 최신 버전의 패키지로 업데이트할 수 있는 이러한 상황은 기존 프로젝트에서 작업할 때 발생할 가능성이 더 큽니다.
v.3의 릴리스 정보를 확인할 때 매우 유망하게 들리는 다음과 같은 특정 문장이 있습니다.
소수 알고리즘으로 대대적인 점검이 이루어졌습니다. 시스템에서 사용 가능한 경우 gmpy2에 대한 (sic) 지원을 포함하면 훨씬 더 많이 증가합니다.
이것을 알아보자!
pip install -U basehash gmpy2
200 GET
/api/v1/하우스/
전체 7738ms
쿼리 시 59ms
4개의 쿼리
응답 시간을 17초에서 8초 미만으로 줄였습니다. 좋은 결과입니다. 하지만 한 가지 더 살펴봐야 할 사항이 있습니다.
2.3 자신의 코드 리팩토링
지금까지 쿼리를 개선하고 복잡한 타사 코드와 일반 코드를 우리 고유의 특정 기능으로 대체했으며 타사 패키지를 업데이트했지만 기존 코드는 그대로 두었습니다. 그러나 때로는 기존 코드의 작은 리팩토링이 인상적인 결과를 가져올 수 있습니다. 그러나 이를 위해서는 프로파일링 결과를 다시 분석해야 합니다.
자세히 살펴보면 해싱이 여전히 문제라는 것을 알 수 있습니다(당연히 이것이 우리가 데이터로 수행하는 유일한 작업임). 비록 우리가 그 방향으로 개선했지만. 그러나 __init__.py
가 2.14초를 소비한다는 녹색 사각형과 바로 뒤에 오는 회색빛 __init__.py:54(hash)
가 저를 괴롭힙니다. 이는 일부 초기화에 시간이 오래 걸린다는 것을 의미합니다.
basehash
패키지의 소스 코드를 살펴보자.
# basehash/__init__.py # Initialization of `base36` class initializes the parent, `base` class. class base36(base): def __init__(self, length=HASH_LENGTH, generator=GENERATOR): super(base36, self).__init__(BASE36, length, generator) class base(object): def __init__(self, alphabet, length=HASH_LENGTH, generator=GENERATOR): if len(set(alphabet)) != len(alphabet): raise ValueError('Supplied alphabet cannot contain duplicates.') self.alphabet = tuple(alphabet) self.base = len(alphabet) self.length = length self.generator = generator self.maximum = self.base ** self.length - 1 self.prime = next_prime(int((self.maximum + 1) * self.generator)) # `next_prime` call on each initialized instance
보시다시피 base
인스턴스를 초기화하려면 next_prime
함수를 호출해야 합니다. 위 시각화의 왼쪽 하단 직사각형에서 볼 수 있듯이 이는 상당히 무겁습니다.
내 Hash
클래스를 다시 살펴보겠습니다.
class Hasher(object): @classmethod def from_model(cls, obj, klass=None): if obj.pk is None: return None return cls.make_hash(obj.pk, klass if klass is not None else obj) @classmethod def make_hash(cls, object_pk, klass): base36 = basehash.base36() # <-- initializing on each method call content_type = ContentType.objects.get_for_model(klass, for_concrete_model=False) return base36.hash('%(contenttype_pk)03d%(object_pk)06d' % { 'contenttype_pk': content_type.pk, 'object_pk': object_pk }) @classmethod def parse_hash(cls, obj_hash): base36 = basehash.base36() # <-- initializing on each method call unhashed = '%09d' % base36.unhash(obj_hash) contenttype_pk = int(unhashed[:-6]) object_pk = int(unhashed[-6:]) return contenttype_pk, object_pk @classmethod def to_object_pk(cls, obj_hash): return cls.parse_hash(obj_hash)[1]
보시다시피, 각 메서드 호출에서 base36
인스턴스를 초기화하는 두 개의 메서드에 레이블을 지정했는데 실제로는 필요하지 않습니다.
해싱은 결정론적 절차입니다. 즉, 주어진 입력 값에 대해 항상 동일한 해시 값을 생성해야 하므로 무언가를 깨뜨릴 염려 없이 클래스 속성으로 만들 수 있습니다. 어떻게 작동하는지 확인해 봅시다:
class Hasher(object): base36 = basehash.base36() # <-- initialize hasher only once @classmethod def from_model(cls, obj, klass=None): if obj.pk is None: return None return cls.make_hash(obj.pk, klass if klass is not None else obj) @classmethod def make_hash(cls, object_pk, klass): content_type = ContentType.objects.get_for_model(klass, for_concrete_model=False) return cls.base36.hash('%(contenttype_pk)03d%(object_pk)06d' % { 'contenttype_pk': content_type.pk, 'object_pk': object_pk }) @classmethod def parse_hash(cls, obj_hash): unhashed = '%09d' % cls.base36.unhash(obj_hash) contenttype_pk = int(unhashed[:-6]) object_pk = int(unhashed[-6:]) return contenttype_pk, object_pk @classmethod def to_object_pk(cls, obj_hash): return cls.parse_hash(obj_hash)[1]
200 GET
/api/v1/하우스/
전체 3766ms
쿼리 시 38ms
4개의 쿼리
최종 결과는 4초 미만으로 처음 시작한 것보다 훨씬 작습니다. 캐싱을 사용하여 응답 시간을 추가로 최적화할 수 있지만 이 기사에서는 다루지 않을 것입니다.
결론
성능 최적화는 분석 및 발견의 프로세스입니다. 각 프로젝트에는 고유한 흐름과 병목 현상이 있으므로 모든 경우에 적용되는 엄격한 규칙은 없습니다. 그러나 가장 먼저 해야 할 일은 코드를 프로파일링하는 것입니다. 그리고 이러한 짧은 예에서 응답 시간을 77초에서 3.7초로 줄일 수 있다면 대규모 프로젝트의 최적화 가능성이 훨씬 더 커집니다.
더 많은 Django 관련 기사를 읽고 싶다면 동료 Toptal Django 개발자 Alexandr Shurigin의 Django 개발자가 저지르는 상위 10가지 실수를 확인하세요.