[ python ] 06. 디스크립터로 더 멋진 객체 만들기_(1)

박찬영·2024년 5월 7일

파이썬 클린 코드

목록 보기
14/19

파이썬 클린 코드

디스크립터 개요

디스크립터는 파이썬의 객체지향 수준을 한 단계 더 끌어올려주는 혁신적인 기능으로 이 기능을 잘 활용하면 보다 견고하고 재사용성이 높은 추상화를 할 수 있다.

디스크립터 메커니즘

디스크립터를 구현하려면 최소 두 개의 클래스가 필요하다.
1. 클라이언트 클래스 : 디스크립터 구현의 기능을 활용할 도메인 모델로서 솔루션을 위해 생성한 일반적인 추상화 객체이다.
2. 디스크립터 클래스 : 디스크립터 로직의 구현체이다.

디스크립터는 단지 디스크립터 프로토콜을 구현한 클래스의 인스턴스이다. 이 클래스는 다음 매직 메서드 중에 최소 한 개 이상을 포함해야 한다.

  • __get__
  • __set__
  • __delete__
  • __set_name__

명심해야할 중요한 사실은 이 프로토콜이 동작하려면 디스크립터 객체가 클래스 속성으로 정의되어야 한다는 것이다. 이 객체를 인스턴스 속성으로 생성하면 동작하지 않으므로 init 메서드가 아니라 클래스 본문에 있어야 한다.

디스크립터 객체는 항상 클래스 속성으로 선언해야 한다.

ClientClass의 인스턴스에서 descriptor 속성을 호출하면 디스크립터 프로토콜이 사용된다. 다음 예에서 보이는 것처럼 일반적인 클래스의 속성 또는 프로퍼티에 접근하면 예상한 것과 같은 결과를 얻을 수 있다.

그러나 디스크립터의 경우 약간 다르게 동작한다. 클래스 속성을 객체로 선언하면 디스크립터로 인식되고, 클라이언트에서 해당 속성을 호출하면 객체 자체를 반환하는 것이 아니라 get 매직 메서드의 결과를 반환한다.

호출 당시의 문맥 정보를 로깅하고 클라이언트 인스턴스를 그대로 반환하는 간단한 예제를 살펴보자

class DescriptorClass:
  def __get__(self, instance, owner):
    if instance is None:
      return self
    logger.info(
        "$s.__get__메서드 호출(%r,%r)",
        self.__class__.__name__,
        instance,
        owner
    )
    return instance


class ClientClass:
  descriptor = DescriptorClass()

이제 ClinetClass 인스턴스의 descriptor 속성에 접근해보면 DescriptorClass 인스턴스를 반환하지 않고 대신에 __get__() 메서드의 반환 값을 사용한다는 것을 알 수 있다.

이 예제의 핵심은 디스크립터 속성을 조회할 경우 일반 속성을 조회할 때와 어떻게 다른지 이해하는 것이다. 디스크립터를 사용하면 __get__ 메서드 뒤쪽으로 모든 종류의 로직을 추상화할 수 있으며 클라이언트에게 세부 내용을 숨긴 채로 모든 유형의 변환을 투명하게 실행할 수 있다.

디스크립터 프로토콜의 메서드 탐색

디스크립터는 단지 객체이기 때문에 이러한 메서드들은 self를 첫 번째 파라미터로 사용한다. self는 디스크립터 객체 자신을 의미한다.

이번에는 디스크립터 프로토콜의 각 메서드에서 사용하는 파라미터와 사용 방법에 대해서 살펴보자

get 메서드

get 매직 메서드의 서명은 다음과 같다.
__get__(self, instance, owner)
두 번째 파라미터인 instance는 디스크립터를 호출한 객체를 의미한다. 앞 예제에서는 client 객체를 의미한다.
onwer 파라미터는 호출한 객체의 클래스를 의미한다.

다음 예제를 통해서 디스크립터가 클래스에서 호출될 때와 인스턴스에서 호출될 때의 차이를 알아보자.
__get__ 메서드는 각각의 경우에 대해 두 개의 개별적인 작업을 수행한다.

일반적으로 정말 owner 파라미터를 활용해야 하는 경우가 아니라면, 인스턴스가 None일 때는 단순히 디스크립터 자체를 반환한다. 이렇게 하는 이유는 클래스에서 인스턴스를 만들지 않고 직접 디스크립터에 접근하려는 경우 단순히 디스크립터 자체를 받고 싶어하는 경우가 많을 것이기 때문이다.

