CustomOneToOneField가 왜 필요 했는지, 그리고 어떤 특성을 가지고 있는지 알아보기 전에 OneToOneField가 무엇인지 잠시 알아보자.
unique=True
속성을 가진 ForeignKey라 볼 수 있다.related_name
을 설정하지 않는다면 장고는 해당 모델 이름의 소문자를 related_name
으로 간주한다.related_name
은 user
가 되는 것이다.아래의 예시는 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
을 설정해 주어야 한다.
그러면 조회시 아래와 같은 결과를 보인다.
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
Model.DoesNotExist
에러를 상속 받아 만들어진 에러 케이스이다.아래 예시는 Django Docs에서 가져온 위의 예시에 대해 related_object를 조회했을 때 RelatedObjectDoesNotExist가 발생하는 케이스이다.
supervisor_of
가 존재하지 않는 경우에 에러가 발생한다.>>> user.supervisor_of
Traceback (most recent call last):
...
RelatedObjectDoesNotExist: User has no supervisor_of.
이제 본론으로 돌아가, 이 글을 작성한 이유를 설명해 보려 한다. 바로 CustomOneToOneField이다.
위에서 RelatedObjectDoesNotExist가 발생하는 케이스를 확인해 보았다. 즉, related_object가 존재하지 않는 케이스이다. 이를 위해 OneToOneField를 조금 더 뜯어보자.
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
rel_obj
를 None으로 만들지 않고 self.related.field.name
를 이용해 related object의 소문자 이름을 가져 왔다.field_args = {self.related.field.name: instance}
를 정의해 이를 이용해 related_model을 생성하도록 변경했다.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
참고