Effective Python Item 47. 지연 계산 어트리뷰트가 필요하면 __getattr__, __getattribute__, __setattr__을 사용하라

    들어가기 전

     


    요약

    • 인스턴스 어트리뷰트에 접근(hello.value, hasattr 등)하면 항상 __getattribute__() 메서드가 호출됨. 
    • __getattribute__()를 호출했을 때, 어트리뷰트가 없으면 AttributeError가 발생함. 이 때, __getattr__() 메서드가 호출됨. 
    • 인스턴스 어트리뷰트에 값을 대입(예시, a.hello = 10)하면, __setattr__()이 항상 호출됨. 
    • __getattribute__, __getattr__, setattr__에서 무한 재귀 문제를 막기 위해 항상 super().__getattribute__() 같은 형식으로 사용해야만 한다.

     


    Item 47. 지연 계산 어트리뷰트가 필요하면 __getattr__, __getattribute__, __setattr__을 사용하라

    파이썬에서 object가 제공하는 훅을 사용하면, 좀 더 제네릭하게 동적으로 어트리뷰트를 가져오고 정의할 수 있게 된다. 기존에 존재하는 다음 방법으로는 동적 어트리뷰트를 제공할 수는 없다. 왜냐하면 미리 정의해서 사용해야하기 때문이다. 

    • 평범한 인스턴스의 어트리뷰트
    • @property 메서드 
    • 디스크립터 

    이 때, object 클래스가 제공하는 __getattribute__, __getattr__, __setattr__을 사용하면 동적 어트리뷰트 할당 / 조회 기능을 사용할 수 있다. 

    # 인스턴스의 어트리뷰트에 접근하면 항상 __getattribute__()가 호출됨.
    # __getattribute__()에서 어트리뷰트를 찾을 수 없으면 __getattr__()이 호출됨.
    # __getattr__()에서 setattr()로 인스턴스에 어트리뷰트를 동적으로 셋팅하고, 그 값을 반환할 수 있음.
    # 즉, 이 메타 메서드를 이용하면 동적으로 인스턴스의 어트리뷰트를 추가할 수 있음. 
    
    
    class Hello:
    
        def __init__(self):
            self.hello = 'hello100'
    
        def __getattribute__(self, item):
            print('__getattribute__ called.')
            value = super().__getattribute__(item)
            return value
    
        def __getattr__(self, item):
            print('__getattr__ called.')
            value = f'{item}을 위한 값'
            setattr(self, item, value)
            return value
    
    h = Hello()
    print(h.hello)
    print(h.a)
    
    >>>
    __getattribute__ called.
    hello100
    __getattribute__ called.
    __getattr__ called.
    a을 위한 값

    간단한 사용법은 위의 예시에서 살펴볼 수 있고, 호출 결과도 함께 확인할 수 있다. 

    1. hello 어트리뷰트는 있기 때문에 __getattribute__만 호출됨. 
    2. a 어트리뷰트를 조회할 때 __getattribute__가 호출되었으나, 그 값이 없기 때문에 __getattr__이 호출된다. __getattr__ 내에서 setattr()을 이용해서 인스턴스에 어트리뷰트를 셋팅할 수 있다. 

    주요 유즈 케이스는 다음과 같다.

    • 동적 어트리뷰트 접근 : 외부 데이터 소스로부터 동적으로 속성을 생성하고 싶은 경우
    • 어트리뷰트 접근 로깅 : 개발 작업 시, Attribute에 접근하는 것을 추적하고 싶을 때 사용할 수 있음. 
    • 속성 접근 제어 : 특정 경우에는 Attribute에 접근하지 못하도록 함. 
    • 속성 접근 시 전처리 : 모든 속성 접근 시, 특정 로직을 실행하고 싶을 때 사용할 수 있음. 
    • 프록시 패턴 구현 : 모든 속성에 접근할 때, 프록시 객체를 통해서 처리하도록 후킹 포인트를 가져갈 수 있음. 

    이 때, __getattribute__를 사용하는 것은 예기치 않은 부작용 (무한 재귀 호출 문제), 성능 저하를 불러 일으킬 수 있기 때문에 항상 조심해야한다. 아래에서 자세한 유즈 케이스를 살펴보자. 


    코드 1. getattr(). 

    class LazyRecord:
        def __init__(self):
            self.exists = 5
    
        def __getattr__(self, name):
            value = f'{name}를 위한 값'
            setattr(self, name, value)
            return value
    
    data = LazyRecord()
    print(f'이전: {data.__dict__}')
    print(f'foo: {data.foo}')
    print(f'이후: {data.__dict__}')
    
    

    위 코드는 다음과 같이 동작한다. 

    1. foo 어트리뷰트에 처음 접근할 때 존재하지 않는 값임. __getattr__이 호출됨.
    2. __getattr__ 내에서 setattr()로 값을 셋팅하고, 그 값은 반환함. 
    3. __dict__를 다시 살펴보면, foo 어트리뷰트 값이 존재하는 것을 확인할 수 있음. 
    # 이전: {'exists': 5}
    # foo: foo를 위한 값
    # 이후: {'exists': 5, 'foo': 'foo를 위한 값'}

    위 코드의 출력 결과는 다음과 같다. 

     


    코드2. 상속에서 __getattr__() 

    class LazyRecord:
        def __init__(self):
            self.exists = 5
    
        def __getattr__(self, name):
            value = f'{name}를 위한 값'
            setattr(self, name, value)
            return value
    
    class LoggingLazyRecord(LazyRecord):
        def __getattr__(self, name):
            print(f'* 호출: __getattr__({name!r})',
                  f'인스턴스 딕셔너리 채워 넣음')
            result = super().__getattr__(name)
            print(f'* 반환: {result!r}')
            return result
    
    
    data = LoggingLazyRecord()
    print(f'이전: {data.__dict__}')
    print(f'foo: {data.foo}')
    print(f'이후: {data.__dict__}')

    위 코드는 상속 구조에서 __getattr__()을 사용하는 방법을 나타낸다. 

    1. LoggingLazyRecord()의 __getattr__()이 호출된다. 
    2. 이 때 super().__getattr__()이 호출되면서 LazyRecord의 __getattr__()이 호출된다.
    3. LazyRecord.__getattr__()에서 반환된 value가 result에 전달되고, result가 반환됨. 

    여기서 주의할 점은 __getattr__()에서는 무한 재귀를 막기 위해 super().__getattr__()를 사용해야한다는 점이다. 만약 __getattr__() 내에서 self.foo로 접근하면, 다시 한번 __getattr__()이 호출된다. 따라서 무한 재귀가 반복되는 큰 문제가 생기기 때문에 super.__getattr__()를 호출해야한다. 

    이전: {'exists': 5}
    * 호출: __getattr__('foo') 인스턴스 딕셔너리 채워 넣음
    * 반환: 'foo를 위한 값'
    foo: foo를 위한 값
    이후: {'exists': 5, 'foo': 'foo를 위한 값'}

    코드의 호출결과는 위와 같다.

     


    코드3. __getattribute__() 사용하기

    class ValidatingRecord:
        def __init__(self):
            self.exists = 5
    
        def __getattribute__(self, name):
            print(f'* 호출: __getattribute__({name!r})')
            try:
                value = super().__getattribute__(name)
                print(f'* {name!r} 찾음, {value!r} 반환')
                return value
            except AttributeError:
                value = f'{name}를 위한 값'
                print(f'* {name!r}를 {value!r}로 설정')
                setattr(self, name, value)
                return value
    
    data = ValidatingRecord()
    print(f'이전: {data.__dict__}')
    print(f'foo: {data.foo}')
    print(f'이후: {data.__dict__}')

    위 코드는 __getattribute__()를 사용한 예시를 보여준다. 

    1. data.foo가 호출될 때 마다 __getattribute__()가 호출됨.
    2. data.foo로 처음 접근했을 때,  __getattribute__()가 호출된다. 이 때, 재귀를 막기 위해 super().__getattribute__()가 호출됨.
    3. 그러나 foo라는 어트리뷰트가 없기 때문에 super().__getattribute__()의 결과로 AttributeError가 발생함. 
    4. Except 절에서 setattr()를 호출해서 어트리뷰트를 설정하고 그 값을 반환함. 
    * 호출: __getattribute__('__dict__')
    * '__dict__' 찾음, {'exists': 5} 반환
    이전: {'exists': 5}
    * 호출: __getattribute__('foo')
    * 'foo'를 'foo를 위한 값'로 설정
    foo: foo를 위한 값
    * 호출: __getattribute__('__dict__')
    * '__dict__' 찾음, {'exists': 5, 'foo': 'foo를 위한 값'} 반환
    이후: {'exists': 5, 'foo': 'foo를 위한 값'}

    위 코드의 호출 결과를 살펴보면 다음과 같다. 위 코드에서 어트리뷰트에 총 3번 접근했는데, 로그에서 확인할 수 있듯이 __getattribute__()는 총 세번 호출된 것을 알 수 있다. 

     


    코드4. hasattr()도 __getattribute__()를 호출함.

    class LazyRecord:
        def __init__(self):
            self.exists = 5
    
        def __getattr__(self, name):
            value = f'{name}를 위한 값'
            setattr(self, name, value)
            return value
    
    data = LazyRecord()
    print(f'이전: {data.__dict__}')
    print(f'최초에 foo가 있나:', hasattr(data, 'foo'))
    print(f'이후: {data.__dict__}')
    print(f'다음에 foo가 있나:', hasattr(data, 'foo'))

    hasattr() 메서드를 이용해 인스턴스/클래스에 어트리뷰트가 존재하는지 확인할 때가 있다. hasattr() 역시 기본적으로 __getattribute__()를 호출하는 형태로 동작한다. 

    이전: {'exists': 5}
    최초에 foo가 있나: True
    이후: {'exists': 5, 'foo': 'foo를 위한 값'}
    다음에 foo가 있나: True

    따라서 위 코드의 실행 결과는 다음과 같다.

    1. hasattr()을 호출하는 순간, 어트리뷰트가 없어 AttributeError가 발생하고 __getattr__()이 호출된다. 
    2. 여기서 'foo' 어트리뷰트에 대한 값이 셋팅되고, '최초에 foo가 있나'라는 물음은 true가 된다. 

     


    코드5. __setattr__()

    class SavingRecord:
    
        def __setattr__(self, key, value):
            print('set attr 호출')
            super().__setattr__(key, value)
    
    
    a = SavingRecord()
    a.q = 10

    위 코드는 __setattr__()을 사용한 예시다. 

    1. 어트리뷰트에 값을 대입할 때 마다 항상 __setattr__()이 호출된다.
    2. 만약 이 부분을 후킹하고 싶다면 __setattr__()을 구현하면 된다.
    3. 이 때, 무한 재귀등의 문제를 막기 위해서 super().__setattr__()을 호출하도록 한다.
    set attr 호출

    이 코드의 호출 결과는 다음과 같다. 

    댓글

    Designed by JB FACTORY