Django의 OneToOneField를 커스터마이징 한 CustomOneToOneField

Jihun Kim·2022년 8월 15일
0

기타

목록 보기
11/12

CustomOneToOneField가 왜 필요 했는지, 그리고 어떤 특성을 가지고 있는지 알아보기 전에 OneToOneField가 무엇인지 잠시 알아보자.


OneToOneField

  • OneToOneField는 장고에서 사용되는 개념적인 필드이다.
  • OneToOneField는 unique=True 속성을 가진 ForeignKey라 볼 수 있다.
    - 그러나 ForeignKey와는 달리 reverse relation을 조회하면 하나의 객체만 조회할 수 있다.
  • OneToOneField는 ForiegnKey에서 사용하는 모든 extra arguments를 사용할 수 있다.
  • 해당 모델이 related 될 클래스를 위치 인자로 추가해야 한다.
    - 이 때 만약 related될 클래스의 related_name을 설정하지 않는다면 장고는 해당 모델 이름의 소문자를 related_name으로 간주한다.
    - 예를 들어, OneToOneField로 User를 추가 한다면 related_nameuser가 되는 것이다.

아래의 예시는 Django Docs에서 가져왔다.

from django.db import models

class MySpecialUser(models.Model):
    user = models.OneToOneField(
        User,
        on_delete=models.CASCADE,
    )
    supervisor = models.OneToOneField(
        User,
        on_delete=models.CASCADE,
        related_name='supervisor_of',
    )

이 경우 MySpecialUser는 user, supervisor 필드가 모두 User 테이블에 대해 OneToOne 관계를 갖는다. 이렇게 두 필드가 모두 같은 테이블에 관계를 가지고 있을 때는 어느 한 쪽은 꼭 related_name을 설정해 주어야 한다.

그러면 조회시 아래와 같은 결과를 보인다.

  • User 모델은 myspecialuser(related_name을 지정하지 않았기 때문에 user 필드는 MySpecialUser의 소문자를 related_name으로 갖는다)와 supervisor_of를 모두 attribute로 갖는다.
>>> user = User.objects.get(pk=1)
>>> hasattr(user, 'myspecialuser')
True
>>> hasattr(user, 'supervisor_of')
True

RelatedObjectDoesNotExist

  • RelatedObjectDoesNotExist 에러는 reverse relationship에 대한 액세스를 할 때 related 테이블에 데이터가 존재하지 않으면 발생하는 에러이다.
  • Model.DoesNotExist 에러를 상속 받아 만들어진 에러 케이스이다.

아래 예시는 Django Docs에서 가져온 위의 예시에 대해 related_object를 조회했을 때 RelatedObjectDoesNotExist가 발생하는 케이스이다.

  • User 모델의 related_object인 supervisor_of가 존재하지 않는 경우에 에러가 발생한다.
>>> user.supervisor_of
Traceback (most recent call last):
    ...
RelatedObjectDoesNotExist: User has no supervisor_of.


이제 본론으로 돌아가, 이 글을 작성한 이유를 설명해 보려 한다. 바로 CustomOneToOneField이다.
위에서 RelatedObjectDoesNotExist가 발생하는 케이스를 확인해 보았다. 즉, related_object가 존재하지 않는 케이스이다. 이를 위해 OneToOneField를 조금 더 뜯어보자.



CustomOneToOneField

CustomOneToOneField를 만들게 된 계기는 OneToOneField를 조회할 때 에러를 발생시키지 말고 get_or_create처럼 해당 object를 생성하도록 하는 것이 어떨까 하는 생각 때문이었다.


그런데 이게 굳이 왜 필요할까?

최근에 개발 하면서 User 모델에 one to one으로 연결 되어야 하는 테이블을 추가해야 하는 상황이 발생한 적이 있다. 예를 들자면, 서비스에서 기존에는 유저에게 포인트를 지급하지 않았는데 이제는 포인트를 쌓는 시스템이 추가 되어야 한다면 유저 별로 포인트 계좌 테이블이 필요할 것이다. 포인트 계좌를 한 유저가 여러 개 갖고 있는 상황이 발생하면 안되기 때문에 User와 포인트 계좌는 one to one 관계가 되어야 한다.

이 때, 배포시에 마이그레이션을 해주어야 하는데 이 과정에서 모든 유저 별 계좌를 생성해 주어야 하며 무중단 배포시 계좌를 생성하는 과정에서 새로 가입한 유저들의 계좌가 생성되지 않은 것은 없는지 확인해야 한다.

