1. 디스크립터 개요
1-1. 디스크립터 메커니즘
디스크립터를 구현하려면 최소 두 개의 클래스가 필요하다. 클라이언트 클래스는 디스크립터 구현의 기능을 활용할 도메인 모델로서 솔루션을 위해 생성한 일반적인 추상화 객체이다. 디스크립터 클래스는 디스크립터 로직의 구현체이다.
디스크립터는 단지 디스크립터 프로토콜을 구현한 클래스의 인스턴스다. 이 클래스는 다음 매직 메서드 중 최소 한 개 이상을 포함해야 한다.
- __get__
- __set__
- __delete__
- __set_name__
또한 다음과 같은 네이밍 컨벤션을 사용한다.
이름 | 의미 |
ClientClass | 디스크립터 구현체의 기능을 활용할 도메인 추상화 객체. 디스크립터의 클라이언트이다. 클래스 속성으로 디스크립터를 갖는다. |
DescribtorClass | 디스크립터 클래스이다. 이 클래스는 디스크립터 프로토콜을 따르는 매직 메서드를 구현해야만 한다. |
client | ClientClass 의 인스턴스 |
describtor | DescribtorClass 의 인스턴스 |
중요한 사실은 이 프로토콜이 동작하려면 디스크립터 객체가 클래스 속성으로 정의되어야 한다는 것이다. 이 객체를 인스턴스 속성으로 생성하면 동작하지 않으므로 init 메서드가 아니라 클래스 본문에 있어야 한다.
디스크립터 객체는 항상 클래스 속성으로 선언해야 한다.
class DescriptorClass:
def __get__(self, instance, owner):
if instance is None:
return self
logger.info(
"Call: %s.__get__(%r, %r)",
self.__class__.__name__,
instance,
owner,
)
return instance
class ClientClass:
descriptor = DescriptorClass()
클래스 속성을 객체로 선언하면 (=descriptor) 디스크립터로 인식되고, 클라이언트에서 해당 속성을 호출하면 객체 자체를 반환하는 것이 아니라 __get__ 매직 메서드의 결과를 반환한다.
>>> client = ClientClass()
>>> client.descriptor
INFO: Call: DescriptorClass.__get__(<__main__.ClientClass object at 0x107e08b10>, <class '__main__.ClientClass'>)
<__main__.ClientClass object at 0x107e08b10>
>>> client.descriptor is client
INFO: Call: DescriptorClass.__get__(<__main__.ClientClass object at 0x107e08b10>, <class '__main__.ClientClass'>)
True
1-2. 디스크립터 프로토콜의 메서드 탐색
# __get__(self, instance, owner)
두 번째 파라미터 instance는 디스크립터를 호출한 객체를 의미한다. 앞선 예제에서는 client 객체를 의미한다.
owner 파라미터는 해당 instance 객체의 클래스를 의미한다.
앞에서 __get__ 메서드 서명에 있는 instance 파라미터는 디스크립터가 행동을 취하려는 객체이고 owner는 인스턴스의 클래스이다. 여기서 owner = instance.__class__ 와 같은 형태로 직접 알 수 있음에도 굳이 owner 를 서명에 정의했는지 의문일 수 있다.
이는 client 인스턴스가 아니라 ClientClass 에서 descriptor 를 직접 호출하는 경우에 instance 값은 None 이기 때문에 굳이 파라미터를 추가하여 받는 것이다.
class DescriptorClass:
def __get__(self, instance, owner):
if instance is None:
return f"{self.__class__.__name__}.{owner.__name__}"
return f"value for {instance}"
class ClientClass:
descriptor = DescriptorClass()
ClientClass 에서 직접 호출하면 네임스페이스와 클래스 이름을 출력한다.
>>> ClientClass.descriptor
'DescriptorClass.ClientClass'
반면 인스턴스에서 호출하면 다른 메시지를 출력한다.
>>> ClientClass().descriptor
'value for <__main__.ClientClass object at 0x107e16550>'
일반적으로 owner 파라미터를 사용하려는 경우가 아니면, 인스턴스가 None 일 때는 디스크립터 자체를 반환한다.
# __set__(self, instance, value)
이 메서드는 디스크립터에 값을 할당하려고 할 때 호출된다.
client.descriptor = "value"
client.descriptor 가 __set__() 메서드를 구현하지 않으면, "value" 는 descriptor 자체를 덮어쓴다.
디스크립터 속성에 값을 할당할 때는 __set__ 메서드를 구현했는지 반드시 확인하여 부작용이 생기지 않도록 주의해야 한다.
기본적으로 __set__ 메서드는 데이터를 저장할 때 사용한다.
다음은 이를 활용하여 속성의 유효성을 검사하는 객체를 어떻게 만들 수 있는지 보여준다. 유효성 검사 함수는 자유롭게 생성할 수 있으며 객체에 값을 할당하기 전에 실행된다.
class Validation:
"""유효성을 검증하는 콜러블 객체"""
def __init__(
self, validation_function: Callable[[Any], bool], error_msg: str
) -> None:
self.validation_function = validation_function
self.error_msg = error_msg
def __call__(self, value):
if not self.validation_function(value):
raise ValueError(f"{value!r} {self.error_msg}")
class Field:
"""유효성 기능을 수행하는 디스크립터"""
def __init__(self, *validations):
self._name = None
self.validations = validations
def __set_name__(self, owner, name):
self._name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__[self._name]
def validate(self, value):
for validation in self.validations:
validation(value)
def __set__(self, instance, value):
self.validate(value)
instance.__dict__[self._name] = value
class ClientClass:
descriptor = Field(
Validation(lambda x: isinstance(x, (int, float)), "is not a number"),
Validation(lambda x: x >= 0, "is not >= 0"),
)
>>> client = ClientClass()
>>> client.descriptor = 42
>>> client.descriptor
42
>>> client.descriptor = -42
Traceback (most recent call last):
...
ValueError: -42 is not >= 0
>>> client.descriptor = "invalid value"
Traceback (most recent call last):
...
ValueError: 'invalid value' is not a number
프로퍼티 자리에 놓일 수 있는 것은 (get, set) 디스크립터로 추상화할 수 있으며 여러 번 재사용할 수 있다. 이 예에서는 __set__() 메서드가 @property.setter 가 하던 일을 대신한다.
# __delete__(self, instance)
self 는 descriptor 속성을 나타내며, instance 는 client 를 나타낸다.
>>> del client.descriptor
다음 예제에서는 __delete__ 메서드를 활용하여 관리자 권한이 없는 객체에서 속성을 제거하지 못하도록 하는 디스크립터를 구현하였다.
class ProtectedAttribute:
def __init__(self, requires_role=None) -> None:
self.permission_required = requires_role
self._name = None
def __set_name__(self, owner, name):
self._name = name
def __set__(self, user, value):
if value is None:
raise ValueError(f"{self._name} can't be set to None")
user.__dict__[self._name] = value
def __delete__(self, user):
if self.permission_required in user.permissions:
user.__dict__[self._name] = None
else:
raise ValueError(
f"User {user!s} doesn't have {self.permission_required} "
"permission"
)
class User:
"""admin 권한이 있는 유저만 이메일 주소를 지울 수 있음"""
email = ProtectedAttribute(requires_role="admin")
def __init__(
self, username: str, email: str, permission_list: list = None
) -> None:
self.username = username
self.email = email
self.permissions = permission_list or []
def __str__(self):
return self.username
User 클래스는 username과 email 파라미터를 필수로 받는다. __init__ 메서드를 보면 email 속성이 없으면 사용자를 생성할 수 없다. email 속성을 지워버리면 불완전한 객체가 되고 User 클래스에서 정의한 인터페이스와 맞지 않는 상태가 된다.
따라서 email 의 삭제는 단순히 None으로 설정하는 것으로 하여 User 클래스의 인터페이스에 맞춘다. 동시에 __set__ 에서 None 으로 설정하는 것을 금지해야 한다. 그 이유는 __delete__ 메서드의 메커니즘을 우회하기 때문이다. 즉, None 으로 설정하는 것이 권한이 없음에도 마치 삭제를 한 것과 같은 효과가 나타날 수 있기 때문에 금지해야 한다.
>>> admin = User("root", "root@d.com", ["admin"])
>>> user = User("user", "user1@d.com", ["email", "helpdesk"])
>>> admin.email
'root@d.com'
>>> del admin.email
>>> admin.email is None
True
>>> user.email
'user1@d.com'
>>> user.email = None
Traceback (most recent call last):
...
ValueError: "email can't be set to None"
>>> del user.email
Traceback (most recent call last):
...
ValueError: "User user doesn't have admin permission"
# __set_name__(self, owner, name)
일반적으로 클래스에 디스크립터 객체를 만들 때는 디스크립터가 처리하려는 속성의 이름을 알아야 한다.
속성의 이름은 __dict__ 에서 __get__ 과 __set__ 메서드로 읽고 쓸 때 사용된다.
파이썬 3.6부터 __set_name__ 이 추가되었는데, 이 메서드는 파라미터로 디스크립터를 소유한 클래스와 디스크립터 이름을 받는다.
class DescriptorWithName:
def __init__(self, name: str = None) -> None:
self.name = name
def __set_name__(self, owner, name):
self.name = self.name or name
class ClientClass:
descriptor = DescriptorWithName()
>>> client = ClientClass()
>>> client.descriptor.name
descriptor
2. 디스크립터 유형
앞선 메서드를 사용하는 디스크립터의 동작 방식에 따라 디스크립터를 구분할 수 있다.
디스크립터가 __set__ 이나 __delete__ 메서드를 구현했다면 데이터 디스크립터(data descriptor) 라고 부른다. 그렇지 않고 __get__ 만을 구현한 디스크립터를 비데이터 디스크립터(non-data descriptor) 라고 부른다. __set_name__ 은 이 분류에 전혀 영향을 주지 않는다.
객체의 속성을 결정할 때, 데이터 디스크립터가 객체의 사전보다 우선적으로 적용되지만 비데이터 디스크립터는 그렇지 않다. 즉, 비데이터 디스크립터는 객체의 사전에 디스크립터와 동일한 이름의 키가 있으면 객체의 사전 값이 적용되고 디스크립터는 호출되지 않는다.
반대로, 데이터 디스크립터에서는 디스크립터와 동일한 이름을 갖는 키가 사전에 존재하더라도 디스크립터 자체가 항상 먼저 출력되기 때문에 객체의 키 값은 사용되지 않는다.
2-1. 비데이터(non-data) 디스크립터
class NonDataDescriptor:
"""__get__ 만을 구현한 비데이터 디스크립터"""
def __get__(self, instance, owner):
if instance is None:
return self
return 42
class ClientClass:
descriptor = NonDataDescriptor()
평소처럼 descriptor 를 호출하면 __get__ 메서드의 결과를 얻을 수 있다.
>>> client = ClientClass()
>>> client.descriptor
42
descriptor 속성을 다른 값으로 바꾸면 이전의 값을 잃고 대신에 새로 설정한 값을 갖는다.
>>> client.descriptor = 43
>>> client.descriptor
43
만약 descriptor 를 지우고 다시 출력하면 어떤 값을 가질까?
>>> del client.descriptor
>>> client.descriptor
42
처음 client 객체를 만들었을 때 descriptor 속성은 인스턴스가 아니라 클래스 안에 있다. 따라서 client 객체의 사전을 조회하면 그 값은 비어 있다.
>>> vars(client)
{}
여기서 .descriptor 속성을 조회하면 client.__dict__ 에서 "descriptor" 라는 이름의 키를 찾지 못하고 클래스에서 디스크립터를 찾게 된다. 이것이 __get__ 메서드의 결과가 반환되는 이유이다.
그러나 .descriptor 속성에 다른 값을 설정하면 인스턴스의 사전이 변경되므로 client.__dict__ 는 비어있지 않다.
>>> client.descriptor = 99
>>> vars(client)
{'descriptor': 99}
따라서 .descriptor 속성을 조회하면 객체의 __dict__ 사전에서 descriptor 키를 찾을 수 있으므로 클래스까지 검색하지 않고 바로 __dict__ 에서 값을 반환한다. 때문에 디스크립터 프로토콜이 사용되지 않고 다음에 이 속성을 조회할 때는 덮어써진 99 값을 반환한다.
그 뒤에 del 을 호출하여 이 속성을 지우면 객체의 __dict__ 사전에서 descriptor 키를 지운 것과 같으므로 다시 디스크립터 프로토콜이 활성화 된다.
>>> del client.descriptor
>>> vars(client)
{}
>>> client.descriptor
42
이렇게 동일한 이름으로 속성을 설정하면 지울 때 디스크립터가 아니라 속성이 지워지게 된다. __delete__ 메서드를 구현하지 않았기 때문이다. 이러한 유형의 디스크립터는 비데이터 디스크립터라고 한다.
2-2. 데이터 디스크립터
class DataDescriptor:
"""__get__ & __set__ 메서드가 구현된 데이터 디스크립터"""
def __get__(self, instance, owner):
if instance is None:
return self
return 42
def __set__(self, instance, value):
logger.debug("setting %s.descriptor to %s", instance, value)
instance.__dict__["descriptor"] = value
class ClientClass:
descriptor = DataDescriptor()
앞선 예제에 __set__ 메서드를 추가로 구현하였다.
>>> client = ClientClass()
>>> client.descriptor
42
descriptor 의 기본 반환 값은 동일하다. 이번에는 값을 변경하고 반환 값을 확인해 보자.
>>> client.descriptor = 99
>>> client.descriptor
42
descriptor 의 반환 값이 변경되지 않았다. 그러나 다른 값으로 할당하면 객체의 __dict__ 사전에는 업데이트 된다.
>>> vars(client)
{'descriptor': 99}
>>> client.__dict__["descriptor"]
99
이렇게 되는 이유는 __set__() 메서드가 호출되면 객체의 사전에 값을 설정하기 때문이다. 그리고 데이터 디스크립터에서 속성을 조회하면 객체의 __dict__ 에서 조회하는 대신 클래스의 descriptor 를 먼저 조회한다. 즉 데이터 디스크립터는 인스턴스의 __dict__ 를 오버라이드하여 인스턴스 사전보다 높은 우선순위를 가진다.
속성의 삭제는 동작하지 않는다.
>>> del client.descriptor
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: __delete__
삭제가 되지 않는 이유는 del 을 호출하면 인스턴스의 __dict__ 에서 속성을 지우려고 하는 것이 아니라 descriptor 에서 __delete__() 메서드를 호출하게 되는데 이를 구현하지 않았기 때문이다.
이 점이 데이터 디스크립터와 비데이터 디스크립터의 차이다. 만약 디스크립터가 __set__() 메서드를 구현했다면 객체의 사전보다 높은 우선순위를 갖는다. 반대로 구현하지 않았다면 객체의 사전이 높은 우선순위를 갖는다.
instance.__dict__["descriptor"] = value
__set__ 메서드에 정의된 부분에 대해 더 깊게 살펴본다.
"descriptor" 라는 이름의 속성 값을 바꾸는 이유는 뭘까? 위 예제에서는 디스크립터의 이름을 따로 설정하지 않았다. 실제로는 __init__ 메서드에서 디스크립터의 이름을 받아서 내부에 저장하거나 또는 __set_name__ 메서드를 사용해 이름을 설정할 수 있다. 그렇지 않으면 자동으로 디스크립터의 인스턴스를 할당한 클래스 속성의 이름이 저장된다.
인스턴스의 __dict__ 속성에 직접 접근하는 이유는 뭘까?
setattr(instance, "descriptor", value)
왜 위처럼 단순하게 값을 설정하지 않았을까?
디스크립터의 속성에 무언가를 할당하려고 하면 __set__ 메서드가 호출된다. 따라서 setattr() 을 사용하면 디스크립터의 __set__ 메서드가 호출되고, __set__ 메서드는 setattr 을 호출하고 다시 __set__ 이 호출되는 무한루프가 발생한다.
instance.descriptor = value 와 같은 할당 표현식도 같은 이유로 무한루프를 유발한다.
디스크립터의 __set__ 메서드에서 setattr() 이나 할당 표현식을 직접 사용하면 무한루프가 발생한다.
디스크립터는 모든 인스턴스의 프로퍼티 값을 보관할 수 없다. 그 이유는 클라이언트 클래스가 이미 디스크립터의 참조를 가지고 있는 상황에서 디스크립터가 다시 클라이언트 객체를 참조하면 순환 종속성(circular dependencies) 이 생기게 되어 가비지 컬렉션이 되지 않는 문제가 생기기 때문이다.
서로를 가리키고 있기 때문에 참조 카운트가 제거 임계치 이하로 떨어지지 않게 된다.
이에 대한 대안은 weakref 모듈에 있는 약한 참조를 사용하여 약한 참조 키 사전을 만드는 것이다.
3. 디스크립터 실전
3-1. 디스크립터를 사용한 애플리케이션
디스크립터로 중복된 코드를 추상화하는 방법을 예제를 통해 알아본다.
# 디스크립터를 사용하지 않은 예
다음 예제는 속성을 가진 일반적인 클래스인데 속성의 값이 달라질 때마다 추적하려고 한다.
속성의 setter 메서드에서 값이 변경될 때 검사하여 리스트와 같은 내부 변수에 값을 저장하는 방법이 있을 수 있다.
class Traveller:
"""여행자가 현재 어느 도시에 있는지를 속성으로 가지며, 방문한 모든 도시를 추척"""
def __init__(self, name, current_city):
self.name = name
self._current_city = current_city
self._cities_visited = [current_city]
@property
def current_city(self):
return self._current_city
@current_city.setter
def current_city(self, new_city):
if new_city != self._current_city:
self._cities_visited.append(new_city)
self._current_city = new_city
@property
def cities_visited(self):
return self._cities_visited
>>> alice = Traveller("Alice", "Barcelona")
>>> alice.current_city = "Paris"
>>> alice.current_city = "Brussels"
>>> alice.current_city = "Amsterdam"
>>> alice.cities_visited
['Barcelona', 'Paris', 'Brussels', 'Amsterdam']
이 이상이 필요하지 않다면 프로퍼티만으로 충분하다. 하지만 애플리케이션의 여러 곳에서 같은 로직을 사용한다면 달라져야 한다. 예를 들어 Alice 가 구입한 모든 티켓을 추적한다거나 방문했던 모든 국가를 추적하는 등의 일을 하고 싶다면 모든 곳에서 같은 로직을 반복해야 한다.
또한 다른 클래스에서도 같은 로직을 사용하려면 코드를 반복하거나 데코레이터, 디스크립터 등을 만들어야 할 것이다.
# 이상적인 구현방법
모든 클래스에 적용할 수 있도록 디스크립터를 사용해본다.
만약 실질적인 코드 반복이 없거나 복잡성의 대가가 명확하지 않다면 굳이 디스크립터를 사용할 필요가 없다.
이제 속성에 대해 일반적인 이름을 가진 디스크립터를 만들 것이다. 이 디스크립터는 값이 달라질 경우 리스트에 저장하여 추적하는 기능을 가진다. 디스크립터는 어떤 유형의 클래스 또는 프로젝트에서도 동일한 결과를 내도록 설계되기 때문에 메서드나 속성의 이름은 도메인 문제와 직접적인 연관이 없다.
class HistoryTracedAttribute:
"""trace_attribute_name 값에 따라서 이를 추적하는 클래스"""
def __init__(self, trace_attribute_name: str) -> None:
self.trace_attribute_name = trace_attribute_name # [1]
self._name = None
def __set_name__(self, owner, name):
self._name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__[self._name]
def __set__(self, instance, value):
self._track_change_in_value_for_instance(instance, value)
instance.__dict__[self._name] = value
def _track_change_in_value_for_instance(self, instance, value):
self._set_default(instance) # [2]
if self._needs_to_track_change(instance, value):
instance.__dict__[self.trace_attribute_name].append(value)
def _needs_to_track_change(self, instance, value) -> bool:
"""값의 변화를 추적할지 여부를 결정하는 메서드
추적 조건:
* 첫 번째 데이터
* current_value 와 같지 않은 경우
"""
try:
current_value = instance.__dict__[self._name]
except KeyError: # [3]
return True
return value != current_value # [4]
def _set_default(self, instance):
instance.__dict__.setdefault(self.trace_attribute_name, []) # [6]
class Traveller:
current_city = HistoryTracedAttribute("cities_visited") # [1]
def __init__(self, name, current_city):
self.name = name
self.current_city = current_city # [5]
1. 속성의 이름은 디스크립터에 할당된 변수 중 하나로 여기서는 current_city 이다. 그리고 이에 대한 추적을 저장할 변수 이름을 디스크립터로 전달한다. 위 예에서는 cities_visited 라는 속성에 current_city 의 모든 값을 추적하도록 지시한다.
2. 디스크립터를 처음으로 호출할 때는 추적 값이 존재하지 않을 것이므로 나중에 추가할 수 있도록 비어 있는 리스트로 초기화한다.
3. 처음 Traveller 를 호출할 때는 방문지가 없으므로 인스턴스 사전에서 current_city 의 키도 존재하지 않을 것이다. 이런 경우도 새로운 여행지가 생긴 것이므로 추적의 대상이 된다. 2번의 목록 초기화와 비슷한 이유이다.
4. 새 값이 현재 설정된 값과 다른 경우에만 변경 사항을 추적한다.
5. Traveller 의 __init__ 메서드에서 디스크립터가 이미 생성된 단계이다. 할당 명령은 2단계 값을 추적하기 위한 빈 리스트를 생성하고, 3단계를 실행하여 리스트에 값을 추가하고 나중에 검색하기 위한 키를 설정한다.
6. 사전의 setdefault 메서드는 KeyError 를 피하기 위해 사용된다. setdefault 는 두 개의 파라미터를 받는데 첫 번째 파라미터의 키가 있으면 해당 값을 반환하고 없으면 두 번째 파라미터를 반환한다.
디스크립터의 코드가 다소 복잡한 것은 사실이다. 반면에 클라이언트 클래스의 코드는 상당히 간단해졌다.(Traveller) 따라서 이 디스크립터를 여러 번 사용한다면 앞서 살펴본 것처럼 충분히 가치가 있을 것이다.
그렇다면 디스크립터가 클라이언트 클래스와 완전히 독립적인가?
디스크립터 안에서는 어떠한 비즈니스 로직도 포함되어 있지 않다. 따라서 완전히 다른 어떤 클래스에서 적용하여도 같은 효과를 낼 것이다. 이것이 파이썬스러운 디스크립터의 특징이다. 디스크립터는 비즈니스 로직의 구현보다는 라이브러리, 프레임워크 또는 내부 API 를 정의하는데 적합하다.
3-2. 다른 형태의 디스크립터
디스크립터의 구현 방법을 생각하기 전에 디스크립터의 특성과 관련된 문제를 먼저 이해해야 한다. 먼저 전역 상태 공유(global shared state) 문제에 대해 알아본다.
# 전역 상태 공유 이슈
디스크립터는 살펴본 바와 같이 클래스 속성으로 설정해야 한다. 이 때 몇 가지 고려사항이 있다.
클래스 속성의 문제점은 이들이 해당 클래스의 모든 인스턴스에서 공유된다는 점이다. 디스크립터도 예외가 아니기 때문에 디스크립터 객체에 데이터를 보관하면 모든 객체가 동일한 값에 접근할 수 있다.
class SharedDataDescriptor:
def __init__(self, initial_value):
self.value = initial_value
def __get__(self, instance, owner):
if instance is None:
return self
return self.value
def __set__(self, instance, value):
self.value = value
class ClientClass:
descriptor = SharedDataDescriptor("first value")
위 예에서 디스크립터 객체는 데이터 자체를 바로 저장한다. 이것은 인스턴스의 값을 수정하면 같은 클래스의 다른 모든 인스턴스에서도 값이 수정된다는 것을 의미한다.
>>> client1 = ClientClass()
>>> client1.descriptor
'first value'
>>> client2 = ClientClass()
>>> client2.descriptor
'first value'
>>> client2.descriptor = "value for client 2"
>>> client2.descriptor
'value for client 2'
>>> client1.descriptor
'value for client 2'
한 객체의 값을 변경하면 갑자기 모든 객체의 값이 한꺼번에 변경되는 것을 볼 수 있다. 이것은 ClientClass.descriptor 가 고유하기 때문이다.
이를 의도하여 디스크립터를 만드는 경우도 있겠지만, 객체를 구별하는 것이 일반적이다.
문제를 해결하기 위해서는 디스크립터는 각 인스턴스의 값을 보관했다가 반환해야 한다. 이것이 각 인스턴스의 __dict__ 사전에 값을 설정하고 검색하는 이유이다.
# 객체의 사전에 접근하기
디스크립터 객체의 사전 __dict__ 에 값을 저장하고 조회한다.
항상 인스턴스의 __dict__ 속성에서 데이터를 저장하고 조회한다.
# 약한 참조 사용
__dict__ 를 사용하지 않으려는 경우 또 다른 대안은 디스크립터 객체가 직접 내부 매핑을 통해 각 인스턴스의 값을 보관하고 반환하는 것이다. 이렇게 하는 것에는 주의사항이 있다. 내부 매핑을 할 때 사전을 사용하면 안 된다. 클라이언트 클래스는 디스크립터에 대한 참조를 가지며 디스크립터는 디스크립터를 사용하는 객체에 대한 참조를 가지므로 순환 종속성이 생겨 결과적으로 가비지 컬랙션이 되지 않는 문제가 있다.
이를 해결하기 위해 weakref 모듈을 사용한다.
from weakref import WeakKeyDictionary
class DescriptorClass:
def __init__(self, initial_value):
self.value = initial_value
self.mapping = WeakKeyDictionary()
def __get__(self, instance, owner):
if instance is None:
return self
return self.mapping.get(instance, self.value)
def __set__(self, instance, value):
self.mapping[instance] = value
class ClientClass:
descriptor = DescriptorClass("default value")
이렇게 하면 문제가 해결되지만 몇 가지 고려사항이 있다.
- 인스턴스 객체는 더 이상 속성을 보유하지 않는다. 대신 디스크립터가 속성을 보유한다. 이러한 사실을 잊으면 객체의 사전에 있는 내용을 찾으려고 할 수 있으나 객체는 속성을 보유하지 않았기 때문에 완전한 데이터를 반환하지 않을 것이다.
- 객체는 __hash__ 메서드를 구현하여 해시 가능해야 한다. 만약 해시가 가능하지 않다면 WeakKeyDictionary 에 매핑할 수 없다.
이러한 이유로 지금까지 각 인스턴스의 __dict__ 사전을 사용하여 디스크립터가 값을 저장하는 방식으로 구현하였다.
3-3. 디스크립터에 대한 고려 사항
디스크립터를 사용하는 것이 좋은 선택일 경우 어떤 일을 할 수 있는지 알아본다.
# 코드 재사용
디스크립터는 코드 중복을 피하기 위한 일반적인 도구이자 강력한 추상화 도구이다. 디스크립터가 필요한 곳을 찾는 가장 좋은 방법은 프로퍼티가 필요한 구조가 반복되는 경우를 찾는 것이다.
프로퍼티는 단지 디스크립터의 특수한 경우일 뿐이다. 즉 디스크립터는 프로퍼티보다 훨씬 복잡한 작업에 사용될 수 있음을 뜻한다.
디스크립터는 데코레이터가 클래스 메서드에서도 동작할 수 있도록 도와 더 나은 데코레이터를 만들 수 있게 한다.
특별한 이유가 있거나 코드를 훨씬 개선하는 효과가 있는 것이 아니라면, 디스크립터에 비즈니스 로직을 넣지 않는 것이 좋다.
# 클래스 데코레이터 피하기
@Serialization(
username=show_original,
password=hide_field,
ip=show_original,
timestamp=format_time,
)
@dataclass
class LoginEvent:
username: str
password: str
ip: str
timestamp: datetime
두 개의 데코레이터를 사용하여 구현한 위의 예제를 디스크립터를 사용하여 개선할 수 있다.
주요 내용은 각 속성의 값에 대해 요건에 맞게 변환 후에 수정된 버전을 반환하는 디스크립터를 만드는 것이다.
class BaseFieldTransformation:
"""속성의 값을 변환하여 반환하는 디스크립터"""
def __init__(self, transformation: Callable[[Any, str], str]) -> None:
self._name = None
self.transformation = transformation
def __get__(self, instance, owner):
if instance is None:
return self
raw_value = instance.__dict__[self._name]
return self.transformation(raw_value)
def __set_name__(self, owner, name):
self._name = name
def __set__(self, instance, value):
instance.__dict__[self._name] = value
ShowOriginal = partial(BaseFieldTransformation, transformation=lambda x: x)
HideField = partial(
BaseFieldTransformation, transformation=lambda x: "**redacted**"
)
FormatTime = partial(
BaseFieldTransformation,
transformation=lambda ft: ft.strftime("%Y-%m-%d %H:%M"),
)
위 예제는 하위 클래스를 추가 생성하는 방법으로 functools.partial 을 사용하고 있다.
class LoginEvent:
username = ShowOriginal()
password = HideField()
ip = ShowOriginal()
timestamp = FormatTime()
def __init__(self, username, password, ip, timestamp):
self.username = username
self.password = password
self.ip = ip
self.timestamp = timestamp
def serialize(self):
return {
"username": self.username,
"password": self.password,
"ip": self.ip,
"timestamp": self.timestamp,
}
위 코드를 실행하면 다음과 같이 출력된다.
>>> le = LoginEvent("usr", "secret password", "127.0.0.1", datetime(2016, 7, 20, 15, 45))
>>> vars(le)
{'username': 'usr', 'password': 'secret password', 'ip': '127.0.0.1', 'timestamp': datetime.datetime(2016, 7, 20, 15, 45)}
>>> le.serialize()
{'username': 'usr', 'password': '**redacted**', 'ip': '127.0.0.1', 'timestamp': '2016-07-20 15:45'}
>>> le.password
'**redacted**'
데코레이터를 사용한 이전 구현과 비교하면 몇 가지 차이점이 있다. 이 예제에서는 serialize() 메서드를 추가하고 필드를 결과 사전에 표시하기 전에 숨겼다. 그러나 이 순간에도 메모리의 이벤트 인스턴스에서 변환을 적용하지 않은 원래의 값을 알 수 있다. 물론 미리 변환 값을 저장하고 가져올 때 그대로 가져올 수도 있다.
애플리케이션의 민감도에 따라 허용되거나 허용되지 않을 수도 있지만, 이번 예제에서는 객체의 public 속성을 요청하면 디스크립터가 결과를 보여주기 전에 변환 작업을 적용한다. 객체의 __dict__ 사전에 접근하여 원본 값을 가져올 수도 있다.
이 예제에서 모든 디스크립터는 기본 클래스에 정의된 공통 논리를 따른다. 디스크립터는 객체에 값을 저장한 다음에 정의된 변환 로직에 따라 값을 반환한다. 이를 활용하여 클래스마다 고유한 변환 함수를 갖도록 클래스 계층 구조를 만들 수도 있다.
디스크립터는 객체이므로 모델을 만들어서 객체 지향 프로그래밍의 모든 규칙을 적용할 수 있다. 이 예제는 SOLID 원칙의 OCP 를 따른다. 새로운 변환 기능을 추가할 때 기본 클래스를 수정하지 않고 파생 클래스를 만들면 되기 때문이다.
__init__(), serialize() 메서드를 구현한 기본 클래스를 만들고, 그것을 상속받아 NewLoginEvent 클래스를 간단히 정의할 수 있다.
class BaseEvent:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
def serialize(self):
return {
attr: getattr(self, attr) for attr in self._fields_to_serialize()
}
def _fields_to_serialize(self):
for attr_name, value in vars(self.__class__).items():
if isinstance(value, BaseFieldTransformation):
yield attr_name
class NewLoginEvent(BaseEvent):
username = ShowOriginal()
password = HideField()
ip = ShowOriginal()
timestamp = FormatTime()
이렇게 코드를 작성하면 클래스가 훨씬 깔끔해보인다. 필요한 속성만 정의하면 되고 각 속성의 클래스를 보면 어떤 로직이 적용되었는지 바로 이해할 수 있다. 기본 클래스는 공통 메서드만 추상화할 것이고, 결과적으로 각 이벤트 클래스는 더 작고 간단하게 된다.
각 이벤트 클래스가 단순해질 뿐 아니라, 디스크립터 자체도 매우 작아서 클래스 데코레이터보다 훨씬 간단하다. 클래스 데코레이터를 사용한 원래의 방식보다 더 뛰어나다.
4. 디스크립터 분석
디스크립터를 사용하여 구현이 깨끗해지고 정확해졌다는 것을 어떻게 알 수 있을까? 좋은 디스크립터의 기준은 무엇인가? 적절히 디스크립터를 사용하는 것일까 아니면 오버 엔지니어링일까?
4-1. 파이썬 내부에서의 디스크립터 활용
어떤 것이 좋은 디스크립터인지 확인하는 가장 간단한 방법은 다른 훌륭한 파이썬 객체와 얼마나 유사한지를 보는 것이다. 좋은 디스크립터는 파이썬 자체의 디스크립터와 동일하다. 파이썬 내부 로직의 일부를 해결하기 위해 디스크립터를 사용하는 가장 일반적인 시나리오를 살펴본다.
# 함수와 메서드
디스크립터 객체 중에 가장 좋은 예는 함수이다. 함수는 __get__ 메서드를 구현했기 때문에 클래스 안에서 메서드처럼 동작할 수 있다.
메서드는 추가 파라미터를 가진 함수일 뿐이다.
class MyClass:
def method(self, ...):
self.x = 1
이는 실제로 다음과 같이 정의하는 것과 같다.
class MyClass: pass
def method(myclass_instance, ...):
myclass_instance.x = 1
method(MyClass())
따라서 메서드는 객체를 수정하는 또 다른 함수일 뿐이며, 객체 안에서 정의되었기 때문에 객체에 바인딩되어 있다고 말한다.
instance = MyClass()
instance.method(...)
위와 같이 호출하는 것은 실제로 파이썬이 다음과 같이 처리하는 것과 같다.
instance = MyClass()
MyClass.method(instance, ...)
이는 파이썬에 의해 디스크립터의 도움을 받아 내부적으로 처리되는 구문 변환일 뿐이다.
함수는 디스크립터 프로토콜을 구현했으므로 메서드를 호출하기 전에 __get__() 메서드가 먼저 호출되고 필요한 변환을 한다.
>>> def function(): pass
...
>>> function.__get__
<method-wrapper '__get__' of function object at 0x107dc5830>
instance.method(...) 구문에서는 괄호 안의 인자를 처리하기 전에 "instance.method" 부분이 먼저 평가된다.
method는 클래스 속성으로 정의된 객체이고 __get__ 메서드가 있기 때문에 __get__ 메서드가 호출된다. 그리고 __get__ 메서드가 하는 일은 함수를 메서드로 변환하는 것이다. 즉 함수를 작업하려는 객체의 인스턴스에 바인딩한다.
파이썬에 내부적으로 하는 일에 대해 좀 더 자세히 알아볼 수 있는 예제를 살펴본다.
class Method:
def __init__(self, name):
self.name = name
def __call__(self, instance, arg1, arg2):
print(f"{self.name}: {instance} called with {arg1} and {arg2}")
class MyClass1:
method = Method("Internal call")
외부에서 호출 가능한 형태의 함수 또는 메서드를 클래스 내에 호출 가능한 객체로 정의한다. 위의 코드에 대한 다음 두 가지 호출은 동일한 역할을 해야 한다.
>>> instance = MyClass1()
>>> Method("External call")(instance, "first", "second")
External call: <__main__.MyClass1 object at 0x107bd5e90> called with first and second
>>> instance.method("first", "second")
Traceback (most recent call last):
File "<input>", line 1, in <module>
TypeError: __call__() missing 1 required positional argument: 'arg2'
첫 번째는 동작하지만, 두 번째는 동작하지 않는다. 그 이유는 데코레이터를 메서드에 사용했을 경우 에러가 발생했던 것과 같은 이유이다. 파라미터의 위치가 한 칸씩 밀려서 Method.__call__ 기준으로 self 자리에 instance 가 전달되고, instance 자리에 "first" 가 전달되고, arg1 자리에 "second" 가 전달되지만, arg2 자리에 아무것도 전달되지 않기 때문이다.
이는 MyClass.method(instance, "first", "second") 를 호출한 것과 같다.
이를 해결하기 위해서는 메서드를 디스크립터로 변경하면 된다. 그렇게 하면 instace.method 호출 시 Method.__get__ 메서드를 먼저 호출할 것이다. 여기에서 첫 번재 파라미터로 Method 의 인스턴스를 전달함으로써 객체에 바인딩하면 된다.
from types import MethodType
class NewMethod:
def __init__(self, name):
self.name = name
def __call__(self, instance, arg1, arg2):
print(f"{self.name}: {instance} called with {arg1} and {arg2}")
def __get__(self, instance, owner):
if instance is None:
return self
return MethodType(self, instance)
class MyClass2:
method = NewMethod("Internal call")
>>> instance2 = MyClass2()
>>> NewMethod("External call")(instance2, "first", "second")
External call: <__main__.MyClass2 object at 0x107bd3950> called with first and second
>>> instance2.method("first", "second")
Internal call: <__main__.MyClass2 object at 0x107bd3950> called with first and second
수정한 부분은 types 모듈의 MethodType 을 사용하여 함수를 메서드로 변환하는 것이다. MethodType 클래스의 첫 번째 파라미터는 호출 가능한 것이어야 한다. 여기서는 self 인데 self 는 NewMethod 의 인스턴스로 __call__ 메서드를 구현했으므로 호출 가능한 형태이다. 두 번째 파라미터는 이 함수에 바인딩할 객체이다.
파이썬의 함수 객체도 이것과 비슷하게 동작한다. 따라서 클래스 내부에 함수를 정의할 경우 메서드처럼 사용할 수 있는 것이다.
예를 들어 사용자 정의 콜러블 객체를 정의할 때는 지금처럼 디스크립터로 만들어서 클래스 속성으로도 사용할 수 있도록 하는 것이 좋다.
# 메서드를 위한 빌트인 데코레이터
@property, @classmethod, @staticmethod 데코레이터는 디스크립터이다.
메서드를 인스턴스가 아닌 클래스에서 직접 호출할 때는 관습적으로 디스크립터 자체를 반환한다. 프로퍼티를 클래스에서 직접 호출하면 계산할 속성이 없으므로 일종의 디스크립터인 프로퍼티 객체 자체를 반환한다.
@classmethod 를 사용하면 디스크립터의 __get__ 함수가 메서드를 인스턴스에서 호출하든 클래스에서 직접 호출하든 상관없이 데코레이팅 함수에 첫 번째 파라미터로 메서드를 소유한 클래스를 넘겨준다. @staticmethod 를 사용하면 정의한 파라미터 이외의 파라미터를 넘기지 않도록 한다. 즉 __get__ 메서드에서 함수의 첫 번째 파라미터에 self 를 직접 바인딩하는 작업을 취소한다.
예를들어 @property 데코레이터처럼 동작하지만 클래스를 대상으로 한다는 것만 다른 @classproperty 데코레이터를 만든다고 가정했을 때, 다음과 비슷한 코드가 잘 동작해야 한다.
class classproperty:
def __init__(self, fget):
self.fget = fget
def __get__(self, instance, owner):
return self.fget(owner)
def read_prefix_from_config():
return ""
class TableEvent:
schema = "public"
table = "user"
@classproperty
def topic(cls):
prefix = read_prefix_from_config()
return f"{prefix}{cls.schema}.{cls.table}"
>>> TableEvent.topic
'public.user'
>>> TableEvent().topic
'public.user'
# 슬롯(slot)
클래스에 __slot__ 속성을 정의하면 클래스가 기대하는 특정 속성만 정의하고 다른 것은 제한할 수 있다.
__slot__ 에 정의되지 않은 속성을 동적으로 추가하려고 하는 경우 AttributeError 가 발생한다. 이 속성을 정의하면 클래스는 정적으로 되고 __dict__ 속성을 갖지 않는다. 따라서 객체에 동적으로 속성을 추가할 수 없다.
그렇다면 객체의 사전 없이 어떻게 속성을 가져올 수 있을까? 디스크립터를 사용한다. __slot__ 에 정의된 이름마다 디스크립터를 만들어서 값을 저장하고 있으므로 나중에 검색도 가능하다.
class Coordinate2D:
__slots__ = ("lat", "long")
def __init__(self, lat, long):
self.lat = lat
self.long = long
def __repr__(self):
return f"{self.__class__.__name__}({self.lat}, {self.long})"
>>> coord = Coordinate2D(1, 2)
>>> repr(coord)
'Coordinate2D(1, 2)'
파이썬의 동적인 특성을 없애기 때문에 주의하여 사용해야 한다. 일반적으로 정적인 객체에만 사용하며 다른 부분에서 절대로 동적으로 속성을 추가할 일이 없다는 것을 확신할 때에만 사용해야 한다.
슬롯을 사용한 객체의 장점은 메모리를 덜 사용한다는 점이다. 왜냐하면 사전 형태가 아닌 고정된 필드의 값만 저장하면 되기 때문이다.
4-2. 데코레이터를 디스크립터로 구현하기
디스크립터를 사용하여 파이썬이 함수를 메서드로 만드는 문제를 해결한 것과 마찬가지로 사용자 정의 데코레이터 개발 중 발생하는 문제를 해결할 수 있다. 일반적인 방법은 __get__ 메서드를 구현하고 types.MethodType 을 사용해 데코레이터 자체를 객체에 바인딩된 메서드로 만드는 것이다.
이렇게 하려면 데코레이터를 객체로 구현해야 한다. 만약 함수로 구현하는 경우 __get__() 메서드가 이미 존재할 것이기 때문에 정상적으로 동작하지 않게 된다. 더 깔끔한 방법은 데코레이터를 위한 클래스를 정의하는 것이다.
클래스 메서드에도 적용 가능한 데코레이터를 만들려면 데코레이터 클래스에 __get__() 메서드를 구현해야 한다.
5. 요약
디스크립터는 파이썬의 경계를 보다 메타프로그래밍(metaprogramming) 에 가깝게 해주는 고급 기능이다.
메타프로그래밍은 프로그램을 데이터처럼 다룰 수 있게 하는 프로그래밍 기법을 뜻한다. 다른 프로그램을 읽고, 생성하고, 분석하고, 변형시키고 심지어 런타임 중에도 수정할 수 있게 한다.
디스크립터 활용의 가장 효과적인 점은 파이썬의 클래스는 일반 객체일 뿐이므로 속성을 갖고 속성과 상호 교류할 수 있다는 점을 명확하게 해준다는 것이다. 디스크립터 프로토콜은 보다 진보된 형태의 객체 지향 기능을 활용하도록 한다.
디스크립터를 활용함으로써 강력한 추상화를 통해 깔끔한 클래스를 만들 수 있다.
디스크립터는 강력한 장점을 가지는 만큼 불필요한 오버 엔지니어링에 사용되지 않도록 주의해야 한다. 이러한 측면에서 디스크립터는 내부 API 개발이나 라이브러리 또는 프레임워크 디자인과 같은 일반적인 경우에 대해서만 사용해야 한다. 또 다른 중요한 고려 사항은 디스크립터에는 비즈니스 로직을 구현한 컴포넌트에서 사용하기 위한 기술적인 기능 구현만을 포함하고 비즈니스 로직 자체를 포함하면 안된다는 것이다.
'python' 카테고리의 다른 글
파이썬 클린 코드 - 8장 (단위 테스트와 리팩토링) (0) | 2022.03.16 |
---|---|
파이썬 클린 코드 - 7장 (제너레이터) (0) | 2022.03.10 |
파이썬 클린 코드 - 5장 (데코레이터를 사용한 코드 개선) (0) | 2022.02.18 |
파이썬 클린 코드 - 4장 (SOLID 원칙) (0) | 2022.02.09 |
파이썬 클린 코드 - 3장 (좋은 코드의 일반적인 특징) (0) | 2022.02.03 |