복사되었습니다.

Python의 enum으로 dynamic value 정의하기

Cover Image for Python의 enum으로 dynamic value 정의하기

Python의 enum은 일종의 alias라고 생각할 수 있다. 어떤 값을 미리 정의한 다른 이름으로 대체해서 참조할 수 있다. 이 별명과 값들은 enum을 정의하는 시점에 결정되고, 이후 값을 변경할 수 없는 "불변"의 속성을 가진다. 그렇다면 만약 enum에 정의된 다른 값들을 활용하여 동적으로 만들어야 하는 값이 있다면 어떻게 해야 할까?

Enum에 dynamic value를 만든다고?

글에서 다루고자 하는 것

애초에 enum으로 무언가를 정의하기로 했다면, 어떤 값을 특정 이름을 가진 상수로 만들겠다는 의지가 담겨있다고 볼 수 있다. 이런 상수를 dynamic하게 정의한다는 것은 무슨 의미일까? 다음의 코드를 살펴보자.

from enum import Enum

class MyEnum(Enum):
  VALUE1 = "Hello"
  VALUE2 = "World"
  MESSAGE = VALUE1 + ", " + VALUE2
>>> MyEnum.MESSAGE
<MyEnum.MESSAGE: 'Hello, World'>

이 글에서 "값을 dynamic하게 정의한다"는 표현은 MESSAGE처럼 같은 클래스 안에 있는 다른 값들을 활용하여 정의한다는 의미로 사용된다.

잘 정의되네... 끝 아니야?

위 코드에서 만약 VALUE3, VALUE4 등 값이 추가된다고 생각해보자. 그리고 MESSAGE는 자신을 제외한 모든 값들을 활용해서 정의해야 한다고 생각해보자. 새로운 값이 정의될 때마다, MESSAGE 부분도 ... + VALUE3 + ", " + VALUE4 이런 식으로 수정해줘야 한다. 값을 추가한다는 하나의 의도를 만족시키기 위해서 코드의 여러 곳을 수정해야 한다는 것은 바람직하지 않다. 함께 코드를 짜는 동료 개발자가 있다면, 새로운 값이 추가될 때마다 MESSAGE 부분도 같이 수정해달라고 미리 구차하게 설명해둬야 한다. 값을 추가하면 자동으로 MESSAGE 값 생성 과정에도 반영되게 해서 수정해야 하는 부분을 최소화하는 방법을 알아보자.

Dynamic value를 만드는 다양한 방법들

three methods to define dynamic values in enum

Property를 사용한 dynamic value 생성

Python의 enum에도 dynamic property를 정의할 수 있다. Property에 대한 자세한 내용은 이 글을 참고하자. Property decorator를 사용하면 다음과 같이 dynamic value를 구현할 수 있다.

class MyEnum(Enum):
  VALUE1 = "Hello"
  VALUE2 = "World"

  @classmethod
  @property
  def MESSAGE(cls):
    return ", ".join([x.value for x in cls.__members__.values()])

Enum은 기본적으로 인스턴스화하지 않고 사용하기 때문에, @classmethod@property를 함께 사용하여 property decorator를 class 레벨로 만들어주었다. 이렇게 구현하면 MyEnum.MESSAGE를 사용할 때마다 동적으로 값을 만들어서 반환해준다. MyEnum에 정의된 값들을 모두 불러올 때는 cls.__members___를 사용했다. 이것을 이용하면 MESSAGE를 제외한 모든 값들을 불러올 수 있다. 따라서, 새로운 값을 추가할 때 MESSAGE 부분을 수정하지 않아도 추가된 값이 자동으로 MESSAGE에 반영된다.

그런데, 이 방법에는 여러가지 문제가 있다. 첫 번째는 매번 값을 조회할 때마다 새로운 값을 만들어주는 불필요한 작업을 반복한다는 것이다. MESSAGE는 이미 정의되어서 변할 일이 없는 MyEnum의 값들을 사용한다. 딱 한 번만 값을 만들어두면 된다. 예제에서는 간단한 로직을 사용했지만, 만약 값 생성을 위해 무거운 작업을 수행해야 한다면 비효율성은 더욱 커진다.

두 번째 문제점은 enum 값들의 type 일관성이 깨진다는 것이다. 다음 코드를 살펴보자.

