본문 바로가기

python & django & scipy

[django] ModelSerializer test code

 

문제 상황

회사에서 리팩토링 작업을 위해 새로운 프로젝트를 파고, dto와 serializer를 어떻게 구성, 명명, 사용할 것인지에 대한 논의를 하고 있는 중이다.

 

문제 상황은 아래와 같았다. 

  • related field 때문에 항상 다른 serializer를 만들어야 한다. (재사용성이 떨어진다)
  • 어떤 serializer가 어디에서 사용되고 있는지 파악하기 어려워 수정, 제거가 불가능하고 매번 새로 생성한다.

사실 매번 serializer를 생성하는 것 자체는 문제가 아니었다. api별로 필요한 field와 related_field가 다르기 때문에 매번 다르고, 생성해야하는 것은 당연하다.

하지만 재사용성을 충분히 높일 수 있음에도 불구하고 명명 규칙도 제대로 정해지지 않은 상황은 문제이다.

그 과정에서 related_field가 아닌 모든 field를 담고 있는 basic serializer를 만들고, 그 serializer를 상속 받아 확장해 related_field를 추가하기로 했다. 

 

사용하기로 결정한 모든 Model의 basic serializer를 만들고 나서 생각하니 사람이 일일이 타이핑했기 때문에 오타나 누락이 있을 수 있다. (왜 진작에 생성 작업을 자동화하지 않았을까)

 

해결

테스트 코드를 만들었다. Model의 related_field를 제외한 모든 field와 serializer의 field를 비교하는 단순한 코드이다. 

 

 

단계 0. 설정

TestApp/models.py (django app)

class A(models.Model):
    name = models.CharField(max_length=20)
    price = models.IntegerField()
    
class B(models.Model):
    ...

TestApp/serializers.py

 

class ASerializer(serializers.ModelSerializer):
    class Meta:
        model = A
        fields = ('id', 'name', 'price',)

 

단계 1. model과 serializer의 field를 불러오기

serializer의 field를 불러오는 것은 쉽다. ModelSerializer의 Meta class에 정의해놓은 fields를 그냥 가져오면 된다.

serializer_fields = ASerializer.Meta.fields

 

model serializer의 field는 get_fields()라는 method로 불러올 수 있는데, related는 불러오면 안된다. 

다행히 Model class에는 여러 메소드와 property가 존재한다.

a_model_fields = [f.name for f in filter(lambda f: f.related_model is None, \
                                                   [f for f in A._meta.get_fields()])]

get_fields method로 field를 list로 불러올 때 related_model값이 None인 field만 가져오면 기본 field만 가져올 수 있다.

 

 

단계 2. 비교하기

model의 field를 기준으로 serializer를 비교하는 것이기 때문에 가져온 field들을 비교하면 된다.

if set(a_model_fields) != set(serializer_fields):
    # 잘못됨. Exception raise, print 등

 

단계 3. django app에 있는 Model class 모두 불러오기

모든 django app에 있는 models.py의 Model class들을 하나하나 list로 불러오는 것은 무리라고 생각했다. 

TestApp이라는 django app의 models.py에 있는 모든 클래스를 불러오기 위해서

models.py를 참조한다.

python의 dir() 내장함수를 사용하면 어떤 객체를 인자로 넣어주면 해당 객체가 어떤 변수와 메소드(method)를 가지고 있는지 나열해준다.

 

attributes = dir(TestApp.models) # [A, B, ...]

 

models.py에 정의된 attribute는 모델 클래스밖에 없긴하지만, class인지 한 번 더 확인한다.

inspect의 isclass method를 활용한다.

https://docs.python.org/3/library/inspect.html

 

inspect — Inspect live objects

Source code: Lib/inspect.py The inspect module provides several useful functions to help get information about live objects such as modules, classes, methods, functions, tracebacks, frame objects, ...

docs.python.org

 

from inspect import isclass

