Post

Python 열거형과 SQLAlchemy

Python의 Enum Type

기본적으로 Python의 열거형은 여러 이름을 같은 값에 대해 별칭으로 사용합니다. 예를 들면 열거형의 멤버에 동일한 값을 가진 A와 B가 정의되어 있다면 B는 A의 별칭입니다.

1
2
3
4
5
class Shape(Enum):
    SQUARE = 2
    DIAMOND = 1
    CIRCLE = 3
    ALIAS_FOR_SQUARE = 2

위 Shape를 예로 들어보면 ALIAS_FOR_SQUARESQUARE의 별칭으로 SQUARE, ALIAS_FOR_SQUARE 중 어느 것을 조회해도 2(SQUARE)를 반환하게 됩니다.

1
2
3
4
5
6
>>> Shape.SQUARE
<Shape.SQUARE: 2>
>>> Shape.ALIAS_FOR_SQUARE
<Shape.SQUARE: 2>
>>> Shape(2)
<Shape.SQUARE: 2>

API로 열거형을 전달할때 고민

FastAPI는 router의 요소를 자동으로 문서화해 주는 기능이 있습니다. 이는 문서작성의 시간을 상당 부분 줄여주어 매우 편리한 기능이라고 생각하는데 Python 열거형에 대해 조금은 부족한 부분이 있죠.

1
2
3
4
5
6
class RequestEmailAuthType(str, Enum):
    JOIN = '1'
    RESET_PASSWORD = '2'
...
class RequestEmailAuthIn(CamelModel):
    request_type: RequestEmailAuthType = Field(title='요청 타입')

위와 같은 열거형을 API 요청에서 필드로 받는다고 가정하면 redoc 문서는 아래와 같이 나오게 됩니다. default-request-body-enum 이 문서에는 한 가지 큰 문제가 있습니다. 바로 "1""2"가 무엇을 뜻하는지 문서만 보아서는 알 수가 없다는 점이죠. 다행히도 이 문제는 조금 간단하게 해결할 수 있습니다. 바로 Python의 __doc__ 기능을 이용해서죠.

1
2
3
4
5
6
class RequestEmailAuthType(str, Enum):
    """
    1: 가입, 2: 비밀번호 초기화
    """
    JOIN = '1'
    RESET_PASSWORD = '2'

이런 식으로 열거형에 __doc__항목을 추가해 주면 redoc은 아래와 같이 자동으로 각각의 값에 대한 설명을 만들어줍니다. doc-request-body-enum

SQLAlchemy와의 연동

FastAPI에서 ORM 기능을 이용하기 위해선 SQLAlchemy라는 OpneSource를 이용합니다. 매우 잘 만들어진 OpenSource이지만 Database에 열거형의 타입을 넣을 때 열거형이 값(value)이 아닌 키(name)가 들어간다는 것입니다. 키가 들어가는 건 나름 의미 있는 데이터가 들어가는 것이라 데이터 베이스의 값을 직접 열어볼 때 조금은 더 직감적일 수 있다는 장점이 있긴 하지만, 최근 개발 동향상 데이터베이스에 직접 접근해서 CRUD 작업을 하는 경우는 잘 없다 보니 저장되는 데이터의 용량을 줄이기 위해 위에서 언급했던 별칭을 이용해 보도록 하겠습니다.

1
2
3
4
5
class RequestEmailAuthType(str, Enum):
    JOIN = '1'
    RESET_PASSWORD = '2'
    J = '1'
    R = '2'

SQLAlchemy가 알아서 별칭만을 알아서 추려낸 후 데이터베이스의 키로 설정해 줄 수 있다면 더 이상 손볼게 없어지지만, 아쉽게도 위 코드만으로는 다음과 같은 스키마로 enum 필드가 생성됩니다.

1
`request_type` enum('JOIN','RESET_PASSWORD','J','R') NOT NULL,

J와 R로 데이터를 채울 수는 있지만 썩 만족스럽지 않습니다. 저장되는 데이터의 크기를 줄여보자는 의도와 상충되는 부분이 있고, 무엇보다 동일한 의미의 값이 중복되어 표시될 수도 있다는 점이 혼란을 야기할 수도 있을 것 같다는 판단입니다.

SQLAlchemy에서 Enum을 처리하는 방식을 살펴보면 대략 아래와 같이 구성되어 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
if len(enums) == 1 and hasattr(enums[0], "__members__"):
    self.enum_class = enums[0]

    _members = self.enum_class.__members__

    aliases = [n for n, v in _members.items() if v.name != n]
    if self._omit_aliases is NO_ARG and aliases:
        util.warn_deprecated_20(
            "The provided enum %s contains the aliases %s. The "
            "``omit_aliases`` will default to ``True`` in SQLAlchemy "
            "2.0. Specify a value to silence this warning."
            % (self.enum_class.__name__, aliases)
        )

__members__를 통해 Enum 필드에 들어갈 항목들을 가져오는데, 아쉽게도 별칭만을 넣을 수 있는 옵션이 따로 없네요. 원키와 별칭의 순서를 바꾼 후 (별칭을 원키처럼 활용) _omit_aliases옵션을 줄까 생각해 보기도 하였지만, 별칭과 원키의 순서를 보장하고 싶다는 생각에 __members__를 조금 수정해 보기로 했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class AliasOnlyEnumMeta(EnumMeta):
    @property
    def __members__(cls):
        sp = super().__members__
        items = sp.items()
        alias_values = [v.value for n, v in items if v.name != n]
        filtered = [n for n, v in items if (v.name == n and v.value not in alias_values) or v.name != n]
        members = {k: v for k, v in items if k in filtered}
        return members

