시작

django 모델에서 복수의 필드 조합을 기준으로 유일한 레코드를 가지도록 설정하고 싶을 때는 보통 아래와 같이 unique_together 속성을 사용했습니다.

class SluggedWithUniqueTogetherTestModel(models.Model):
    title = models.CharField(max_length=42)
    slug = AutoSlugField(populate_from='title')
    category = models.CharField(max_length=20, null=True)

    class Meta:
        app_label = 'django_extensions'
        unique_together = ['slug', 'category']

하지만 django 2.2 이후부터는 아래와 같이 UniqueConstraint 클래스를 활용해 설정할 수 있게 되었습니다.

class SluggedWithConstraintsTestModel(models.Model):
    title = models.CharField(max_length=42)
    slug = AutoSlugField(populate_from='title')
    category = models.CharField(max_length=20, null=True)

    class Meta:
        app_label = 'django_extensions'
        constraints = [
            UniqueConstraint(
                fields=['slug', 'category'],
                name="unique_slug_and_category",
            ),
        ]

한편 django 프레임워크에 여러 확장 기능들을 제공하는 django-extensions 패키지는 유용한 모델 필드 클래스를 제공하고 있습니다. AutoSlugField 클래스도 그 중 하나인데요. 아직 django 2.2 버전에서 추가된 UniqueConstraint 클래스를 지원하지 않아 불편함이 있는 상황이었습니다. 이에 대한 이슈가 django-extensions 저장소에 제기된 상황이었고 이를 지원하기 위한 작업을 진행해보기로 결심했습니다.

AutoSlugField

슬러그(slug)란 원래 신문 등 언론사에서 기사의 내용을 잘 표현하는 중요 어휘만으로 만든 제목과 그 방법을 뜻합니다. 인터넷 세계에서는 URL의 일부로서 슬러그를 사용합니다. http://www.example.com/sample-slug 중에서 sample-slug 부분을 슬러그라고 부를 수 있습니다. 자세히 보면 하이픈(-)으로 단어를 구분하고 있는 것이 특정이라는 것을 알 수 있습니다. AutoSlugField 클래스는 모델의 다른 필드(populate_from에서 설정하는 값)를 이용해서 고유한 슬러그 값을 자동으로 생성해줍니다. 만약 필드의 값이 같아서 슬러그가 중복될 것 같으면 슬러그 뒤에 숫자를 추가함으로서 중복이 발생하지 않도록 합니다.

class SluggedTestModel(models.Model):
    title = models.CharField(max_length=42)
    slug = AutoSlugField(populate_from='title')

    class Meta:
        app_label = 'django_extensions'

m = SluggedTestModel(title='foo')
m.save()  # slug foo

m = SluggedTestModel(title='foo')
m.save()  # slug foo-2

단, 여기서 populate_from의 값은 리스트, 튜플일 수 있으며 복수의 필드 기준으로도 동작합니다. 여기에 unique_together 설정을 함께 사용한다면 2개의 필드를 기준으로 고유한 슬러그를 만들어낼 수 있을 것입니다.

UniqueConstraint 적용

문제는 UniqueConstraint 클래스의 도입으로 AutoSlugField 클래스 역시 이를 뒷받침할 수 있어야 했습니다. 이를 위해서 고유한 슬러그 값을 생성하는 부분을 찾아 수정해야만 합니다. find_unique 함수가 그 부분이었고 해당 함수를 수정하는 작업을 진행했습니다. 아래는 수정 후 코드입니다.

def find_unique(self, model_instance, field, iterator, *args):
    # exclude the current model instance from the queryset used in finding
    # next valid hash
    queryset = self.get_queryset(model_instance.__class__, field)
    if model_instance.pk:
        queryset = queryset.exclude(pk=model_instance.pk)

    # form a kwarg dict used to implement any unique_together constraints
    kwargs = {}
    for params in model_instance._meta.unique_together:
        if self.attname in params:
            for param in params:
                kwargs[param] = getattr(model_instance, param, None)

    # for support django 2.2+
    query = Q()
    constraints = getattr(model_instance._meta, 'constraints', None)
    if constraints:
        for constraint in constraints:
            if self.attname in constraint.fields:
                condition = {
                    field: getattr(model_instance, field, None)
                    for field in constraint.fields
                    if field != self.attname
                }
                query &= Q(**condition)

    new = six.next(iterator)
    kwargs[self.attname] = new
    while not new or queryset.filter(query, **kwargs):
        new = six.next(iterator)
        kwargs[self.attname] = new
    setattr(model_instance, self.attname, new)
    return new