for attribute_name in dir(TestApp.models):
    attribute = getattr(TestApp, attribute_name)
    if isclass(attribute):
        # serializer field 검사
    else:
        continue

근데 이 때 class라고 해서 모두 가져오면 안된다는 걸 깨달았다... dir로 불러오면 foriegn key로 참조한 외부 app 의 attribute까지 불러오기 때문이다.

 

불러온 class attribute가 TestApp에 정의되어있는 것인지 확인하기위해 path로 비교했다.

from inspect import isclass

for attribute_name in dir(TestApp.models):
    attribute = getattr(TestApp, attribute_name)
    if isclass(attribute):
        app_name = TestApp.__path__[0].split('/')[-1]
        if not app_class._meta.app_label == app_name:
            continue
        # serializer field 비교
    else:
        continue

 

_meta가 없는 경우(Model이 아닌 경우)도 확인해준다.

from inspect import isclass

for attribute_name in dir(TestApp.models):
    attribute = getattr(TestApp, attribute_name)
    if getattr(attribute, '_meta', None) is None:
        continue
    if isclass(attribute):
        app_name = TestApp.__path__[0].split('/')[-1]
        if not app_class._meta.app_label == app_name:
            continue
        # serializer field 비교
    else:
        continue

 

단계 4. 모듈화

App 이름도 하나하나 바꿔주기 귀찮으니 list로 선언해놓고 모듈화 시킨다.

완성된 코드는 아래와 같다. 

 

from inspect import isclass

def get_model_name_ls(app):
    model_name_ls = []
    for x in dir(app.models):
        app_class = getattr(app.models, x)
        if not isclass(app_class):
            continue
        if getattr(app_class, '_meta', None) is None:
            continue
        app_name = app.__path__[0].split('/')[-1]
        if not app_class._meta.app_label == app_name:
            continue
        if app_class._meta.abstract == True:
            continue
        model_name_ls.append(x)

    return model_name_ls
    
def test_model_serializer(app, model_name_ls):
    
    for model_name in model_name_ls:
        
        try:
            model_class = getattr(app.models, model_name, None)
            serializer_class = getattr(app.serializers.basic_serializers, f'{model_name}Serializer', None)

            instances = model_class.objects.all()[:10]
            serializer = serializer_class(instances, many=True)
            try:
                serializer.data
            except Exception as e:
                print(f'{model_name}Serializer field 이름 잘못됨')
                print(e)

            model_fields = [f.name for f in filter(lambda f: f.related_model is None, \
                                                   [f for f in model_class._meta.get_fields()])]
            serializer_fields = serializer_class.Meta.fields


            if set(model_fields) != set(serializer_fields):
                diff_at_model = set(model_fields).difference(set(serializer_fields))
                diff_at_serializer = set(serializer_fields).difference(set(model_fields))
                print(f'{model_name} model, serializer 간의 필드가 맞지 않음 \n    model: ', 
                      diff_at_model, '\n    serializer', diff_at_serializer)
        except Exception as e:
            print(model_name)

 

사용

import TestApp, TestApp2, TestApp3

for app in [TestApp, TestApp2, TestApp3]:
    model_name_ls = get_model_name_ls(app)
    test_model_serializer(app, model_name_ls)

 

완료!

 

결론

django와 python 공부를 더 해야겠다고 느낀 경험이었다. 

원래 잘 쓰지 않는 property나 내장 함수를 찾아볼 때 당연히 나같은 고민을 한 사람이 있지않을까? 그 전에 이미 구현되어있는 내장함수나 property가 있지 않을까? 하면서 찾아보긴 하는데 너무 단발적이어서 휘발되는 게 문제인 것 같다. 

애초에 생성부터 자동화했으면 이런 고생을 안해도 됐지만....

project의 model field나, 새로운 model이 추가되는 등 변화가 있을 때마다 test code를 돌려보면서 검증할 수 있게 되었다.

다음에는 field의 option 값도 비교하는 것도 필요하게되면 짜봐야겠다.