본문 바로가기

파이썬 코딩의 기술

파이썬코딩의기술 : Betterway 46 - 재사용 가능한 @property 메서드를 만들려면 디스크립터를 사용하라

@property 내장 기능의 가장 큰 문제점은 재사용성이다.
@property가 데코레이션하는 메서드를 같은 클래스에 속하는 여러 애트리뷰트로 사용할 수는 없다.
그리고, 서로 무관한 클래스 사이에서 @property 데코레이터를 적용한 머세드를 재사용할 수도 없다.

예를 들어, 학생의 숙제 점수가 백분율 값인지 검증하고 싶다고 하자.

 

class Homework:
    def __init__(self):
        self._grade = 0
        
    @property
    def grade(self):
        return self._grade
    
    @grade.setter
    def grade(self, value):
        if not (0 <= value <= 100):
            raise ValueError('점수는 0과 100 사이입니다')
        self._grade = value

@property를 사용하면 이 클래스를 쉽게 사용할 수 있다.

galileo = Homework()
galileo.grade = 95

이 학생에게 시험 점수를 부여하고 싶다고 하자. 시험 과목은 여러 개고, 각 과목마다 별도의 점수가 부여된다.

class Exam:
    def __init__(self):
        self._writing_grade = 0
        self._math_grade = 0

    @staticmethod
    def _check_grade(value):
        if not (0 <= value <= 100):
            raise ValueError('점수는 0과 100 사이 입니다.')

이런식으로 계속 확장하려면, 시험 과목을 이루는 각 부분마다 새로운 @property를 지정하고
관련 검증 메서드를 작성해야 하므로 금방 지겨워진다.

    @property
    def writing_grade(self):
        return self._writing_grade
    
    @writing_grade.setter
    def writing_grade(self, value):
        self._check_grade(value)
        self._writing_grade = value
        
    @property
    def math_grade(self):
        return self._math_grade
    
    @math_grade.setter
    def math_grade(self, value):
        self._check_grade(value)
        self._math_grade = value

이러한 접근 방법은 일방적이지도 않다.

숙제나 시험 성적 이외의 부분에 백분율 검증을 활용하고 싶다면 

똑같은@property와 검증 대상_grade 세터 메서드를 번거롭게 다시 작성해야한다.

 

파이썬에서 적용할 수 있는 더 나은 방법은 디스크립터를 사용하는 것이다.

- 디스크립터 프로토콜은 파이썬 언어에서 애트리뷰트 접근을 해석하는 방법을 정의한다.

- 디스크립터 클래스는 __get__과 __set__ 메서드를 제공하고, 이 두 메서드를 사용하면 별다른 준비 코드 없이도

원하는 점수 검증 동작을 제공할 수 있다.

- 같은 로직을 한 클래스 안에 속한 여러 다른 애트리뷰트에 적용할 수 있으므로 디스크립터가 믹스인 보다 낫다.

 

다음 코드는 Grade의 인스턴스인 클래스 애트리뷰트가 들어있는 Exam 클래스를 정의한다.

- Grade 클래스는 다음과 같은 디스크립터 프로토콜을 구현한다.

 

 

class Grade:
    def __get__(self, instance, instance_type):
        ...
    def __set__(self, instance, value):
        ...

class Exam:
    # 클래스 애트리뷰트
    math_grade = Grade()
    writing_grade = Grade()
    science_grade = Grade()

Exam 인스턴에 있는 이런 디스크립터 애트리뷰트에 대한 접근을 파이썬이 어떻게 처리하는지 이해하는 것이 중요하다.

 

exam = Exam()
exam.writing_grade = 40

다음과 같이 해석됨

Exam.__dict__['writing_grade'].__set__(exam, 40)

다음과 같이 프로퍼티를 읽으면

exam.writing_grade

다음과 같이 해석됨

Exam.__dict__['writing_grade'].__get__(exam, Exam)

이러한 동작을 이끌어내는 것은 object의 __getattribute__ 메서드 입니다.

 

즉, Exam 인스턴스에 writing_grade 라는 이름의 애트리뷰트가 없으면 파이썬은 Exam 클래스의 애트리뷰트를 대신 사용한다.

