[Django] DRF의 Serializer 성능 개선하기

namukeu·2021년 6월 5일
8

Django

목록 보기
1/2
post-thumbnail

머리말

Django를 이용해 백엔드를 개발하게 되면, 편의를 위해 Django Rest Framework(이하 DRF)를 사용해 API개발을 하게되는 경우가 많은 것 같다.

그러나.. DRF 관련 글들을 찾아보면 성능과 관련된 부정적인 단어들을 굉장히 쉽게 접할 수 있다. 이 글에서는 필자가 DRF를 이용해 개발하면서 가장 체감이 심했던 성능저하 이슈는 무엇이었는지, 어떤 고민들을 했는지, 또 결국 어떻게 해결했는지, 또 다른 어떤 이슈들이 있을지를 공유하고자 한다.

(첫 글이니만큼 문체나 내용이 다소 어색할 수 있는점 양해바란다..)

필자는 Django 프로젝트 개발에 합류했을때가 Django 개발의 처음이었고, 이미 DRF가 적용되어 있어서 별다른 고민 없이 개발을 했던 것 같다. 덕분에 성능 저하가 심했지만.. 편의성 만큼은 인정한다^^

TLDR;

시간이 없는 분들을 위해 결론부터 이야기하자면, prefetch_relatedselect_related를 적재적소에 활용하면 쉽게 해결할수 있다!! 고민 과정과 구체적인 내용이 궁금한 분들은 쭉 읽어보셔도 좋다!


본문

문제 상황

다음과 같은 Model이 있다고 가정해보자(여기서 DB는 MySQL과 같은 RDBMS를 사용한다고 가정하겠다.)

class CustomUser(models.Model):
    username = models.CharField(max_length=15,unique=True)
    full_name = modelsCharField(max_length=15)
    
class UserProfile(models.Model):
    user = models.OneToOneField(CustomUser, on_delete=CASCADE)
    mobile_number = models.CharField(max_length=20,unique=True)
        
class Article(models.Model):
    user = models.ForeignKey(CustomUser, on_delete=SET_NULL)
    title = models.CharField(max_length=20)
    content = models.TextField(blank=True)

그렇다면, 기본적인 UserProfileSerializer는 아래처럼 구성되겠지만,

class UserProfileSerializer(serializerss.ModelSerializer):
    class Meta:
        model = UserProfile
        fields = '__all__'
        
>>> user_profile_queryset = UserProfile.objects.all()
>>> serializer = UserProfileSerializer(user_profile_queryset, many=True)
>>> serializer.data
[{
    'id' : 1,
    'user' : 1,
    'mobile_number' : '01011111111'
}, {
    'id' : 2,
    'user' : 2,
    'mobile_number' : '01022222222'
}, ...]

serializer.data 로 받아오는 데이터에 Depth를 부여할 수 없다. 즉, 위의 예시처럼 UserProfileSerializer로 얻은 data는 연결된 User에 대한 정보를 ForeignKey에 해당하는 id값만 보여주고, 그 User의 username 혹은 full_name이 무엇인지에 대해서는 알 수 없다.
User에 대한 더 자세한 정보까지 serialize하기 위해서는 아래와 같은 코드가 필요하다.

class CustomUserSerializer(serializers.ModelSerializer):
    class Meta:
        model = CustomUser
        fields = '__all__'

class UserProfileSerializer(serializers.ModelSerializer):
    user = CustomUserSerializer(read_only=True, is_relation=True)
    class Meta:
        model = UserProfile
        fields = '__all__'

>>> user_profile_queryset = UserProfile.objects.all()
>>> serializer = UserProfileSerializer(user_profile_queryset, many=True)
>>> serializer.data
[{
    'id' : 1,
    'user' : {
        'id' : 1,
        'username' : 'user_1',
        'full_name' : '김유저'
    },
    'mobile_number' : '01011111111'
}, {
    'id' : 2,
    'user' : {
        'id' : 2,
        'username' : 'user_2',
        'full_name' : '박유저'
    },
    'mobile_number' : '01022222222'
}, ...]

하지만 위처럼 코드를 실행하면, 좋은 방법이라고 할 수 없다.

위의 예시는, 연결된 field가 하나 뿐이고, 연결된 테이블 자체도 그렇게 많은 정보를 가지고 있지 않다. 하지만 연결된 field가 늘어난다면? 혹은 연결된 테이블에 있는 row가 100만개라면?
과연 UserProfile 정보를 serialize 하기 위해 몇 번의 쿼리가 필요할까?

Database와 Query에 대해 조금이라도 알고 있는 사람이라면,이 문제에 다음과 같은 의문이 들 것이다.

"단순하게 Join하면 모든 정보를 다 조회할 수 있는데 왜 굳이 하나씩 차례차례 쿼리해야할까?"

그것은, 우리가 QuerySet을 만들 때, 쿼리에 대한 아무런 정보도 넘겨주지 않았기 때문이다. Serializer에서 데이터에 접근해야 쿼리가 발생하는 것은 맞지만, 우리는 그 어디에서도 쿼리를 어떻게 했으면 좋겠다고 정하지 않았고, Django는 lazy loading 방식으로 실제 데이터를 쿼리하기 때문에 정말 그 데이터에 접근하려고 하는데, cache에서 찾을 수 없을 경우에 실제 쿼리를 한다.