set 메서드

__set__(self, instance, value)
이 매직 메서드는 디스크립터에 값을 할당하려고 할 때 호출된다. 다음과 같은 코드에서 디스크립터가 __set__() 메서드를 구현한 경우에 메서드가 활성화된다. 다음 에제의 경우 instance 파라미터는 client이고 value는 "value"라는 문자열이다.

client.descriptor = "value"

만약 client.descriptor가 __set__() 메서드를 구현하지 않았다면 할당문의 오른쪽에 있는 값으로 descriptor 자체를 덮어씌울 것이다.

디스크립터 속성에 값을 할당할 때는 __set__ 메서드를 구현했는지 반드시 확인하여 부작용이 생긱지 않도록 주의해야 한다.

기본적으로 이 메서드는 단지 객체에 값을 저장하는 것이지만 이 기능을 잘 활용하면 강력한 추상화를 할 수 있다. 예를 들어, 자주 사용되는 유효성 검사 객체를 디스크립터로 만들면 프로퍼티의 세터 메서드에서 같은 유효성 검사를 반복할 필요가 없다.

from typing import Callable, Any


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)),"는 숫자가 아님",),
        Validation(lambda x: x > 0, "는 0보다 작음"),
    )

delete 메서드

delete의 서명은 더 간단한 형태를 갖는다.
__delete__(self, instance)
이 메서드는 다음과 같은 형태로 호출된다. self는 descriptor 속성을 나타내고 instance는 client를 나타낸다.

del clinet.descriptor
이 메서드를 사용하여 관리자 권한이 없는 객체에서 속성을 제거하지 못하도록 하는 디스크립터를 만들어 보자

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}를 None으로 설정할 수 없음")
    user.__dict__[self._name] = value

  def __delete__(self, user):
    if self.permission_required in user.permission:
      user.__dict__[self._name] = None

    else:
      raise ValueError(
          f"{self._name}{self.permission_required} 권한이 필요함"

      )

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.permission = permission_list or []

  def __str__(self):
    return self.username

객체가 어떻게 동작하는지 살펴보기 전에 몇 가지 디스크립터의 기준을 확인하는 것이 중요하다. User 클래스는 username과 email 파라미터를 필수로 받는다. __init__ 메서드를 보면 email 속성이 없으면 사용자를 생성할 수 없다. email 속성을 지워버리면 불완전한 객체가 되고 User 클래스에서 정의한 인터페이스와 맞지 않는 유효하지 않은 상태가 된다. 문제를 예방하기 위해서는 이 같은 세부 사항을 확인하는 것이 중요하다. User를 사용하고자 하는 객체는 email 속성이 있는 것으로 기대하고 있다.

이런 이유 때문에 email을 삭제하면 단순히 None으로 설정한다. 같은 이유로 None 값으로 설정하는 것을 금지해야한다. 왜냐하면 __delete__ 메서드의 메커니즘을 우회해버리기 때문이다.

set_name 메서드

__set__name__(self, owner, name)
일반적으로 클래스에 디스크립터 객체를 만들 때는 디스크립터가 처리하려는 속성의 이름을 알아야 한다.
속성의 이름은 __dict__에서 __get____set__메서드로 읽고 쓸 때 사용된다.

class DescriptorWithName:
	def __init__(self, name=None):
    	self.name = name
        
    def __set_name__(self, owner, name):
    	self.name = name

__set_name__은 디스크립터가 할당된 속성의 이름을 구할 때 유용하다. 그러나 다른 값으로 설정하고 싶은 경우 우선순위가 높은 __init__메서드도 사용할 수 있기 때문에 유연성을 유지할 수 있다.
디스크립터의 이름으로 무엇이든 사용할 수 있지만 일반적으로 디스크립터의 이름(속성이름)을 클라이언트 __dict__객체의 키로 사용한다. 즉, 디스크립터의 이름도 속성으로 해석된다는 것을 의미한다. 때문에 가급적 유효한 파이썬 변수명을 사용하려고 노력해야 한다.

디스크립터 이름을 직접 정의하려는 경우 유효한 파이썬 변수명을 사용해야 한다.

profile
안녕하세요 박찬영입니다.

0개의 댓글