SW개발/Django

[Django]GenericForeignKey의 문제점 (feat. 안티패턴의 지름길)

GenericForeignKey 란?

https://leffept.tistory.com/358

 

[Django]GenericForeignKey, ContentType로 여러 모델과의 관계 맺기

RDB를 사용하여 모델을 만들다 보면 한 모델이 여러 모델과의 관계를 맺어야 하는 순간이 생기기 마련입니다. 일반적으로 생각한다면 ForeignKey를 이용하여 모델링을 하는 방법이 떠오를 것입니다

leffept.tistory.com

GenericForeignKey와 구현 방법에 대해서는 이전에 다루었기에 해당 포스팅의 링크만 남겨두도록 하겠습니다.

 

우선, 지난번에도 단점을 살짝 언급하였지만 직접 개발을 진행하다보니 돌이킬 수 없는 단점들이 몇몇 보이게 되어 포스팅을 다시 작성하게 되었습니다.

 

Performance 이슈

제가 개발하면서 가장 문제가 되었던 점은 performance 이슈였습니다. Django에는 select_related(), prefetch_related()와 같이 발생하는 쿼리수를 최적화 시켜주는 Eager Loading 옵션들이 존재합니다.

 

하지만, Generic ForeignKey의 경우에는 Eager Loading을 정상적으로 활용할 수가 없습니다.

우선 첫째로, select_related()는 어떤 테이블을 선택해야 하는지 모르기 때문에 사용할 수 없습니다.

둘째로, prefetch_related()는 선택적으로 사용이 가능합니다.

 

저의 경우에는, prefeth_related()에서 reverse relationship을 활용하려고 하였는데 실패하였습니다. 모든 필드에 존재하지 않는 reverse relationship의 경우에는 오류가 발생하게 됩니다. 

이러한 부분 때문에 쿼리셋을 강제로 캐싱하는 로직을 적용하여 문제를 해결하였습니다만, 가독성과 유지보수 등 모든 측면에서 좋지 않은 코드를 생성해낼 수밖에 없었습니다.

(아래에서 좀더 자세하게 다뤄보도록 하겠습니다.)

 


 

문제를 해결하던 중 관련된 좋은 자료를 발견하여, 번역하며 공부해보았습니다.

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

 

Avoid Django's GenericForeignKey

Why Django's GenericForeignKey is (usually) a bad idea

lukeplant.me.uk

 

GenericForeignKey 를 사용해도 좋은 경우 & 아닌 경우

사용해도 좋은 경우

  • DB행에 대한 변경사항이 별도의 테이블에서 추적되는 감사 모델인 경우
  • 범용 TAG APP인 경우
  • 실제로 많은 모델을 알지 못하기 때문에 얼마나 많은 모델을 참조해야하는지 모르는 경우

그러나 위의 내용에 해당하는 경우 이외의 상황에서 GenericForeignKey(이하 GFK)를 사용하는 사람들이 많다고 생각합니다.

사용하기 좋지 않은 경우

  • 주어진 모델의 각 object들을 다른 모델 집합 중에 하나만 연결해야하는 경우
  • 모델이 다른 모델과 관련되도록 설계된 일반적인 앱을 개발중이지만, 아직 어떤 모델일지 모르는 경우

우선, 좋지 않은 경우중 두번째의 상황에 해당하는 예시를 먼저 살펴보겠습니다.

 

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 와 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의 두가지 옵션이 존재하지만 그 이상인 경우에도 아래와 같은 사항들이 대부분 적용됩니다.

 

나쁜 데이터베이스 디자인을 만듭니다.

데이터베이스에 들어있는 데이터들은 Django외의 여러 응용프로그램에 의해서 사용될 수 있습니다. 따라서 데이터베이스의 컬럼들은 잘 문서화 되거나 그 자체적으로 의미를 가지고 있어야 합니다.

하지만 GFK는 데이터베이스 자체적으로만으로는 알 수 없는 필드로 기록됩니다.

 

위의 예제에서 데이터베이스는 다음과 같습니다. (SQLite)

CREATE TABLE "gfks_task" (
    "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
    "description" varchar(200) NOT NULL,
    "owner_id" integer unsigned NOT NULL,
    "owner_type_id" integer NOT NULL REFERENCES "django_content_type" ("id")
);
CREATE INDEX "gfks_task_618598c8"
    ON "gfks_task" ("owner_type_id");

owner_id는 단지 정수일 뿐이며 이것이 무엇을 참조하는지 알아낼 수 있는 명확한 방법이 존재하지 않습니다. 