사용할 때 비로소 쿼리가 실행되는 것과 같이 정말 필요할 때 리소스를 로딩하는 방식을 lazy loading 이라고 하며, ForeignKey로 연결된 Model에 직접 접근할 때 비로소 다시 그 Model에 대해 쿼리하는 것 또한 lazy loading 방식으로 작동한다.

실제 쿼리를 날리는 쪽은 Serializer가 아니라 QuerySet이다. Serializer에 어떤 데이터를 계층 구조로 받아오고 싶은지는 명시되어 있지만, 그 데이터가 꼭 DB Model의 attribute라는 보장은 없고, Serializer는 QuerySet을 통해서 데이터에 접근해서 데이터를 serialize 해주는 역할만 할 뿐이다. 실제로 쿼리를 날리는 QuerySet에 어떤 방식으로 쿼리를 하고싶은지 명시해줘야 한다.

해결 방법 1

첫번째 방법은, 내가 Django에 대해 충분히 이해하지 못했을 때 떠올린 방법이다.

내가 Join을 명시할 수 없다면, 명시하더라도 만약 매번 새롭게 쿼리한다면?

Serializer에 대해 제대로 찾아보지 않은 상태에서 잘못된 방법으로 테스트했던 내 마음속의 불신이었다...

이 때의 나는 내 탓인줄도 모르고 느린 serializer를 탓하며 거의 혐오하기에 이르렀다. 그렇게 생각해낸 것이 바로 Database View를 이용하는 방식이다. View에 미리 원하는 Join된 테이블들을 생성해두고 Meta.managed=False 옵션과 db_table 옵션을 통해 View를 조회에 사용하는 방법이었다.

이는 성능적으로 만족스러웠지만, 아주 커다란 단점이 있었는데, 바로 serialize 된 데이터에 계층구조를 부여할 수 없다는 점이었다. 물론 후처리를 통해서 계층을 부여해줄 수 있었지만, 그것은 데이터가 소규모일때나 할 법한 방법이었고, 먼 미래를 생각하면 매우 적절하지 못한 방법이었다.

솔직히 사용할 필요가 없는 방법이라고 생각하기 때문에, 따로 코드로 설명하지는 않겠다.

해결 방법 2

그렇다면, Join해서 미리 정보를 가져오도록 하려면 어떻게 해야할까?

그럴 때 필요한 함수가 바로 select_related이다. Join이 필요한 경우(쿼리의 시작이 되는 Model과 미리 Join해서 쿼리하고 싶은 연결된 Model이 서로 OneToOne 혹은 ManyToOne 관계일 때) 사용한다.

만약 쿼리의 시작이 되는 Model에 연결된 Model이 역참조를 통해 여러 개의 instance를 가지고 있거나(OneToMany), 애초에 ManyToMany의 관계를 갖는 경우, 조금 다른 방식으로 쿼리하게 된다. 이 때 사용하는 것이 prefetch_related이다.

select_related는 Join을 사용해 쿼리하지만(한 번), prefetch_related는 1차적으로 모델 쿼리를 한 후, 거기에 있는 연결된 다음 Model의 id 값들을 리스트로 만들어 IN 쿼리를 더 날린다(prefetch를 요청한 field 수만큼).

Join으로 쿼리해서 얻게되는 중복된 Model object들을 피하기 위함이라고 생각한다.

django.db.models.QuerySet()._result_cachedjango.db.connection.queries 를 통해 DB connection에서 이루어지는 쿼리를 추적하고 cache 또한 확인할 수 있다. 직접 select_relatedprefetch_related가 잘 작동하는지 확인해보기 바란다.
시간이 나면 확인하는 부분에 대한 코드 및 실행 내용을 업데이트 해볼 예정이다.

그래서 어떻게 구현했는데?

각 Serializer마다 같은 이름의 메소드를 두었다. setup_preloading() 메소드를 Serializer를 구현할 때 field에 맞게 작성해두고, 그 때 그 때 아래와 같은 코드로 사용한다.

class UserProfileSeiralizer(serializers.ModelSerializer):
    user = CustomUserSerializer(is_relation=True, read_only=True)
    class Meta:
        model = UserProfile
        fields = "__all__"
    
    @classmethod
    def setup_preloading(cls, queryset):
        return queryset.select_related("user")

>>> user_profile_queryset = UserProfile.objects.all()
>>> user_profile_queryset = UserProfileSerializer.setup_preloading(user_profile_queryset)
>>> serializer = UserProfileSerializer(user_profile_queryset,many=True)
>>> serializer.data

맺음말

첫 글이라 조금은 두서없게 내용을 늘어놓은 것 같다.
Django를 사용하면 할수록, document를 정독하지 않고, 필요한 것만 체리피킹해가며 공부한 것에 대한 대가를 치르는 중임을 느낀다. ㅠㅠ
다음 포스트는 아마.. Proxy Model에 대한 내용으로 작성할 것 같다.(다른 내용일수도..)

혹시라도 잘못된 내용이 있거나 질문이 있다면 댓글로 말씀해주시면 감사하겠습니다 ! (__)

profile
💻😊

관심 있을 만한 포스트

0개의 댓글