안녕하세요, 렌딧 Engineering 팀의 Jesse입니다. 이번에는 지난 글에 이어서 Django의 inspectdb를 튜닝한 경험을 정리해보려고 합니다.

이 시리즈의 내용을 파이콘 한국 2018에서 발표 하였습니다. 아래 자료도 참고하세요.

CharField와 blank

1편에서 만든 어드민으로 실제로 정보를 고치려고 해보았는데, 가장 먼저 만난 것은…

필수 항목입니다.

inspectdb는 문자열 필드를 만들 때, 기본적으로 모두 필수 필드라고 가정합니다. 즉 blank 속성의 기본값인 false를 사용하는 것이지요. 때문에, 위와 같이 비어있는 필드를 용납하지 못합니다. (Django의 필드 속성값이 익숙하지 않으시다고요? 저도 그랬습니다. Django - Model field reference를 늘 옆에 끼고 보아야합니다.)

정상적인 케이스라면, 모델링 문서를 열어놓고 어느 필드가 필수이고 아닌지에 따라 생성된 모델 파일을 고쳐야겠지요. 하지만 우리는 모델 파일을 손으로 수정하지 않을 겁니다. 레거시 시스템의 보조시스템을 만드는 것이기에 DB에 변경이 생길때마다 모델 파일이 자동으로 업데이트되는 것이 핵심이니까요. 그러니 1편에서 만들었던 업데이트 스크립트를 수정해봅시다.

1
#!/bin/bash
2
3
set -e
4
5
TEMP_FILE=models.py.tmp
6
MODEL_FILE=apps/core/models.py
7
8
python manage.py inspectdb --database lendit > $TEMP_FILE
9
10
sed "\
11
    s/some_field = models.CharField(max_length=200)/some_field = models.CharField(max_length=200, blank=True)/; \
12
    s/other_field = models.CharField(max_length=200)/other_field = models.CharField(max_length=200, blank=True)/;\
13
    " $TEMP_FILE | tee $MODEL_FILE
14
15
rm $TEMP_FILE

sed를 써서 일단은 돌아가게 만들었습니다. 음… 그런데 필드는 엄청 많은데 이걸 하나하나 언제 필수값인지 다 추가하지… 42초 쯤 고민하다가, Django의 validation은 사용하지 않기로 결정하였습니다. 아쉽긴 하지만 어디까지나 레거시 시스템을 보조하는 것이 초점이므로, validation 없이 모든 CharField를 blank=True로 만들기로 했습니다.

자 그렇다면, 이제 sed에서 쓰는 정규표현식을 모든 CharField에 적용하면 될까요? 그랬다면 이 글의 카테고리가 Django가 아니라 정규표현식이겠지요. sed를 쓰지 말고, 아예 inspectdb 자체를 고쳐서 써봅시다.

inspectdb customization 하기

inspectdb는 파이썬 코드를 생성하는 파이썬 코드입니다. 유연한 확장(과 흑마법의 여지가 충만하다는 것)이 파이썬의 장점이니만큼, 어딘가 끼어들 곳이 있을 것으로 기대하며 Django 소스코드를 열어봅니다. github의 Django 1.11버전 inspectdb 소스코드

링크를 눌러보시면 아시겠지만 우리가 이미 살펴본 model.py 생성을 위하여 yield로 한 줄 한 줄 열심히 출력을 뿜어내는 모습입니다. 그런데 사실 위 코드를 찾아가려고, https://github.com/django/django 에 가서, t를 누르고 inspectdb를 입력했는데…

inspectdb search result

빙고! 본체 inspectdb.py 밑에 contrib/gis 디렉토리 내부에 이미 customization된 사례까지 있네요. gis관련된 사례를 보니, 기존 inspectdb 중 get_field_type을 확장해서 자신만의 GeometryField라는 필드를 수정하고 있군요. 자신감이 생기니 우리도 해봅시다. 기껏해야 필드에 옵션 하나 추가하는 거니까요.

디렉토리 구조를 맞추어 apps/core/management/commands/inspectdb.py 파일을 만들어봅시다. 이 경로에서 core부분이 앱의 이름이니 적절히 바꾸시면 됩니다.

1
from django.core.management.commands.inspectdb import (
2
    Command as InspectDBCommand,
3
)
4
5
class Command(InspectDBCommand):
6
    def get_field_type(self, connection, table_name, row):
7
        field_type, field_params, field_notes = super().get_field_type(connection, table_name, row)
8
        if field_type == 'CharField':
9
            field_params['blank'] = True
10
        return field_type, field_params, field_notes

원래 inspectdb의 Command클래스를 상속받은 후, 모든 CharField 타입에 대하여 blank 옵션을 강제로 넣어주었습니다. sed로 바꾸는 것보다 훨씬 폼나고 멋지네요. (필수 필드인지에 대한 validation을 포기했다는 자괴감은 이미 사라졌습니다.) 모델파일 업데이트 스크립트도 원래대로 원복하였습니다.

자 이렇게 멋진 customization을 여기에만 쓰면 아깝겠지요? 또 어떤 것을 할 수 있을까요?

created_at, updated_at

렌딧에서는 널리 사용되는 컨벤션에 따라 대부분의 테이블에 created_at 필드와 updated_at 필드를 만듭니다. 각각 레코드를 최초 생성한 시각과 마지막으로 수정한 시각을 저장하는 필드이지요. 그런데 모델 생성된 결과를 보니 다음과 같네요.

1
created_at = models.DateTimeField()
2
updated_at = models.DateTimeField()