이러한 번거로움을 해결하기 위해 CustomOneToOneField를 만들었다.
이를 위해 OneToOneField를 이용해 related object가 조회되는 과정을 디버거로 뜯어 보았다.

OneToOneField는 내부적으로 related_accessor_class 라는 클래스 변수에 ReverseOneToOneDescriptor를 정의해 놓는데 이 안의 __get__ 메소드를 이용해 related object를 조회한다.


기존의 ReverseOneToOneDescriptor


class ReverseOneToOneDescriptor:
	def __init__(self):
    	...
        
    def __get__(self, instance, cls=None):
        """
        Get the related instance through the reverse relation.

        With the example above, when getting ``place.restaurant``:

        - ``self`` is the descriptor managing the ``restaurant`` attribute
        - ``instance`` is the ``place`` instance
        - ``cls`` is the ``Place`` class (unused)

        Keep in mind that ``Restaurant`` holds the foreign key to ``Place``.
        """
        if instance is None:
            return self

        # The related instance is loaded from the database and then cached
        # by the field on the model instance state. It can also be pre-cached
        # by the forward accessor (ForwardManyToOneDescriptor).
        try:
            rel_obj = self.related.get_cached_value(instance)
        except KeyError:
            related_pk = instance.pk
            if related_pk is None:
                rel_obj = None
            else:
                filter_args = self.related.field.get_forward_related_filter(instance)
                try:
                    rel_obj = self.get_queryset(instance=instance).get(**filter_args)
                except self.related.related_model.DoesNotExist:
                    rel_obj = None
                else:
                    # Set the forward accessor cache on the related object to
                    # the current instance to avoid an extra SQL query if it's
                    # accessed later on.
                    self.related.field.set_cached_value(rel_obj, instance)
            self.related.set_cached_value(instance, rel_obj)

        if rel_obj is None:
            raise self.RelatedObjectDoesNotExist(
                "%s has no %s."
                % (instance.__class__.__name__, self.related.get_accessor_name())
            )
        else:
            return rel_obj

조회시 없으면 생성 되도록 변경한 CustomReverseOneToOneDescriptor

  • ReverseOneToOneDescriptor에서 self.related.related_model.DoesNotExist 에러가 발생할 경우 rel_obj를 None으로 만들지 않고 self.related.field.name를 이용해 related object의 소문자 이름을 가져 왔다.
  • 그 다음, field_args = {self.related.field.name: instance}를 정의해 이를 이용해 related_model을 생성하도록 변경했다.
  • OneToOneField를 상속 받은 CustomOnetoOneField를 선언해 이렇게 만든 CustomReverseOneToOneDescriptor를 related_accessor_class 클래스 변수로 사용하도록 오버라이딩 했다.
class CustomReverseOneToOneDescriptor(ReverseOneToOneDescriptor):
    def __get__(self, instance, cls=None):
        """
        Get the related instance through the reverse relation.

        With the example above, when getting ``place.restaurant``:

        - ``self`` is the descriptor managing the ``restaurant`` attribute
        - ``instance`` is the ``place`` instance
        - ``cls`` is the ``Place`` class (unused)

        Keep in mind that ``Restaurant`` holds the foreign key to ``Place``.
        """
        if instance is None:
            return self

        # The related instance is loaded from the database and then cached
        # by the field on the model instance state. It can also be pre-cached
        # by the forward accessor (ForwardManyToOneDescriptor).
        try:
            rel_obj = self.related.get_cached_value(instance)
        except KeyError:
            related_pk = instance.pk
            if related_pk is None:
                rel_obj = None
            else:
                filter_args = self.related.field.get_forward_related_filter(instance)
                try:
                    rel_obj = self.get_queryset(instance=instance).get(**filter_args)
                except self.related.related_model.DoesNotExist:
                    field_args = {self.related.field.name: instance}
                    rel_obj = self.related.related_model.objects.create(**field_args)
            self.related.set_cached_value(instance, rel_obj)

        return rel_obj


class CustomOnetoOneField(OneToOneField):
    """
    OneToOneField가 없을 경우 조회시 바로 생성할 수 있게 만들어 준다.
    """

    related_accessor_class = CustomReverseOneToOneDescriptor


참고

profile
쿄쿄

0개의 댓글