ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Django 에서 Generic ForeignKey 를 사용을 권장하지 않는 이유
    기술/Django 2021. 9. 24. 18:25

    다형성을 효율적으로 구현하기 위한 설계로 GenericForeignKey 를 찾게됩니다.

    하지만 GenericForeignKey 를 사용하지 않아야할 이유와 대안에 대해서 이야기드리고자 합니다.

    GenericForeignKey 란?

    한 특정 모델과 ForeignKey 를 연결해주는 것이 아니라 모든 모델과 범용적으로 ForeignKey 를 연결해주는 것이 GFK(GenericForeignKey) 입니다.

    즉, ForeginKey 를 어떤 모델에나 연결하고싶다는 이야기입니다.

    구현 방식

    Django 에는 ContentType 이라는 모델이 있습니다.

    이 모델은 DB 에 생성된 각 모델들의 정보를 가지고 있으며, 이 정보를 이용하여 여러 모델과 연결고리를 만들어 주는 것입니다.

     

    세 필드를 가지고 GenericRelation 을 구현합니다.

    •  content_type: 어떤 모델인지를 가리키는 ForeignKey
    • object_id: 모델의 어떤 row 인지를 가리키는 ID 값
    • content_object: GenericRelation 임을 알리는 필드
    class TaggedItem(models.Model):
      tag = models.SlugField()
      content_type = models.ForeignKey(ContentType)
      object_id = models.PositiveIntegerField()
      content_object = GenericForeignKey('content_type', 'object_id')

    GFK 를 언제 사용하나요?

    사용해도 좋은 경우

    • DB 행의 변경사항이 별도의 테이블에서 추적되는 감사 모델
    • 범용 TAG APP
    • 실제로 어떤 모델 또는 많은 모델을 알지 못하기 때문에 실제 대안이 없는 설계

    사용하기 애매한 경우

    • 주어진 모델의 각 객체를 알려진 다른 모델 집합 중 하나만 연결해야하는 경우
    • 모델이 다른 모델과 관련이 있도록 설계된 일반 앱을 개발중이지만 아직 어떤 모델인지 모르는 경우

    주로 GenreicForeignKey 주어진 모델의 각 객체를 알려진 다른 모델 집합 중 하나만 연결해야하는 경우 에 찾게됩니다.

     

    아래 예는 Task 는 Group 또는 Person 이 가질 수 있습니다. 이 때 GFK 를 사용하는 예 입니다.

    class Person(models.Model):
        name = models.CharField()
    
    
    class Group(models.Model):
        name = models.CharField()
        creator = models.ForeignKey(Person)  # 나중의 예에서 사용됩니다.
    
    
    class Task(models.Model):
        description = models.CharField(max_length=200)
    
        # owner_id and owner_type 는 GFK 를 위해 사용됩니다.
        owner_id = models.PositiveIntegerField()
        owner_type = models.ForeignKey(ContentType, on_delete=models.PROTECT)
    
        # Group 또는 Person 이 오기를 기대합니다.
        # 나중에 다른 소유자가 있을 수 있을 수 있습니다.
        owner = GenericForeignKey('owner_type', 'owner_id')

    이 경우 Task.owner 에는 Group, Person 두 옵션이 있지만 모든 모델에 적용될 수 있습니다.

    하지만 알려진 특정 모델 중 하나를 연결해야하는 패턴은 권장되지 않는 패턴이며 이유는 다음과 같습니다.

    GFK 를 사용하지 않아야하는 이유

    Database 의 디자인이 나빠집니다.

    데이터베이스에 들어있는 데이터들은 Django 가 아니더라도 다른 응용프로그램에 의해 사용할 수 있습니다.

    그렇기 때문에 데이터베이스의 컬럼들은 문서로 잘 남겨져 있어야하거나 자체적으로 의미를 가져야합니다.

    일반적으로 Django 가 생성하는 테이블, 컬럼, 제약 조건들은 자체적으로 설명가능하게 하지만 GFK 는 데이터베이스 자체적으로만으론 알 수 없는 필드로 기록됩니다.

    Referential Integirty

    더 중요한 이유는 참조 무결성이 깨지는 것입니다. GFK 는 단순히 IntegerField 로 모델에 연결된 값을 저장하기 때문에 연결된 값이 실제로 존재하는지, on_delete 같은 전략을 사용할 수 없습니다.

    Performance

    다른 주요한 이슈는 성능에 있습니다.

    GFK 를 사용하면 ContentType 테이블을 한번 더 거치기 때문에 일반적인 ForeignKey 동작보다는 비쌉니다.

    또한 연결된 객체들을 한번에 가져오지 못하고 각각 쿼리가 여러번 발생합게 됩니다.

     

    select_related 는 어떤 테이블을 선택해야하는지 모르므로 사용하지 못하고, 

    prefetch_related 만이 선택적으로 사용가능합니다.

    Task.objects.all().prefetch_related('owner')

    Django 는 prefetch_related 를 통해 많은 수의 쿼리를 줄이지만, GFK 의 경우 없는 필드를 prefetch 하려는 경우에는 실패합니다.

    # Person 모델 에는 없지만, Group 모델 에는 존재합니다.
    # 모든 모델에 존재하는 것이 아니므로 에러가 발생합니다.
    Task.objects.all().prefetch_related('owner__creator')

    Django Code

    Django ORM 으로 사용한 필터는 제대로 동작하지 않습니다. 위에서 owner__creator 를 prefetch 시도했던 것처럼 filter 를 시도하게 되면 에러를 발생합니다. 이를 해결하기 위해서는 다른 방법으로 시도해야합니다.

    if isinstance(task.owner, Group):
      pass
    else:
      pass

    이런 코드들은 결국 더 나쁜 코드로 만들어진다고 생각합니다. GFK 로 사용한 모델이 모두 동일한 인터페이스가 아닌 이상 모델을 분기하지 않고 제어할 수 없게 됩니다.

    대안

    연결 가능한 모델을 모두 명시

    가장 단순한 솔루션입니다. owner 에 들어갈 수 있는 모든 ForeignKey 를 nullable 하게 정의하고, Django 단에서 하나만 채워질 수 있도록 제어합니다.

    class Task(models.Model):
        owner_group = models.ForeignKey(Group, null=True, blank=True, on_delete=models.CASCADE)
        owner_person = models.ForeignKey(Person, null=True, blank=True, on_delete=models.CASCADE)

    다형성의 동작을 위해 하나의 owner 를 세팅하고 가져오는 동작을 가지는 owner 프로퍼티를 만듭니다. 이렇게 작성하게되면 데이터베이스 수준에서 수정하지 않는 이상 owner 두 필드 모두를 사용할 가능성은 없습니다.

    @property
    def owner(self):
        if self.owner_group_id is not None:
            return self.owner_group
        if self.owner_person_id is not None:
            return self.owner_person
        raise AssertionError("Neither 'owner_group' nor 'owner_person' is set")

    연결 가능한 모델을 중간 테이블에 명시

    source 모델을 깔끔하게 관리하기 위해 중간 테이블을 명시하는 방법입니다.

    class Owner(models.Model):
        group = models.OneToOneField(Group, null=True, blank=True, on_delete=models.CASCADE)
        person = models.OneToOneField(Person, null=True, blank=True, on_delete=models.CASCADE)
    
    
    class Task(models.Model):
        owner = models.ForeignKey(Owner, on_delete=models.CASCADE)

    Owner 를 추상화하고 이전 방법과는 다르게 단일 책임의 원칙을 더 잘 지킨 코드라고 생각합니다. owner 를 깔끔하게 관리할 수 있는 장점이 있습니다. 하지만 테이블이 늘어나 Join 이 늘어난다는 단점도 있습니다.

    참고

    원글: https://lukeplant.me.uk/blog/posts/avoid-django-genericforeignkey/

    댓글

Designed by Tistory.