django-filter용 NULLS LAST 정렬 필터.

이 글 내용은 PostgreSQL을 기반으로 하며, 다른 RDBMS 에서는 확인 안 해봤다.

Django ORM으로 NULLS LAST 정렬

Hannal이라는 모델이 있고 name 필드는 null을 허용한다.

class Hannal(models.Model):
    name = models.CharField(max_length=32, null=True, blank=True)
    birthday = models.DateField(null=True, blank=True)

여러 데이터의 name 필드의 값이 다음과 같이 들어갔다고 가정하자.

  • 'abc'
  • 'zyx'
  • None
  • 'lmn'

이제 name 필드로 오름차순(Ascending) 정렬해보자.

Hannal.objects.order_by('name')
  • None
  • 'abc'
  • 'lmn'
  • 'zyx'

결과는 null이 먼저 나온다. 만약 null이 아닌 데이터를 먼저 정렬하고 그 이후에 null인 데이터를 나열하려면 NULLS LAST로 정렬해야 한다.

from django.db.models import F

Hannal.objects.order_by(F('name').desc(nulls_last=True))
  • 'abc'
  • 'lmn'
  • 'zyx'
  • None

우리가 원하는 대로 정렬됐다.

django-filter의 OrderingFilter

이번엔 이를 django-filter에 적용해보자. django-filter에 정렬 필터인 OrderingFilter를 사용하면 간편하게 정렬 필터를 적용할 수 있다.

import django_filters as filters


class HannalFilter(filters.FilterSet):
    ordering = filters.OrderingFilter(
      fields=(
          ('name', 'name', ),
          ('birthday', 'saengil', ),
      ),
    )

자세한 내용은 공식 문서에 나와있지만, 영문 싫어하는 사람도 많고 공식 문서가 썩 친절하진 않으니 설명하겠다.

fields로 모델의 필드와 HTTP Query String에서 넘겨 받을 필드를 짝지어야 한다. 뒤(namesaengil)는 Query string으로 받을 값, 앞(namebirthday)은 모델의 필드/필드표현식을 뜻한다. 보통은 모델 필드명과 동일하게 하는 게 알아보기 좋지만, 다르게 해야 할 경우도 많다.

우선 요청자(client)측에서 다른 이름을 쓰고 싶은 경우이다. Python 관례에 따르면 snake case 표기를 쓰겠지만, Front-end쪽에서는 camel case 표기를 대개 쓴다. 예를 들어 모델 필드명은 birth_day인데 요청자는 굳이 birthDaysaengil을 쓰고 싶은 경우이다.

다른 경우가 더 흔한 경우인데, 모델 관계(model relationship)를 필드 표현식으로 다뤄야 하는 경우이다. 예를 들어 Kay라는 모델이 있고 Hannal 모델과 1:N 관계를 맺고 있다고 하자.

class Kay(models.Model):
    name = models.CharField(max_length=32, null=True, blank=True)


class Hannal(models.Model):
    name = models.CharField(max_length=32, null=True, blank=True)
    birthday = models.DateField(null=True, blank=True)
    related = models.ForeignKey('Kay', on_delete=models.CASCADE)

그리고 Kay 모델의 name 필드를 기준으로 정렬한 Hannal 모델의 데이터를 가져오려면 다음과 같이 한다.

Hannal.objects.order_by('related__name')

여기서 related__name을 django-filter 에서도 사용할 수 있다.

class HannalFilter(filters.FilterSet):
    ordering = filters.OrderingFilter(
      fields=(
          ('name', 'name', ),
          ('birthday', 'saengil', ),
          ('related__name', 'kayname', ),
      ),
    )

이렇게 fields를 지정하면 ordering키의 값으로 name, saengil, kayname을 사용할 수 있다. Django REST framework(이하 DRF)에 적용하면 URL을 ?ordering=name로 하여 name 필드에 대해 오름차순 정렬하거나 ?ordering=-name로 하여 내림차순 정렬한다. 이 뿐만 아니라 여러 개 필드를 정렬할 수도 있다. 예를 들어 name 필드는 오름차순, birthday 필드는 내림차순으로 정렬하고자 한다면 HTTP Query String 으로 ?ordering=name,-birthday라고 하면 된다.

OrderingFilter에 NULLS LAST 적용

편하다. 근데 django-filter 2.4.0 버전 기준까지는 NULLS LASTNULLS FIRST를 지원하지 않는다. 그러므로 따로 구현해야 한다. OrderingFilter가 제공하는 기능은 그대로 쓰되 QuerySet 만들 때 null 정렬만 추가하면 되니 이 클래스를 상속받아 쓴다.

from django.db.models import F
import django_filters as filters


class NullsLastOrderingFilter(filters.OrderingFilter):
    def filter(self, qs, value):
        if not value:
            return qs
        for _v in value:
            is_desc = _v.startswith('-')
            field = self.param_map.get(_v[1:] if is_desc else _v)
            if not field:
                continue
            if is_desc:
                qs = qs.order_by(F(field).desc(nulls_last=True))
            else:
                qs = qs.order_by(F(field).asc(nulls_last=True))
        return qs


class HannalFilter(filters.FilterSet):
    ordering = NullsLastOrderingFilter(
      fields=(
          ('name', 'name', ),
          ('birthday', 'saengil', ),
          ('related__name', 'kayname', ),
      ),
    )

    class Meta:
        model = Hannal
        fields = ('name', 'birthday', )