>>> MyEnum.VALUE1
<MyEnum.VALUE1: 'Hello'>
>>> MyEnum.VALUE2
<MyEnum.VALUE2: 'World'>
>>> MyEnum.MESSAGE
'Hello, World'
>>> type(MyEnum.VALUE1), type(MyEnum.VALUE2), type(MyEnum.MESSAGE)
(<enum 'MyEnum'>, <enum 'MyEnum'>, <class 'str'>)

기본적으로 enum에 정의된 값들은 자신이 정의된 클래스의 이름과 같은 <enum 'MyEnum'> 타입을 가진다. 그런데, property decorator를 통해 정의한 MESSAGE는 문자열을 반환하기 때문에, <class 'str'> 타입이 된다. 사용하는 곳에서는 MESSAGE에 대해서만 따로 처리를 해야 하기 때문에 불편하다.

>>> MyEnum.VALUE1.name
'VALUE1'
>>> MyEnum.VALUE2.value
'World'
>>> MyEnum.MESSAGE.name
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'str' object has no attribute 'name'

세 번째는 dynamic value들끼리는 서로 참조할 수 없다는 것이다. 예를 들어, 위 코드 예제에서 VALUE1, VALUE2, MESSAGE를 모두 사용해서 만들어지는 FINAL_MESSAGE라는 새로운 dynamic value를 만든다고 생각해보자.

...

  @classmethod
  @property
  def FINAL_MESSAGE(cls):
    return ", ".join([x.value for x in cls.__members__.values()])
>>> MyEnum.MESSAGE, MyEnum.FINAL_MESSAGE
('Hello, World', 'Hello, World')

Property로 만드는 값은 enum의 멤버가 아니라고 판정된다. 따라서, cls.__members__에 포함되지 않는다. 그 결과, FINAL_MESSAGE에는 MESSAGE의 값이 반영되지 않는다.

이처럼 여러 문제점을 안고 있기 때문에, property를 사용하여 dynamic value를 정의하는 것은 유효한 솔루션으로 보기 어렵다.

일반 클래스 변수를 사용하여 dynamic value 정의하기

첫 번째로 소개한 방법의 문제점 중에서 enum에 정의한 값들의 type 일관성이 깨지는 문제를 해결할 수 없을까? Property로는 enum 타입을 반환할 수 없다. 그렇다면, 오히려 다른 값들이 <class 'str'> 타입을 반환하도록 하면 어떨까? Enum을 상속하지 않는 일반 클래스로 정의하는 것이다. 코드를 통해 알아보자.

class MyEnum:
  VALUE1 = "Hello"
  VALUE2 = "World"

  @classmethod
  @property
  def MESSAGE(cls):
    members = [getattr(cls, x) for x in dir(cls) if '__' not in x and 'MESSAGE' not in x]
    return ", ".join(members)

  def __new__(cls, *args, **kwargs):
      raise TypeError(f"Can't instantiate class {cls.__name__}")
>>> MyEnum.VALUE1
'Hello'
>>> MyEnum.VALUE2
'World'
>>> MyEnum.MESSAGE
'Hello, World'
>>> type(MyEnum.VALUE1), type(MyEnum.VALUE2), type(MyEnum.MESSAGE)
(<class 'str'>, <class 'str'>, <class 'str'>)

모든 값들의 type이 <class 'str'>로 통일되었다. 사용하는 쪽에서는 모든 값을 일관된 방식으로 사용할 수 있기 때문에, 첫 번째 방법의 문제 중 하나는 해결한 셈이다. 또한 enum의 멤버들을 불러오는 과정에서 cls.__members__가 아니라 dir(cls)를 사용하기 때문에, dynamic variable들끼리도 서로 참조할 수 있게 된다. dir(cls)MyEnum이라는 클래스에 포함된 모든 내용을 불러오기 때문이다. 두 가지 문제가 해결되었다.

그런데, 여전히 몇 가지 문제가 남아있다. 첫 번째로, 여전히 property decorator를 사용하고 있기 때문에 값을 조회할 때마다 불필요한 반복작업을 해야 한다는 것이다.