시간을 나타내는 필드로 잘 되긴 했는데, 조금 아쉽습니다. 어드민에서 보면 시간을 입력하는 필드로 잘 나오긴 하는데 매번 고칠때마다 직접 입력해야합니다. Django 어드민에서 데이터를 추가하거나, 수정하는 경우에도 당연히 함께 업데이트가 되어야하지 않을까요? Django에서 제공하는 auto_now, auto_now_add 기능을 연결해봅시다.

1
def get_field_type(self, connection, table_name, row):
2
    field_type, field_params, field_notes = super().get_field_type(connection, table_name, row)
3
    if row.name == 'created_at':
4
        field_params['auto_now_add'] = True
5
    elif row.name == 'updated_at':
6
        field_params['auto_now'] = True
7
    if field_type == 'CharField':
8
        field_params['blank'] = True
9
    return field_type, field_params, field_notes

이렇게만 추가해주면 다음과 같이 생성됩니다.

1
created_at = models.DateTimeField(auto_now_add=True)
2
updated_at = models.DateTimeField(auto_now=True)

개인적으로는 이 정도는 Django에서 옵션으로 넣어줄만하지 않나 싶긴 하네요. ^^;

BIT(1), BOOLEAN

마지막으로 다룰 주제는 약간 생소하실 수도 있는데요. 렌딧 레거시 시스템의 특징으로 mysql BIT(1) 필드가 있습니다. 사실 요즘은 대부분 BOOLEAN(=TINYINT(1)) 필드를 사용하고 있는데, 예전 테이블 중에 저 BIT(1) 필드를 사용하는 곳이 있습니다. 그런데…

1
old_bit_field = models.TextField()  # This field type is a guess.

와 같이 생성이 되는군요. 아… BIT 필드를 아예 인식을 못하네요. 덕분에 체크박스가 생겨야할 곳에 광대한 textarea가 떡 자리잡고, 더 큰 문제는 BIT(1) 필드의 내용은 b’\x00’, b’\x01’ 같이 출력이 되기 때문에 이게 그대로 브라우저에 표시되면서 브라우저마다 내용이 달라지고 혼돈이 시작됩니다.

그래서 BooleanField로 연결해주려고 해보았는데, 그냥은 안되는군요. 데이터베이스에는 또 바이너리 형태로 저장되어야하기 때문에 우리만의 Custom Field가 필요해졌습니다. 사용자 필드 생성 매뉴얼을 정독한 후에 한 번 만들어봅시다. 이름은 Lendit의 L을 따서 LBooleanField로 정해보았습니다. 새로 추가하는 필드들을 넣어둘 디렉토리도 필요한데, 여기서는 저희가 Django를 패치해서 쓰는 부분들을 모아놓은 patches 디렉토리로 잡았습니다.

먼저 apps/core/patches/models/fields.py 파일을 추가하고, LBooleanField를 다음과 같이 구현합니다.

1
from django.db.models import BooleanField, NullBooleanField
2
3
class LBooleanField(BooleanField):
4
    def from_db_value(self, value, expression, connection, context):
5
        if value is None:
6
            return False
7
        return self.to_python(value)
8
9
    def to_python(self, value):
10
        # BIT(1)은 b'\x00' b'\x01'로 떨어짐. 변환필요.
11
        if isinstance(value, bytes):
12
            return bool(value[0])
13
        return super(BooleanField, self).to_python(value)

그리고 이를 불러다 쓰는 inspectdb는 다음과 같이 더 고칩니다.

1
from MySQLdb.constants import FIELD_TYPE
2
from django.core.management.commands.inspectdb import (
3
    Command as InspectDBCommand,
4
)
5
6
class Command(InspectDBCommand):
7
    db_module = '.patches'
8
    def get_field_type(self, connection, table_name, row):
9
        field_type, field_params, field_notes = super().get_field_type(connection, table_name, row)
10
        if (row.type_code == FIELD_TYPE.TINY or row.type_code == FIELD_TYPE.BIT) and row.internal_size == 1:
11
            field_type = 'LBooleanField'
12
            field_notes = []
13
        if row.name == 'created_at':
14
            field_params['auto_now_add'] = True
15
        ...(생략)

사실, mysql에서 많이 쓰는 BOOLEAN도 사실은 TINYINT이기 때문에 IntegerField로 생성됩니다. 그래서 TINYINT(1)과 BIT(1) 두 가지를 모두 LBooleanField로 지정하였습니다.
맨 위의 db_module은 나중에 model.py가 생성되면, from .patches import models로 출력되는 부분입니다. 즉, model.py를 로드할 때, 필드 클래스들에 대한 정보를 읽어오는 곳이지요. LBooleanField에 대한 정의를 읽을 수 있도록 db_module을 지정해주어야합니다.

내부적으로는 NULL 필드인 경우를 처리하기 위한 LNullBooleanField 도 만들어서 잘 활용하고 있습니다. 위 코드에서 if row.null_ok:조건 체크 하나만 추가해주면 됩니다.

정리

inspectdb로 할 수 있는 일이 굉장히 제한적일 것 같았지만, 상속을 사용하여 확장하면서 어렵지 않게 많은 기능을 구현할 수 있었습니다. 이게 바로 Django와 파이썬의 강력한 점인 것 같은데요. 1편에서 만든 어드민은 여러 필드들의 값 형식이 제대로 저장되지 않는 부분 때문에 실제로 바로 쓰기는 어려웠지만, inspectdb를 확장하여 거의 모든 문제를 해결할 수 있었답니다. 다음 편에서는 inspectdb만으로는 부족했던 어드민 입력 편의 기능 부분을 다루어보겠습니다.