위에서 설명한 내용을 모두 반영한 코드이다. filter() 메서드는 django-filter 가 넘겨받은 QuerySet 객체이다. DRF에 연동해 사용한다면 DRF가 이런 저런 조치를 취한 QuerySet일테고, 사용자가 만든 filter 들을 거친 QuerySet이기도 하다.

NULLS FIRST를 적용하려면 nulls_last 대신 nulls_firstTrue로 지정하면 된다.


2019년을 마치며

2019년을 회고하는 글을 쓰려 했는데 2019년엔 블로그에 글을 하나도 안 썼더라. 2019년에 쓴 블로그 첫 글이 2019년 회고 글이라니 웬지 맥이 빠져서 회고 대신 간결하게 정리만 해본다.

방송통신대학교 졸업

2016년에 신입생으로 방송통신대학교(이하 방송대)에 입학했고, 얼마 전 마지막 학기의 기말시험을 마쳤다. 목표대로 4년 만에 졸업하여 기쁘다. 매 학기에 여섯 과목 중 한두 과목은 날로 먹었는데, 그래도 성적 중 70%를 차지하는 객관식 기말시험은 부담스러웠다. 성적이 아주 높진 않지만, 장학금도 반 정도 받았고 대학원에 응시할 정도는 달성했다. 물론 대학원엔 안 갈 거지만.

방송대를 적극 추천한다. 나를 비롯해서 많은 사람이 자신의 의지력을 과대 평가하는데, 방송대는 자신의 의지력을 파악하는 아주 좋은 측정자이다. 커리큘럼과 교수진도 좋으며, MS Office, GitHub 등 학생 혜택도 받는다.

프리랜서 생활을 마치다

2016년 3분기부터 프리랜서 생활을 시작했다. 만 3년 했는데, 이렇게 오래 할 줄 몰랐다. 프리랜서 생활이 방송통신대학교 학업 진행에 딱히 더 유리하거나 불리한 건 없었다. 직장인에 비해 출석 수업에 나가기 편하긴 했지만, 늘 여러 일을 진행했기 때문에 일정하게 공부할 시간 자체를 확보하기 힘들었다.

매년 직전 년도 기준으로 평균 30~40%씩 성장했던 건 고객 덕분이다. 유연하고 기민하게 협업하려는 목적으로 진행한 협업 방식이 중장기 프로젝트에서는 기대만큼 잘 동작하지 않았는데, 후반 프로젝트들은 중장기 계획으로 진행하여 나나 고객사들 모두 고생했다. 아쉬운 마음이 많이 남는다.

회사 입사

프리랜서 생활을 마치고 핏펫에 합류했다. 주변 사람들은 내가 1. 프리랜서 생활을 뜬금없이 그리고 갑자기 마치고선 회사에, 2. 그 회사의 사업 분야가 반려동물이라는 점, 3. 개발 실무자가 아니라 관리자로 합류했다는 사실을 놀라워하거나 신기해했다. 공감한다.

1년에 3만km

테슬라 모델X를 출고하고 만 1년 만에 주행거리 3만km를 돌파했다. 그동안 들어간 에너지 비용이 대략 40만원으로 1km 당 13~14원 정도 들었다. 이전에 타던 차가 디젤이고 리터 당 17~20km 정도 달렸는데, 리터 당 1,400원으로 계산하여 3만km를 달렸다면 대략 230만원 정도 연료비가 들었을 것이다.

출고 1년 동안 여러 펌웨어 업데이트가 이뤄져 기능과 성능만 놓고 보면 1년 전에 산 그 차가 맞는 지 의심될 수준이다. 배터리는 1% 정도 열화되어 주행거리 20만km까지는 많이 불편하지 않을 것 같다.

한 해를 정신없이 보내서 책은 거의 안 읽었다고 생각했는데, 세보니 40권 정도 읽었다. 개발 관련 책은 필요한 부분만 보다보니 완독한 책은 몇 권 없다. 4분기엔 평일 두 시간씩 운전하다 보니 전자책을 TTS로 듣는다. 처음 읽는 책을 TTS로 들으니 개운하지 않다. 정독하기 전이나 후에 TTS로 듣는 게 나을 것 같다.

개발, 프로그래밍

알고리즘과 자료구조, 수학에 취약하다. 방송대 3학년부터 슬슬 압박을 받는 요인이었다. 공부하고 끝! 하는 게 아니라 그냥 평생 취미로 재미로 붙들고 살아야겠다.

이런 저런 언어나 도구를 여럿 익혔지만, 별 감흥이 없다. 모래성 위에 깃발 꽂은들.

영어

Safari Books online에서 낑낑대며 원서 읽고 있는데 번역서가 나온 적이 있다. 듣고 싶은 강의가 자막도 없이 영어로만 진행된 게 좀 있다. 영어가 발목을 잡은 적이 한두 번이 아니긴 했지만, 최근 유독 영어가 학습하는 데 제약이 되는 빈도가 늘었다. 의도적 수련으로 다시 공부하겠다.

건강

중년에 들어섰다는 걸 절감한다. 손목, 어깨, 손가락, 허리 부근 직업병은 갈수록 심해지고, 체력 저하도 심각하다. 집중력과 의지력, 친절과 배려심은 체력에서 나오더라. 크로스핏을 사랑하지만 또 다치고 나서 잠정 중단했다. 체형 교정과 유연성을 개선하기 전엔 재개하면 안 될 것 같다.