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_SQUARE는 SQUARE의 별칭으로 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 문서는 아래와 같이 나오게 됩니다.
이 문서에는 한 가지 큰 문제가 있습니다. 바로 "1"과"2"가 무엇을 뜻하는지 문서만 보아서는 알 수가 없다는 점이죠. 다행히도 이 문제는 조금 간단하게 해결할 수 있습니다. 바로 Python의 __doc__ 기능을 이용해서죠.
1
2
3
4
5
6
class RequestEmailAuthType(str, Enum):
"""
1: 가입, 2: 비밀번호 초기화
"""
JOIN = '1'
RESET_PASSWORD = '2'
이런 식으로 열거형에 __doc__항목을 추가해 주면 redoc은 아래와 같이 자동으로 각각의 값에 대한 설명을 만들어줍니다.

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