그나마 owner_type_id는 django_content_type이라는 테이블과 연관이 있다는 것을 알 수 있습니다.

CREATE TABLE "django_content_type" (
    "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
    "app_label" varchar(100) NOT NULL,
    "model" varchar(100) NOT NULL);
)
CREATE UNIQUE INDEX "django_content_type_app_label_76bd3d3b_uniq"
    ON "django_content_type" ("app_label", "model");

데모 앱의 해당 내용(django_content_type)을 살펴보면 다음과 같습니다.

id app_label model
1 admin logentry
2 auth group
3 auth user
4 auth permission
5 contenttypes contenttype
6 sessions session
7 gfks group
8 gfks person
9 gfks task

이 표를 보고 어떻게 동작하는지에 대해 몇가지를 추측해볼 수 있습니다.

  • gfks_task.owner_type_id는 django_content_type의 행을 참조합니다. (명확한 제약조건)
  • 이 행에서 app_label 및 model을 결합하여 밑줄을 추가하면 테이블 이름을 알아낼 수 있습니다.
    if gfks_task.owner_type_id == 8 이라면, gfks_person 테이블을 살펴보면 됩니다.
    * (다만 이 방법은 정확하지 않습니다. 실제로 gfks.models.Person을 import하여 ._meta.db_table 속성을 봐야합니다.)
  • 이제 PK가 owner_id값 과 일치하는 레코드를 조회할 수 있는 테이블 이름이 있습니다.

위의 사항들에 대해 명확하게 언급할 사항들이 있습니다.

  • 위의 방식들은 테이블에 대한 외래키 조회를 수행하는 것보다 훨씬 더 복잡합니다.
  • 위의 메커니즘은 이 데이터를 쿼리하기위해 SQL을 작성하는 것을 어렵게 만듭니다.
    테이블 이름 자체가 계산해야하는 값이 되어 조인 조건이 매우 좋지 않습니다.

그러나 무엇보다도 가장 큰 문제는 데이터베이스 스키마가 데이터를 잘 설명해주지 못한다는 점입니다.

 

참조 무결성이 없습니다.

가장 크고 중요한 문제로 참조 무결성이 존재하지 않습니다.

일반적으로 데이터베이스는 데이터의 일관성과 무결성이 가장 중요합니다. 데이터베이스의 외래키를 사용하는 것 대신에 GFK를 사용함으로써 막대한 손실이 발생합니다.

 

owner_id는 단지 정수일 뿐이므로 실제 데이터를 참조하지 않는 Junk가 있을 수 있습니다. Junk는 필드가 수동으로 편집되거나 참조하는 행이 삭제되거나하는 등 다양한 일로 인해 발생할 수 있습니다.

 

일반적인 외래키를 사용하면 이로한 것들로부터 데이터베이스가 사용자를 보호합니다.

 

성능이 나빠집니다.

다른 중요한 문제는 성능입니다.

generic related된 object를 얻으려면 조회를 여러번 수행해야 합니다.

  1. 메인이 되는 object를 가져옵니다. (ex: Task)
  2. ContentType이 가르키는 object를 가져옵니다. Task.owner_type (이 테이블은 일반적으로 장고에 의해 캐시됩니다.)
  3. ContentType object로부터 모델에 따라서 테이블 이름을 찾을 수 있습니다.
  4. 이 테이블 이름과 메인이 되는 object의 id를 알면 related object를 얻을 수 있습니다.

이러한 절차는 일반 외래키를 사용하는 것보다 복잡하고 비용이 많이 드는 프로세스입니다. 특히 최적화를 할 수 없게 만듭니다.

select_related()의 경우에는 어떤 테이블과 조인을 해야할지 모르는 상태이기 때문에 사용할 수 없습니다.

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

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

장고는 prefetch_related()를 통해서 많은 수의 쿼리를 줄입니다. GFK의 경우 없는 필드에 대해 prefetch를 시도할 경우 오류가 발생합니다.

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

Person 모델에는 없지만 Group 모델에는 존재하는 경우, 모든 모델에 존재하는 것이 아니므로 위의 쿼리는 실패하게 됩니다.

 

Django의 코드가 나빠집니다.

첫째로, Django ORM을 사용한 필터링은 제대로 동작하지 않습니다. 위의 경우와 마찬가지로 filter를 시도하는 경우에도 에러가 발생합니다.

Task.objects.filter(owner__creator=foo)