이 클래스의 애트리뷰트가 __get__과 __set__메서드가 정의된 객체라면, 파이썬은 디스크립터 프로토콜을 따라야한다고 결정한다.

 

한 Exam 인스턴스에 정의된 여러 애트리뷰트에 접근할 경우에는 예상대로 작동합니다.

class Exam:
    # 클래스 애트리뷰트
    math_grade = Grade()
    writing_grade = Grade()
    science_grade = Grade()

first_exam = Exam()
first_exam.writing_grade = 82
first_exam.science_grade = 99
print('쓰기', first_exam.writing_grade)
print('과학', first_exam.science_grade)


>>> 
쓰기 82
과학 99

하지만 여러 Exam 인스턴스 객체에 대해 애트리뷰트 접근을 시도하면 예기치 못한 동작을 볼 수 있습니다.

second_exam = Exam()
second_exam.writing_grade = 75
print(f'두 번째 쓰기 점수 {second_exam.writing_grade} 맞음')
print(f'첫 번째 쓰기 점수 {first_exam.writing_grade} 틀림')

>>> 
두 번째 쓰기 점수 75 맞음
첫 번째 쓰기 점수 82 틀림

문제는 writing_grade 클래스 애트리뷰트로 한 Grade 인스턴스를 모든 Exam 인스턴스가 공유한다는 점입니다. 

 

이를 해결하려면 Grade 클래스가 각각의 유일한 Exam 인스턴스에 대해 따로 값을 추적하게 해야합니다. 

- 인스턴스별 상태를 딕셔너리에 저장하면 이런 구현이 가능합니다.

class Grade:
    def __init__(self):
        self._values = {}

    def __get__(self, instance, instance_type):
        if instance is None:
            return self
        return self._values.get(instance, 0)
    
    def __set__(self, instance, value):
        if not (0 <= value <= 100):
            raise ValueError('점수는 0과 100 사이입니다')
        self._values[instance] = value

이 구현은 간단하고 잘 동작하지만, 바로 메모리를 누수(leak)시킨다는 점 입니다.

- _values 딕셔너리는 프로그램이 실행되는 동안 __set__ 호출에 전달된 모든 Exam 인스턴스에 대한 참조를 저장하고 있음

- 이로 인해 인스턴스에 대한 참조 카운터가 절대로 0이 될 수 없고, 따라서 쓰레기 수집기(garbage collector)가 인스턴스 메모리를 결코 재활용하지 못함

 

이 문제를 해결하기 위해 파이썬 weakref 내장 모듈을 사용할 수 있습니다.

from weakref import WeakKeyDictionary

class Grade:
    def __init__(self):
        self._values = WeakKeyDictionary

    def __get__(self, instance, instance_type):
        if instance is None:
            return self
        return self._values.get(instance, 0)
    
    def __set__(self, instance, value):
        if not (0 <= value <= 100):
            raise ValueError('점수는 0과 100 사이입니다')
        self._values[instance] = value

 

class Exam:
    # 클래스 애트리뷰트
    math_grade = Grade()
    writing_grade = Grade()
    science_grade = Grade()

first_exam = Exam()
first_exam.writing_grade = 82
second_exam = Exam()
second_exam.writing_grade = 75
print(f'두 번째 쓰기 점수 {second_exam.writing_grade} 맞음')
print(f'첫 번째 쓰기 점수 {first_exam.writing_grade} 틀림')

>>> 
첫 번째 쓰기 점수 82 맞음
두 번째 쓰기 점수 75 맞음

이Grade 디스크립터 구현을 사용하면 모든 코드가 원하는대로 작동한다고 하는데

class Grade 부분에서 에러 발생 ..

 

### 기억해야 할 내용 ###

- @property 메서드의 동작과 검증 기능을 재사용하고 싶다면 디스크립터 클래스를 만들라.

- 디스크립터 클래스를 만들 때는 메모리 누수를 방지하기 위해 WeakKeyDictionary를 사용하라.

- __getattribute__가 디스크립터 프로토콜을 사용해 애트리뷰트 값을 읽거나 설정하는 방식을 정확히 이해하라.