Django Proxy Model 을 활용한 방법

Jeonghoon·2022년 12월 16일
0

django

목록 보기
3/3
post-thumbnail

일단 프록시 모델을 만드는 법 부터 설명을 합니다.

프록시 모델을 만드는 방법

# models.py
from django.db import models
from django.utils import timezone

# 기본으로 상속받는 모델
class BaseModel(models.Model):
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)
    deleted = models.DateTimeField(null=True, blank=True)

    class Meta:
        abstract = True


# 원본이 될 모델
class Item(BaseModel):
    name = models.CharField(max_length=50)
    price = models.IntegerField()


# 프록시 모델에 쓰일 매니저(소프트 딜리트가 되지 않은 항목만 보여준다)
class ActivatedManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(deleted__isnull=True)


# 프록시 모델(해당 모델은 활성화된 항목만 보여준다.)
class ActivatedItem(Item):
    objects = ActivatedManager()

    class Meta:
        proxy = True # 해당 부분으로 설정이 가능하다.

내가 Django 에서 Proxy Model 을 쓰는 이유

솔직히 말하면 Django 에서 직면하는 문제 중에 Proxy Model 을 안쓰고 하는 방식도 가능하다.

그런데 왜 Proxy Model 을 쓰는가?

라는 질문에 대한 답은 프록시 모델을 만들기가 어렵지도 않고 노력에 비해 얻어지는 이점이 크다.

내가 생각하는 이점은 아래와 같다.

  1. Proxy Model Class 이름만으로도 무엇을 위해 존재하는 모델인지 유추가 가능해진다.

  2. Proxy Model Class 에 맞는 역할 동작이 가능해진다.

  3. Django의 전체적인 Flow에 융합이 잘 되어진다.

이름만으로 유추가 가능하다.

Item # 이렇게 클래스 이름만으로는 항목이라는 정보밖에 떠오르지 않는다.

# 이렇게 뒤에 속성이 결국 어떠한 항목인지를 나타낸다.
Item.objects.filter(deleted__isnull=True) # deleted__isnull 이 True니까 활성화된 항목
Item.objects.filter(deleted__isnull=False) # deleted__isnull 이 False니까 비활성화된 항목

위에 나온 방식이 틀린가? 라고 했을 때 틀리지 않았다. 하지만 클래스명으로만 봤을 때는 정보가 빈약하다.

저 Item이라는 모델이 일차적으로 구분되는 곳은 뒤에 붙은 filter 가 나올 때 이다.
그전에는 그냥 큰 항목으로 판단하게 된다.

예시로 들면 아무 사람을 데리고 와서 뭐하는 사람인지 맞춰봐라 하는 듯한 느낌이다.

어떠한 필드로 명확하게 구분이 가능하다면 프록시 모델을 쓰면 더욱 직관적으로 변하게 된다.
지금의 필드는 deleted 이다.

Item # 항목 (원본)
ActivatedItem # 활성화된 항목
DeletedItem # 비활성화된 항목(삭제된)

위의 코드를 보면 구현 코드는 없지만 해당 클래스명만 보고도 어떠한 항목인지 알 수가 있다.
나는 deleted를 기준으로 값이 있는지 없는지로 나누어서 프록시 모델을 만들었다.

처음부터 아무 사람을 모으는 것이 아니고
소방관 복장을 하고 있는 사람을 데리고 와서 뭐하는 사람인지 물어보는 것이다.
대부분 소방관이라고 답한다. 반골 기질이 있는 사람 빼고 그런 예외는 무시한다.

###class 에 맞는 영역만 동작

프록시에 대한 설명을 하는 것이다보니 예시는 두가지 경우가 존재한다.

  1. Item 모델 하나로 처리하는 경우

  2. Item 모델을 통해서 프록시 모델을 생성한 경우

# 첫번째 경우
class Item(BaseModel):
	...
	def recover(self):
		self.deleted = None
		self.save()
	
	def soft_delete(self):
		self.deleted = timezone.now()
		sef.save()

	def delete(self):
		if self.deleted:
			return super().delete()
		else:
			self.soft_delete()


# 두번째 경우
class Item(BaseModel):
	...

class ActivateItem(Item):
	...
	class Meta:
		proxy = True

	def soft_delete(self):
		self.deleted = timezone.now()
		sef.save()	

	def delete(self):
		self.soft_delete()
	
	