class RequestEmailAuthType(str, Enum, metaclass=AliasOnlyEnumMeta):
    JOIN = '1'
    RESET_PASSWORD = '2'
    J = '1'
    R = '2'

Enum의 metaclass를 커스터마이징 한 AliasOnlyEnumMeta를 생성 후 __members__ property를 override 해서 별칭이 없는 키와 별칭이 있다면 별칭 만을 내려 주도록 해보았습니다. 이제 이 코드를 적용해서 스키마를 생성해 보면 다음과 같이 Field가 생성되는 걸 확인할수 있죠.

1
`request_type` enum('J','R') NOT NULL,

Enum의 값으로 사용되는 키가 상당히 간결해진 느낌은 있지만, 이제 약자로 표현된 필드의 값이 무엇이 의미하는지 쉽게 해석이 힘들어지는 문제가 생기네요. SQLAlchemy에서 제공하는 comment키워드에 redoc에서 사용했던 것과 같이 __doc__ 이용해 해당 키의 의미를 설명으로 넣어보겠습니다.

1
request_type = Column(Enum(RequestEmailAuthType), comment=RequestEmailAuthType.__doc__, nullable=False)

이제 생성된 스키마를 보면 html과 일반 text의 차이 때문에 생각했던 것과는 조금 다른 형태의 comment가 붙어있는 걸 볼 수 있습니다.

1
`request_type` enum('J','R') NOT NULL COMMENT '\n        1: 가입, 2: 비밀번호 초기화\n        ',

어떻게 본다면 별문제 아닌 것 같지만 만들다 만 것 같은 느낌의 결과는 용납할 수 없습니다. comment키워드에 일일이 주석을 달아주는 것도 좋은 방법이 될 수 있지만, 수작업이 많아진다는 건 그만큼 실수의 가능성이 커진다는 것과 동일한 의미이기 때문에 이것을 자동으로 만들어 줄 수 있는 무언가를 만들어야겠다는 생각을 하고 다음과 같이 만들어 보았습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class AnnotatedEnum(Enum):
    def __new__(cls, *args, **kwargs):
        tu = cls.__bases__
        if len(tu) == 1:
            # plain Enum
            t = object
            pass
        else:
            t = tu[0]
        obj = t.__new__(cls)
        obj._value_ = args[0]
        return obj

    def __init__(self, _, desc: str = None):
        self.desc = desc

    @classmethod
    @property
    def comment(cls):
        items = cls.__members__.items()
        docs = [f'{n}: {v.desc}' for n, v in items]
        return ', '.join(docs)

class RequestEmailAuthType(str, AnnotatedEnum, metaclass=AliasOnlyEnumMeta):
    """
    1: 가입, 2: 비밀번호 초기화
    """
    JOIN = '1', '회원가입'
    RESET_PASSWORD = '2', '비밀번호 초기화'
    J = '1'
    R = '2'

...

request_type = Column(Enum(RequestEmailAuthType), comment=RequestEmailAuthType.comment, nullable=False)

Python 열거형에 설명을 달아줄 수 있는 AnnotatedEnum을 만들어 하위 클래스로 RequestEmailAuthType을 생성했습니다. 이제 JOIN = '1', '회원가입'이런 식으로 Tuple 형태로 열거형의 값을 지정할 수 있고, 두 번째 인자는 desc에 자동으로 매핑 되도록 만들어주었고, 이런 식의 구조는 다음과 같은 장점을 가질 수 있습니다.

  • 키, 값, 설명을 한 줄로 처리를 해서 보다 직관적으로 해석이 가능하다.
  • 키가 새로 생기거나 삭제될 때 __doc__처리의 누락을 최소화할 수 있다.

위 방식으로 만들어낸 스키마를 보면 아래와 같이 좀 더 깔끔한 설명을 남길 수 있습니다.

1
`request_type` enum('J','R') NOT NULL COMMENT 'J: 회원가입, R: 비밀번호 초기화',

redoc에서도 Annotation을 사용할 수 없을까?

키와 값, 그리고 설명을 한 줄로 관리할 수 있게 만들었다면 이를 다른 곳에서도 활용할 수 있는 게 더 좋겠다는 생각이 들어 앞서 만든 AliasOnlyEnumMeta클래스를 생성할 때에 몇 가지 수정사항을 넣어 열거형의 __doc__을 자동으로 만들어 주는 기능을 넣어봤습니다. redoc은 html 태그를 지원하다 보니 키와 값에 대한 설명에 약간의 강조 처리도 함께 적용시켜보았죠.

1
2
3
4
5
6
7
8
9
10
11
class AliasOnlyEnumMeta(EnumMeta):
    def __new__(metacls, cls, bases, classdict, **kwargs):
        obj = super(AliasOnlyEnumMeta, metacls).__new__(metacls, cls, bases, classdict, **kwargs)
        members = obj.__members__
        _desc_list = [
            f'<em>{v.value}</em> : <strong>{v.desc or "-"}</strong>'
            for _, v in members.items() if hasattr(v, 'desc')
        ]
        obj.__doc__ = ', '.join(_desc_list)
        return obj

위 코드를 이용해 redoc 문서를 보면 아래와 같이 강조되는 항목들이 생겨났습니다. auto-doc-request-body-enum 처음보다 훨씬 보기 좋은 문서가 만들어졌습니다. 또한 열거형에 개발자가 서술한 __doc__을 그대로 유지하면서 메모리상의 __doc__만을 변경처리하여, 열거형의 Overview를 그대로 유지하면서 API / Database의 설명만을 자동으로 만들어 주는 형식이라 여러 가지로 효율성이 증대되는 방식을 만들어 낸 것 같습니다.

This post is licensed under CC BY 4.0 by the author.