두 번째는 값의 별명을 설정할 때 각별히 주의해야 한다는 것이다. dir(cls)를 통해 모든 내용을 가져오기 때문에, 정확히 필요한 클래스 변수들만 발라낼 수 있는 방법이 필요하다. 위 코드 예제에서는 __가 포함되지 않은 변수들만 가져오도록 했다. 이 경우 멤버로 사용할 값들 외에 추후 추가적으로 정의하는 모든 변수와 메서드의 이름에 __가 무조건 포함되어야 한다는 제약이 생긴다. 그리고 dir(cls)를 순회하는 과정에서 property 자기자신의 이름을 제외해주지 않는다면, 순환참조가 발생하기 때문에 코드가 제대로 동작하지 않는다. 이렇게 신경쓸 부분이 많이 생긴다는 것은 바람직하지 않다.

세 번째는 enum과 유사하게 동작하게 만들기 위해서 인스턴스화를 못하도록 명시적으로 막아줘야 한다는 것이다. 위 코드에서는 __new__ 메서드를 오버라이딩해서 객체 생성을 못하도록 막았다. 사실 이 부분은 필수는 아니지만, "enum처럼 쓰이는 클래스"를 만드는 것이 목적이라면 고려할 필요는 있다.

Enum 생성자를 통한 정의

앞서 소개한 두 방법 모두 property decorator를 사용했기 때문에 값을 참조할 때마다 불필요한 반복작업을 피할 수 없었다. Enum 생성자를 활용하면 property decorator를 사용하지 않고도 dynamic value를 정의할 수 있다.

Enum은 인스턴스화하지 않고 클래스 자체로 사용하는데, 생성자가 무슨 소용이냐는 의문이 든다. 사실 첫 번째 방법에서 소개한 코드를 보면, enum에 정의된 값들은 <enum '자신이 정의된 클래스'> 타입을 가진다는 것을 알 수 있다. 각 값의 정체는 자신이 정의된 enum 클래스의 객체인 것이다.

>>> type(MyEnum.VALUE1)
<enum 'MyEnum'>
>>> isinstance(MyEnum.VALUE1, MyEnum)
True

Enum의 생성자는 해당 클래스가 정의되는 시점에 각각의 값마다 호출된다. 생성자 호출 순서는 값이 정의된 순서와 동일하다. 다음 코드로 쉽게 확인해볼 수 있다.

>>> class MyEnum(Enum):
...   VALUE1 = "Hello"
...   VALUE2 = "World"
...
...   def __init__(self, value) -> None:
...     print(self.name, value)
...
VALUE1 Hello
VALUE2 World

MyEnum을 정의하자마자 바로 print(...) 부분이 실행되는 것을 확인할 수 있다. 이 MyEnum 클래스가 다른 파일에 정의가 되어있다면, import를 하는 시점에 실행된다.

이런 특징을 활용하면 다음과 같이 생성자를 통해 dynamic value를 정의할 수 있다.

class MyEnum(Enum):
  VALUE1 = "Hello"
  VALUE2 = "World"
  MESSAGE = ""

  def __init__(self, _: str) -> None:
    if self.name == 'MESSAGE':
      self._value_ = ", ".join([x.value for x in self.__class__.__members__.values()])
>>> MyEnum.VALUE1
<MyEnum.VALUE1: 'Hello'>
>>> MyEnum.VALUE2
<MyEnum.VALUE2: 'World'>
>>> MyEnum.MESSAGE
<MyEnum.MESSAGE: 'Hello, World'>
>>> type(MyEnum.VALUE1), type(MyEnum.VALUE2), type(MyEnum.MESSAGE)
(<enum 'MyEnum'>, <enum 'MyEnum'>, <enum 'MyEnum'>)

이렇게 구현하면 dynamic value 할당이 최초 클래스 정의 시점에 한 번만 이루어지기 때문에 불필요한 반복작업이 없어진다. 멤버 변수들의 type도 통일된다. 그리고, dynamic value들끼리 서로 참조할 수도 있다. 앞선 방법들이 가지고 있는 대부분의 문제가 해결된다.

구현할 때 주의할 점이 세 가지 있다. 첫 번째로, 정의된 멤버들을 조회할 때 self.__members__가 아니라 self.__class__.__members__를 사용해야 한다는 것이다. 각각의 객체에서는 __members__ 속성에 직접 접근할 수 없다. 다음과 같은 에러를 만나게 된다.

AttributeError: 'MyEnum' object has no attribute '__members__'