class DeletedItem(Item):
	...
	class Meta:
		proxy = True
	
	def recover(self):
		self.deleted = None
		self.save()

첫번째의 경우 내가 생각했을 때의 애매한 점? 은 두 가지이다.

  1. 적합하지 않은 recover()
    적합하지 않다고 표현한 이유는 recover() 의 목적은 deleted 에 값이 있는 얘들을 None으로 만들어준 것이다.
    하지만 해당 recover() 는 코드만으로 봤을 때 deleted 의 값에 상관없이 None 으로 처리할 것 이다.

  2. delete() 의 동작이 두 가지
    deleted 에는 두 가지의 경우가 존재한다.
    소프트 딜리트가 되지 않아서 아직 deleted 가 None 인 경우,
    이미 소프트 딜리트가 되어서 deleted 에 값이 들어간 경우

그리고 delete() 여기에 맞추어서 동작을 한다.
소프트 딜리트가 안된 경우에는 deleted 에 현재의 시간을 넣어서 저장을 하고
이미 소프트 딜리트가 된 경우에는 하드 딜리트를 한다.
내가 생각하는 애매한 점은 여기이다. 딜리트가 결국 두 가지 동작을 하게 되어 결과 예측이 어렵다.

슈뢰딩거의 고양이하고도 똑같다 관측이전에는 두 가지 상태의 중첩이다.
소프트 딜리트가 되었는지 하드 딜리트가 되었는지 db에 접속해서 확인해야 결과가 확정된다.

그래서 여기에서 취하는 방식이 프록시 모델을 통해서 분리를 시킨다.

  1. ActivatedItem 은 delete 만 오버라이드를 통해서 동작 방식을 변경. (하드 딜리트 → 소프트 딜리트)
  2. DeletedItem recover 만 추가해서 복원 기능 추가

그러면 이렇게 해서 뭐가 좋아졌냐인데

쓸데없이 deleted 가 None 인 얘들한테 recover 가 쓰일 일이 없다.

delete 가 정확하게 어떠한 결과 값을 도출할 지 알게 된다.

이제는 살아있는 사람들에게 부활이라는 쓸모없는 행동을 안해도 되고
죽음의 방식은 사회적 죽음 이후에 물리적인 죽음으로 진행하는 것으로 변경되었다.

흐름에 적합하다

# views.py
class RecoverMixin:
    @action(methods=['POST'], detail=True)
    def recover(self, request, pk=None, *args, **kwargs):
        instance = self.get_object()
        self.perform_recover(instance)
        return Response(status=status.HTTP_200_OK)

    @staticmethod
    def perform_recover(instance):
        instance.recover()


# 첫번째 경우
class ItemView(RecoverMixin, ModelViewSet):
    queryset = Item.objects.all()
    serializer_class = ItemSerializer
		
		def list(self, request, *args, **kwargs):
			deleted = self.request.query_params.get('deleted', False)
			self.queryset = self.queryset.filter(deleted__isnull=deleted)
			return super().list(request, *args, **kwargs)
		
		
# 두번째 경우
class ActivatedItemView(ModelViewSet):
    queryset = ActivatedItem.objects.all() # Item.object.filter(deleted__isnull=True)
    serializer_class = ActivatedItemSerializer


class DeletedItemView(RecoverMixin, ModelViewSet):
    queryset = DeletedItem.objects.all() # Item.object.filter(deleted__isnull=False)
    serializer_class = DeletedItemSerializer

세번째는 두번째의 연장이다.

먼저 흐름은 urls ⇒ views ⇒ serializers ⇒ response 이다.

첫번째의 경우에는 list를 오버라이드 하고 query 파라미터를 통해서 filter()를 한다. 약간의 흐름제어가 있다.

[activatedItemUrl] : /item?deleted=False
[deletedItemUrl] : /item?deleted=True

두번째의 경우에는 list를 오버라이드 하지않아도 올바르게 동작을 한다.

[activatedItemUrl] : /activated-item
[deletedItemUrl] : /deleted-item

추가적으로 한 것은 RecoverMixin 을 통해서 deleted 된 항목에만 사용할 수 있도록 recover action을 실행하게 만들어줬다.

참고로 recover는 django restframework의 DeleteMixin을 참고해서 만들어졌다.

