Effective Python Item 51. 합성 가능한 클래스 확장이 필요하면 메타클래스보다는 클래스 데코레이터를 사용하라.

    들어가기 전

     


    요약

    • 클래스 데코레이터는 class 인스턴스를 파라미터로 받아서 이 클래스를 변경한 클래스나 새로운 클래스를 반환해주는 간단한 함수다.
    • 준비 코드를 최소화하면서 클래스 내부의 모든 메서드나 어트리뷰트를 변경하고 싶을 때, 클레스 데코레이터가 유용하다.
    • 메타 클래스는 서로 쉽게 합성할 수 없지만, 여러 클래스 데코레이터를 충돌 없이 사용해 똑같은 클래스를 확장할 수 있다. 
    • 클래스 전체적으로 데코레이터 사용하는 방법
      • 클래스 함수마다 필요한 데코레이터 붙이기
        • 가독성에 문제 있음 / 데코레이터 실수로 놓칠 수 있음 / 부모 클래스에 메서드가 추가되면, 데코레이터 누락됨.
      • 메타 클래스를 이용해 데코레이터 붙이기
        • 상속 구조에서 부모 / 자식 클래스가 각각 메타 클래스를 사용하는 경우 메타 클래스 Conflict 발생해서 확장이 제한됨.
      • 클래스 데코레이터 이용해서 붙이기
        • 위에서 이야기한 모든 단점들을 상쇄시킴. 

     


    Item 51. 합성 가능한 클래스 확장이 필요하면 메타클래스보다는 클래스 데코레이터를 사용하라.

    클래스의 모든 메서드에 데코레이터를 적용하고 싶은 경우가 있다. 이럴 때는 아래 세 가지 방법을 고려해 볼 수 있다. 

    1. 모든 메서드에 데코레이터 선언하기
    2. 메타 클래스를 이용해 데코레이터 붙이기
    3. 클래스 데코레이터를 이용해 데코레이터 붙이기 

    각 방법에는 장/단점이 존재할 것이지만 단점을 위주로 살펴보면, '클래스 데코레이터를 이용해 데코레이터 붙이기'가 가장 좋은 방법이다. 

     

    모든 메서드에 데코레이터 선언

    - 데코레이터를 여기저기 많이 붙이니 코드 가독성이 떨어짐. 
    - 특정 함수에 데코레이터 붙이는 것을 까먹을 수 있음. 
    - 부모 클래스에 메서드가 추가되는 경우, 누락됨. 

     

    메타 클래스를 이용해 데코레이터 붙이는 것

    - 부모 클래스, 자식 클래스가 서로 다른 메타 클래스를 사용하는 경우 Meta Class Conflict 발생. 
    - Meta 클래스 상속을 통해 해결할 수 있지만, 라이브러리 코드인 경우 불가능함. 

     

     

    이런 문제점들이 존재하는데, 클래스 데코레이터를 사용하면 이 모든 문제를 해결할 수 있게 된다. 따라서 클래스 차원에서 공통적으로 추가되어야 하는 데코레이터가 있다면, 클래스 데코레이터를 사용하도록 한다. 

    def my_class_decorator(klass):
        print('데코레이터 펑션 호출')
        klass.extra_param = '안녕'
        return klass
    
    
    @my_class_decorator
    class MyClass:
        print('클래스 호출')
        pass
    
    print('시작 전')
    print(MyClass)
    print(MyClass.extra_param)
    

    클래스 데코레이터의 사용 예시는 위 코드를 참고하면 된다. 

    1. Import 시점에 클래스 블록쪽이 먼저 호출된다. 
    2. 클래스 블록이 호출되고 난 후에, 데코레이터 함수가 호출된다. 이 때, 클래스 전체가 전달된다. 
    3. 데코레이터 함수에서 클래스를 반환해주면 된다. 
    클래스 호출
    데코레이터 펑션 호출
    시작 전
    <class '__main__.MyClass'>
    안녕

    실행 결과는 다음과 같다. 클래스 호출 → 데코레이터 펑션 → ... 순으로 진행된다. 

     


    코드1. 데코레이터를 일일이 붙여주기 

    from functools import wraps
    
    def trace_func(func):
    
        # 단 한 번만 데코레이터를 적용함.
        if hasattr(func, 'tracing'):
            return func
    
        @wraps(func)
        def wrapper(*args, **kwargs):
            result = None
            try:
                result = func(*args, **kwargs)
                return result
            except Exception as e:
                result = e
                raise
            finally:
                print(f'{func.__name__}({args!r}, {kwargs!r}) -> {result!r}')
    
        wrapper.tracing = True
        return wrapper
    
    
    class TraceDict(dict):
    
        @trace_func
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
    
        @trace_func
        def __getitem__(self, *args, **kwargs):
            return super().__getitem__(*args, **kwargs)
    
        @trace_func
        def __setitem__(self, *args, **kwargs):
            super().__setitem__(*args, **kwargs)
    
    
    trace_dict = TraceDict([('안녕', 1)])
    trace_dict['거기'] = 2
    trace_dict['안녕']
    try:
        trace_dict['존재하지 않음']
    except KeyError:
        pass

    위 코드의 설명은 다음과 같다.

    1. trace_func 라는 데코레이터를 선언한다. 이 함수는 디버깅용으로 사용된다.
    2. 파이썬 딕셔너리를 상속한 TraceDict를 선언한다. 이 때, 3개의 함수에 데코레이터를 추가했다. 
    3. trace_dict를 호출했을 때, 디버깅이 적절히 이루어지는 것을 확인하고자 한다. 
    __init__(({'안녕': 1}, [('안녕', 1)]), {}) -> None
    __setitem__(({'안녕': 1, '거기': 2}, '거기', 2), {}) -> None
    __getitem__(({'안녕': 1, '거기': 2}, '안녕'), {}) -> 1
    __getitem__(({'안녕': 1, '거기': 2}, '존재하지 않음'), {}) -> KeyError('존재하지 않음')

    위 코드를 실행해보면, 로깅이 적절하게 남겨진 것을 확인할 수 있다. 그런데 이 때 어떤 문제점이 존재할까?

    1. 함수에 데코레이터를 빼먹고 안달 수도 있음. 
    2. 데코레이터를 추가해야 할 함수가 많아지면 가독성이 떨어짐. 
    3. 파이썬 dict 클래스에 새로운 메서드가 추가되는 경우, 데코레이터가 적용되지 않음. 

    이런 단점들이 존재하기 때문에 함수마다 데코레이터를 추가해주는 것은 간단한 대응책은 될 수 있지만, 좋은 대응책이 될 수는 없다. 

     

     


    코드2. 메타 클래스로 데코레이터 넣기

    import types
    from functools import wraps
    
    trace_types = (
        types.MethodType,
        types.FunctionType,
        types.BuiltinFunctionType,
        types.BuiltinMethodType,
        types.MethodDescriptorType,
        types.ClassMethodDescriptorType)
    
    class TraceMeta(type):
    
        def __new__(meta, name, bases, class_dict):
            klass = super().__new__(meta, name, bases, class_dict)
    
            for key in dir(klass):
                value = getattr(klass, key)
                if isinstance(value, trace_types):
                    wrapped = trace_func(value)
                    setattr(klass, key, wrapped)
    
            return klass
    
    def trace_func(func):
    	...
    
    class TraceDict(dict, metaclass=TraceMeta):
        pass
    
    
    trace_dict = TraceDict([('안녕', 1)])
    trace_dict['거기'] = 2
    trace_dict['안녕']
    try:
        trace_dict['존재하지 않음']
    except KeyError:
        pass

    위 코드는 메타 클래스를 이용해 데코레이터를 추가하는 방법이다.

    1. trace_types를 이용해 데코레이터를 추가할 타입을 선언한다.
    2. TraceDict 클래스 Import가 완료되면, TraceMeta.__new__()가 호출됨.
    3. TraceMeta.__new__()에 TraceDict의 클래스 딕셔너리(어트리뷰트 관련)가 전달되고, 여기서 trace_types에 맞는 인자들이 있으면 데코레이터를 한번 감싼다

    메타 클래스의 new() 메서드로 넘겨지는 인자 중 클래스 딕셔너리가 있는데, 클래스 딕셔너리는 부모의 메서드 / 어트리뷰트들이 항상 포함된다. 따라서 부모 클래스에 새로운 메서드가 추가되더라도 데코레이터를 누락없이 추가할 수 있게 된다. 

    __new__((<class '__main__.TraceDict'>, [('안녕', 1)]), {}) -> {}
    __getitem__(({'안녕': 1, '거기': 2}, '안녕'), {}) -> 1
    __getitem__(({'안녕': 1, '거기': 2}, '존재하지 않음'), {}) -> KeyError('존재하지 않음')

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

    - 함수에 데코레이터를 빼먹고 안달 수도 있음.
    - 데코레이터를 추가해야 할 함수가 많아지면 가독성이 떨어짐.
    - 파이썬 dict 클래스에 새로운 메서드가 추가되는 경우, 데코레이터가 적용되지 않음. 

    위에서 언급했던 이 문제들이 모두 해결되는 것이다. 그러나 메타 클래스를 이용한 데코레이터 추가에도 단점은 존재한다. 부모 클래스가 이미 메타 클래스를 사용하는 경우, 확장하기 어려워진다는 것이다.

    class TraceMeta(type):
    	pass
    
    class OtherMeta(type):
        pass
    
    class SimpleDict(dict, metaclass=OtherMeta):
        pass
    
    class TraceDict(SimpleDict, metaclass=TraceMeta):
        pass
        
    >>>
    TypeError: metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its bases

    메타 클래스를 이용해 데코레이터를 추가할 때의 명확한 한계는 다음과 같다.

    1. TraceMeta, OtherMeta가 메타 클래스로 존재함.
    2. SimpleDict는 OtherMeta를 메타 클래스로 사용.
    3. TraceDict는 SimpleDict를 상속받고, TraceMeta를 메타 클래스로 사용 

    이 경우, 위에서 볼 수 있듯이 실행 결과에서 'metaclass conflict'를 볼 수 있다. 

    class OtherMeta(TraceMeta):
        pass
    
    class SimpleDict(dict, metaclass=OtherMeta):
        pass
    
    class TraceDict(SimpleDict, metaclass=TraceMeta):
        pass

    OtherMeta 클래스가 TraceMeta 클래스를 상속받도록 해서 문제를 해결할 수는 있다. 그러나 해결할 수 없는 부분은 만약 이런 코드들이 특정 라이브러리의 코드라면, 개발자가 직접 수정할 수 없는 부분이기 때문에 확장에 한계가 있다는 것이다. 

     


    코드3. 클래스 데코레이터 이용하기

    ...
    
    trace_types = (
        types.MethodType,
        types.FunctionType,
        types.BuiltinFunctionType,
        types.BuiltinMethodType,
        types.MethodDescriptorType,
        types.ClassMethodDescriptorType)
    
    def trace_func(func):
    	...
    
    def trace(klass):
        for key in dir(klass):
            value = getattr(klass, key)
            if isinstance(value, trace_types):
                wrapped = trace_func(value)
                setattr(klass, key, wrapped)
        return klass
    
    @trace
    class TraceDict(dict):
        pass
    
    trace_dict = TraceDict([('안녕', 1)])
    trace_dict['거기'] = 2
    trace_dict['안녕']
    try:
        trace_dict['존재하지 않음']
    except KeyError:
        pass
    1. 앞서 이야기했던 클래스 데코레이터 기능을 이용해 클래스 전체에 데코레이터를 적용한다. 
    2. 클래스 메서드 중 어떤 메서드에 데코레이터를 적용할 지는 if isinstance(..) 문을 이용해서 처리한다. 

    클래스는 내부적으로 클래스 딕셔너리를 가지고 있고, 클래스 딕셔너리에서는 어트리뷰트와 메서드 등을 조회할 수 있다. trace_types에 명시된 타입들에만 데코레이터를 적용한다. 그리고 setattr()을 이용해 클래스 딕셔너리에 데코레이터로 한번 감싼 함수를 다시 배열하도록 한다. 이렇게 코드를 작성했을 때는 앞에서 이야기 했던 모든 단점을 극복할 수 있다.

    1. 가독성 문제 없음.
    2. 실수로 빼먹지 않을 수 있음. 
    3. 부모 클래스에 새로운 메서드가 추가되어도 trace_types에 명시된 타입과 일치한다면, 놓치지 않고 업데이트 됨. 
    __new__((<class '__main__.TraceDict'>, [('안녕', 1)]), {}) -> {}
    __getitem__(({'안녕': 1, '거기': 2}, '안녕'), {}) -> 1
    __getitem__(({'안녕': 1, '거기': 2}, '존재하지 않음'), {}) -> KeyError('존재하지 않음')

    위 코드의 실행 결과를 살펴보면 다음과 같다. 

    댓글

    Designed by JB FACTORY