두 번째로 주의할 점은 enum의 값들이 일종의 상수라는 것이다. 다시 말해서, 한 번 값을 설정하면 변경할 수 없다는 "불변"의 속성을 지닌다. 따라서, self.value = ...같은 식으로 값을 직접 변경할 수 없다. 다음과 같은 에러를 만나게 된다.

AttributeError: <enum 'Enum'> cannot set attribute 'value'

따라서, self._value_로 값을 세팅해야 한다. Enum의 특징을 무시하는 작업이기 때문에 찝찝하지만 정상적으로 동작하긴 한다.

세 번째는, 값의 정의 순서를 주의해야 한다는 것이다. 특정 값이 만들어질 때 호출되는 생성자 안에서는 해당 값보다 먼저 정의된 변수들만 참조할 수 있다. 여러 dynamic value들을 만들 때는 특히 주의해야 한다.

언뜻 보면 이 방법은 앞선 두 방법의 모든 문제를 해결한 것처럼 보인다. 그러나, 이 방법도 한 가지 문제점이 존재한다. 새로운 dynamic value를 추가할 때 수정할 곳이 두 군데라는 점이다. 우선 생성자 호출을 유도하려면 enum의 멤버 변수로 정의를 해야 한다. 위 코드에서 MESSAGE = "" 부분에 해당한다. 그리고 이 값을 변경하기 위해 현재 호출된 생성자가 MESSAGE에 해당하는 것인지 확인하는 절차를 추가해야 한다. 만약 dynamic value의 수가 늘어난다면, 조건문이 점점 복잡해질 것이다.

이 방법은 결국 값을 추가한다는 하나의 의도를 만족시키기 위해 코드의 여러 부분을 수정해야 한다는 점 때문에, 무의미한 방법이 아닐까? 완전히 무의미하다고는 볼 수 없다. 추가하려는 값이 dynamic value가 아니라면, 일반적인 enum처럼 멤버 변수로만 정의하면 된다. Dynamic value를 추가할 때만 생성자까지 수정하면 되는 것이다.

어떤 방법이 가장 좋을까?

소개한 세 가지 방법 중에 완벽한 방법은 없다. 그나마 enum 생성자를 오버라이딩한 세 번째 방법이 가장 낫다고 할 수 있다. 불필요한 중복작업 없이 일반적인 enum과 동일한 방식으로 사용할 수 있다. 하지만, 이 방법도 결국 "불변"의 속성을 지닌 enum의 특징을 무시한 일종의 편법에 해당하므로 찝찝함이 남아있다.

결국 최선은 enum에 dynamic value를 정의해야 하는 상황에 의구심을 품고, 다른 시나리오를 설계하는 것이다. 어쩔 수 없이 dynamic value를 정의해야 하는 상황이라면 enum 생성자를 오버라이딩하는 방법을 사용하도록 하자.

from enum import Enum

class MyEnum(Enum):
  VALUE1 = "Hello"
  VALUE2 = "World"
  MESSAGE = ""

  def __init__(self, _: str) -> None:
    if self.name == 'MESSAGE':
      self._value_ = ", ".join([x.value for x in self.__class__.__members__.values()])

Comments

    More Posts

    Python으로 kubernetes 환경에 app 배포하기 - Kubernetes deployment with python

    Kubectl 명령어를 통해 kubernetes 환경에 다양한 구성요소들을 배포할 수 있지만, python으로 템플릿 코드를 짜두면 개발자들도 편리하게 배포를 자동화할 수 있다. Jinja2와 kubernetes 패키지를 통해 사용자 요청에 따라 kubernetes 환경에 pod들을 띄우는 기능을 만들어보자.

    Domain entity와 ORM을 따로 구분해서 정의하는 방법 - Domain entity with imperative ORM

    Domain entity를 정의할 때 declarative 방식의 ORM을 사용하면 domain model에 DB 관련 정보가 노출되는 문제가 있다. Python sqlalchemy를 활용하여 imperative mapping style로 domain entity로부터 ORM을 분리해보자.

    도메인 entity와 ORM을 동시에 추구하면 안 되는 걸까? - Domain entity with declarative ORM

    ORM을 사용하는 환경에서 DDD를 따르는 코드를 작성하다보면, domain entity를 어떤식으로 정의해야 하는지 혼란스러울 때가 있다. Python sqlchemy를 통해 declarative mapping 방식으로 domain entity를 어떻게 구현하면 좋은지 알아보자.

    Font Size