위 코드를 보면 kwargs[self.attname] = new 부분에서 새로운 슬러그 값을, field: getattr(model_instance, field, None) 코드가 포함된 반복문에서 슬러그 필드와 함께 UniqueConstraint 클래스에서 설정한 필드 정보를 읽어온다는 것을 알 수 있습니다. 이 값들을 이용해 고유한 슬러그를 찾기 위한 조건(queryset.filter(query, **kwargs))을 생성합니다. 만약 조건에 부합하는 레코드가 없다면 현재의 슬러그 값은 고유한 슬러그가 맞다는 것을 알 수 있습니다. (참고로 iterator 객체가 숫자 1씩 증가시킨 슬러그 후보 문자열을 반환합니다.)

test case

고유한 슬러그를 의도한 대로 생성하는지 확인하기 위해 테스트 케이스를 함께 작성했습니다. 먼저 테스트에 필요한 모델을 정의합니다. UniqueConstraint 클래스는 django 2.2 이후부터 적용할 수 있기 때문에 관련 코드를 추가했습니다.

class SluggedWithConstraintsTestModel(models.Model):
    title = models.CharField(max_length=42)
    slug = AutoSlugField(populate_from='title')
    category = models.CharField(max_length=20, null=True)

    class Meta:
        app_label = 'django_extensions'
        if django.VERSION >= (2, 2):
            constraints = [
                UniqueConstraint(
                    fields=['slug', 'category'],
                    name="unique_slug_and_category",
                ),
            ]


class SluggedWithUniqueTogetherTestModel(models.Model):
    title = models.CharField(max_length=42)
    slug = AutoSlugField(populate_from='title')
    category = models.CharField(max_length=20, null=True)

    class Meta:
        app_label = 'django_extensions'
        unique_together = ['slug', 'category']

위 모델에 대해 올바르게 슬러그를 생성하는지 확인하는 시나리오는 아래와 같습니다. unique_together, UniqueConstraint 2개의 케이스가 같은 결괏값을 내놓는지 확인하는 것 또한 중요한 목적입니다.

def test_auto_create_slug_with_unique_together(self):
    m = SluggedWithUniqueTogetherTestModel(title='foo', category='self-introduction')
    m.save()
    self.assertEqual(m.slug, 'foo')

    m = SluggedWithUniqueTogetherTestModel(title='foo', category='review')
    m.save()
    self.assertEqual(m.slug, 'foo')

    # check if satisfy database integrity
    m = SluggedWithUniqueTogetherTestModel(title='foo', category='review')
    m.save()
    self.assertEqual(m.slug, 'foo-2')

@pytest.mark.skipif(django.VERSION < (2, 2), reason="This test works only on Django greater than 2.2.0")
def test_auto_create_slug_with_constraints(self):
    m = SluggedWithConstraintsTestModel(title='foo', category='self-introduction')
    m.save()
    self.assertEqual(m.slug, 'foo')

    m = SluggedWithConstraintsTestModel(title='foo', category='review')
    m.save()
    self.assertEqual(m.slug, 'foo')

    # check if satisfy database integrity
    m = SluggedWithConstraintsTestModel(title='foo', category='review')
    m.save()
    self.assertEqual(m.slug, 'foo-2')

시나리오를 보면 slug, category 두 필드를 기준으로 고유한 슬러그를 만듭니다. 단, slug 필드 하나만 중복될 경우에는 같은 슬러그를 만듭니다. 두 필드 기준으로는 고유한 레코드이기 때문입니다. 만약 이것이 문제가 된다면 populate_from=['title', 'category']처럼 AutoSlugField 클래스를 재정의해야만 합니다.

마치며

초기에는 약간의 혼란을 겪으며 작업한 기능이었습니다. 잘 모르는 기능에 대해서 코드를 읽어가며 작업하는 것은 버그 수정보다 훨씬 어려웠습니다. 하지만 버전이 올라감에 따라 생기는 코드 변화를 따라가도록 작업한다는 것이 어떤 일인지 체감할 수 있었습니다. 새 버전의 기능을 따라가주지 못 한다면 프로젝트는 도태될 것이고 때때로 바탕이 되는 오픈소스의 버전 안정화와 배포에 악영향을 끼칠 수도 있을 것입니다. 이번 작업을 하면서 오픈소스 생태계에 도움을 주면서 신 버전의 기능을 공부할 수 있는 좋은 기회가 되었습니다.