대신에 아래와 같이 작성하여야 합니다.

group_ct = ContentType.objects.get_for_model(Group)
groups = Group.objects.filter(creator=foo)
tasks = Task.objects.filter(owner_type=group_ct,
                            owner_id__in=groups.values_list('id'))

 

둘째, 다형성 object는 잘 작동하는 경우가 거의 없습니다. 특히 다음과 같은 유형으로 분기처리를 해야하는 경우가 많이 생깁니다.

if isinstance(task.owner, Group):
    # do group things
else:
    # do person things

파이썬 코드에서든 템플릿에서든 더 이상 깔끔해보이지 않습니다. 특히 GFK를 사용한 모델이 모두 동일한 인터페이스가 아니라면 모델을 분기하지 않고서는 제어할 수 없습니다.

 

대안책 1 - 소스 테이블에 연결 가능한 모델을 모두 명시

가장 단순한 해결책입니다. 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 @property를 만듭니다. 이렇게 작성한다면 데이터베이스 수준에서 수정하지 않는 이상 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(" 'owner_group'도 'owner_person'도 설정되지 않았습니다. ")

 

대안책 2 - 중간 테이블을 통해 연결 가능한 모델을 명시

대안책 1의 nullable FK를 새로운 테이블을 생성하고 1:1 필드로 전환하여 Task 테이블에 nullable이 아닌 FK를 만듭니다.

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이 늘어난다는 단점도 존재합니다.

 

대안책 3 - 대상 테이블에 OneToOneFields가 있는 중간 테이블

대안책 2와 비슷하지만, OneToOneField를 활용해 대상 테이블로 Owner 테이블을 FK 합니다. (더 이상 nullable일 필요가 없습니다.)

class Owner(models.Model):
    pass


class Person(models.Model):
    name = models.CharField()
    owner = models.OneToOneField(Owner, on_delete=models.CASCADE)


class Group(models.Model):
    name = models.CharField()
    owner = models.OneToOneField(Owner, on_delete=models.CASCADE)
    creator = models.ForeignKey(Person)


class Task(models.Model):
    description = models.CharField(max_length=200)
    owner = models.ForeignKey(Owner, on_delete=models.CASCADE)

대안책 2와의 차이점

  • 더 이상 걱정할 NULL FK가 존재하지 않습니다.
  • 그러나 Person 또는 Group object를 생성할 때 owner 행을 생성해야 합니다. 이러한 행은 사용되지 않을 수도 있습니다.
  • 이 패턴은 Person 또는 Group의 수정이 필요합니다.
  • 만약 Task로 시작하고 owner의 type을 알고싶은 경우에는 대안책 2보다 더 많은 쿼리를 만들어냅니다.

 

대안책 4 - 다중 테이블 상속

Django의 다중 테이블 상속에 대해 알고 있다면 대안책 3을 훨씬 더 적은 코드로 구현할 수 있습니다. Owner에 대한 명시적인 OneToOneField 대신 Person 또는 Group이 Owner에서 상속되도록 할 수 있습니다.

 

이것은 실제로 대안책 3과 유사한 데이터베이스 스키마를 생성합니다. Django는 OneToOneField 링크를 추가합니다. 컬럼명의 차이점 이외에도 owner 컬럼도 기본키로 사용된다는 것입니다.

 

코드 수준에서는 대안책 3과 매우 유사하고, 실제로 몇가지를 단순화 합니다. owner object를 수동으로 만들 필요가 없고 다형성을 무료로 얻을 수 있습니다. Person is-a owner이기 때문에 동작을 상속합니다.

 

개인적으로는 다중 테이블 상속을 사용하지 않습니다. 그 이유 중 하나는 Django가 사용하는 상속 메커니즘의 복잡성 때문입니다. 두번째는 성능 문제가 있습니다. OneToOneField를 명시하면 Join 및 성능 문제를 더 쉽게 인식할 수 있습니다. 셋째로 Django는 다중 상속을 지원하지 않으므로 한 번만 사용할 수 있습니다. 다시 말해서, 하나의 "is-a" 또는 "has-a" 관계(Group is-a owner & Person is-a  owner)를 취하고 특별한 상태와 구현을 부여하는 반면 모든 다른 유사한 관계들은 다른 메커니즘으로 처리해야 합니다.

반면에 대안책 2와 3은 원하는 만큼 사용할 수 있습니다. OOP, 실제 비즈니스의 변화하는 요구사항에서는 상속보다 더 나은 구성을 사용하여 모든 관계를 '강등'하고 구현하는 것이 더 낫습니다.

 