내가 생각할 수 있는 부분도 두번째의 연장이다 결과 값 예측이 어렵고
목적에 맞게 사용되지 않는 action들이 존재하게된다.

마지막에는 기능적인 추가에 의해 코드가 더러워질 확률이 크다.
현재의 list는 deleted 필드만 filter를 하다보니 간편하다.
하지만 필드가 늘어나면 늘어날 수록 저기에서 담당하는 비중이 커지게 되고 보기가 어려워질 확률이 크다.

물론 그것만을 위해서 존재하는 것은 아니지만 django-filter 라는 게 있기도 하다.

django-filter 를 보니 생각나는 것이 있는데
예전에 모든 api가 filter가 안 걸린체 동작을 해서 디버그 하는데 애를 먹은 적이 있다.
그때 문제의 원인은 어떤 사람이 해당 프로젝트의 패키지를 최신으로 업데이트했고
dev → stage → main 이렇게 단계적으로 올린 것이 아니고 각 브랜치에에 직접적으로 올렸다.
그때 django-filter 가 업데이트 된 버전에서는 약간의 동작 방식이 변경이 되어서 수정이 필요했었다.
하지만 해당 사람은 그것을 확인 안하고 올렸기에 당연히 filter는 안 걸렸고 신기하게 500 도 안 떴다.
그러다보니 오히려 언제부터 해당하는 오류가 언제부터 있었는지 파악이 어려웠고
filter가 안 먹는다는 것을 알았기에 vcs에서 filter 버전이 업데이트 된 시점으로 파악해서
해당 오류가 발생했을 시점과 문제점을 알았다.
이때 가장 먼저 오류를 발견했는데 그때가 dev에서 확인을 했고 merge 가 어떻게 된거지 하고 생각을 하고 있었는데
merge request(pull request) 가 필요없는 사람이 그냥 올린거라서 할 말이 없었다.
만약에 이때 Proxy 모델을 썼으면 최소한의 안전장치는 되지 않았을까 생각한다.

그외...

주로 프록시 모델을 사용하면서 같이 사용했던 Manager 가 있다.

하지만 해당 부분을 더욱 더 유용하게 쓰려면 QuerySet 을 쓰는 것도 추천한다.
Django admin 에서 action 중 선택해서 삭제하는 액션이 있는데 해당 기능을 ActivateItem 에 사용하면
원하는 결과는 소프트 딜리트이지만 실제 결과는 하드딜리트가 된다.

이유는 벌크로 딜리트 하는 부분을 오버라이드 하지 않았기 때문이다.
해당 부분을 해결하기 위해서는 Manager에서 사용하는 Queryset의 delete를 오버라이드 해서 우리가 원하는 방향으로 변경할 필요가 있다.

class Item(BaseModel):
    name = models.CharField(max_length=50)
    price = models.IntegerField()


class ActivatedQuerySet(models.QuerySet):
    def delete(self):
        self.update(deleted=timezone.now())


# 첫번째 방법
class ActivatedManager(models.Manager):
    def get_queryset(self):
        return ActivatedQuerySet(self.model).filter(deleted__isnull=True)


class ActivatedItem(ActivatedActionMixin, Item):
    objects = ActivatedManager()

    class Meta:
        proxy = True


# 두번째 방법
class ActivatedManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(deleted__isnull=True)


class ActivatedItem(ActivatedActionMixin, Item):
    objects = ActivatedManager.from_queryset(ActivatedQuerySet)()

    class Meta:
        proxy = True

첫번째 방법과 두번째 방법이 존재한다.

개인적으로는 두번째 방법이 선호될 것 같다.
첫번째 방식은 확정이고 두번째 방식은 옵션이다.

첫번째의 ActivatedManager는 이미 코드 상에 알 수 있듯이 ActivatedQuerySet 이라는 의존이 생겼다.

두번째의 ActivatedManager는 의존 관계가 없다.
그냥 get_queryset을 통해서 주입받은 QuerySet을 쓰면 된다.

현재의 경우는 from_queryset을 통해 받은 ActivatedQuerySet 이다.

여태까지 나온 전체의 코드도 적은 양이기는 하지만 개선의 여지는 다분하다.

이유는 뭔가 설명하기 편하게 하기 위해서 한 것이기는 한데 오히려 아는 사람들에게는 방해요소가 될 수도 있겠다.

profile
개발중

0개의 댓글