다중 테이블 상속에 관한 코드는 다음과 같습니다.

class Owner(models.Model):
    pass


class Person(Owner):
    name = models.CharField()


class Group(Owner):
    name = models.CharField()
    creator = models.ForeignKey(Person)


class Task(models.Model):
    description = models.CharField(max_length=200)
    owner = models.ForeignKey(Owner, on_delete=models.CASCADE)

 

대안책 5 - 멀티 링크 모델

이 솔루션도 매우 간단하며 실제로 연결된 모델(Task)이 단일 모델/테이블이 될 필요가 없는 경우에 적용됩니다. 일부 사례의 경우 PersonTask와 관련이 있는 Person, GroupTask와 관련이 있는 Group 모델을 갖는 것이 바람직할 수 있습니다.

 

이제 모델과 테이블이 조인 테이블 없이 완전히 구별되는 경우, 발생할 수 있는 문제가 거의 없습니다.

 

첫째, 정렬, 필터링 및 페이징을 포함하여 서로 다른 모델의 결합 인스턴스를 포함하는 목록을 표시해야 하는 일부 인스턴스가 있을 수 있습니다. 단일 테이블이 필요한 것처럼 보입니다. 그러나 SQL에는 UNION 쿼리가 있고, Django에는 QuerySet.union을 통해 쿼리를 지원합니다.

 

둘째, PersonTask 와 GroupTask 사이에 중복된 기능이 많을 수 있습니다. Django에서 이것은 다루기 쉽습니다. 먼저 일반적인 작업을 abstract Task 모델로 가져오기만 하면 됩니다.

# Person and Group as in our initial code

class Task(models.Model):
    description = models.CharField(max_length=200)

    class Meta:
        abstract = True


class PersonTask(Task):
    owner = models.ForeignKey(Person)


class GroupTask(Task):
    owner = models.ForeignKey(Group)

이제 모든 공통 필드와 기능을 Task에 넣을 수 있습니다. 스키마 수준에서 두 가지 유형의 Task들은 완전히 별개이고 상속은 Python 수준에서만 존재합니다.

 

PersonTask 및 GroupTask 인스턴스를 모두 조작해야하는 다른 코드(유틸리티, view, 템플릿)가 있을 수 있습니다. 덕 타이핑으로 인해 파이썬에서는 이러한 루틴이 일반적이고 Task 인스턴스가 true인 경우에만 사용할 경우 문제가 되지 않습니다. 어떤 경우든 isinstance를 활용해 어떤 종류가 있는지 확인할 수 있습니다.

 

또한 파이썬에는 first class 클래스가 있으므로, 함수를 정의할 때 인수로서 모델 클래스를 가지게 할 수 있습니다. 다음과 같습니다.

def get_happy_tasks(model):
    return model.objects.filter(description__contains="☺")

happy_person_tasks = get_happy_tasks(PersonTask)

유사한 패턴을 사용하여 이러한 기술을 사용하는 모델이 많다면 중복을 제거할 수 있습니다.

 

교체 가능한(swappable) 모델

마지막으로, GFK가 매력적인 솔루션이 될 수 있는 알 수 없는 단일 모델(예: 일반적인 타사 앱)에 연결해야 하는 경우가 있습니다.

이러한 경우에는 두 가지 방식이 있습니다.

  1. 모델을 추상화하고 사용자가 이 모델에서 상속하도록 요구하여 ForeignKey 필드 자체를 추가합니다. 이것은 다른 이유로 유용한 패턴이 될 수 있지만 어떤 경우에는 다소 다루기 어려울 수 도 있습니다.
  2. 교체 가능한(swappable)모델을 사용하세요. Django는 이를 실제로 지원하지만 공식적으로는 내부용으로만 사용되었습니다.
    그러나 Swapper는 공개 API를 잘 만드려는 시도로 보이고 잘 유지 관리 되는 것으로 보입니다. (GFK보다 더 나은 옵션이라고 생각합니다.)

 


 

개인적으로 GFK에 대한 경험이 매우 부정적이었기에 위 내용의 작성자와 동일한 입장입니다. 이미 GFK를 통해 구현되어 있는 경우라면 불가피 하겠지만, 새롭게 시작하거나 추가가 되는 경우라면 GFK의 사용에 대해 다시 한번 고려해보시는 것을 추천드립니다.

 

긴 게시물 읽어주셔서 감사합니다 :)

 

 

728x90