파이썬 코딩의 기술 - 45

JinWooHyun·2021년 10월 7일
0

애트리뷰트를 리팩터링하는 대신 @property를 사용하라

내장 @property 데코레이터를 사용하면, 겉으로는 단순한 애트리뷰트처럼 보이지만, 실제로는 지능적인 로직을 수행하는 애트리뷰트를 정의할 수 있다. 흔히 간단한 수치 애트리뷰트를 그떄그떄 요청에 따라 계산해 제공하도록 바꾸는 것을 들 수 있다. 이 기법은 기존 클래스를 호출하는 코드를 전혀 바꾸지 않고도 클래스 애트리뷰트의 기존 동작을 변경할 수 있기 때문에 아주 유용하다. @property는 인터페이스를 점차 개선해나가는 과정에서 중간중간 필요한 기능을 제공하는 수단으로도 유용하다.

예를 들어 일반 파이썬 객체를 사용해 leaky bucket 흐름 제어 알고리즘을 구현한다고 하자. 다음 코드의 Bucket 클래스는 남은 가용 용량(quota)과 이 가용 용량의 잔존 시간을 표현한다.

from datetime import datetime, timedelta

class Bucket:
    def __init__(self, period):
        self.period_delta = timedelta(seconds=period)
        self.reset_time = datetime.now()
        self.quota = 0
        
    def __repr__(self):
        return f'Bucket(quota={self.quota})'

리키 버킷 알고리즘은 시간을 일정한 가격으로 구분하고(period) 가용 용량을 소비할 때마다 시간을 검사해서 주기가 달라질 경우에는 이전 주기에 미사용한 가용 용량이 새로운 주기로 넘어오지 못하게 막는다.

def fill(bucket, amount):
    now = datetime.now()
    if (now - bucket.reset_time) > bucket.period_delta:
        buket.quota = 0
        bucket.reset_time = now
    bucket.quota += amount

가용 용량을 소비하는 쪽(예를 들어, 네트워크라면 데이터를 전송하는 클래스)에서는 어떤 작업을 하고 싶을 때마다 먼저 리키 버킷으로부터 자신의 작업에 필요한 용량을 할당받아야 한다.

def deduct(bucket, amount):
    now = datetime.now()
    if (now - bucket.reset_time) > bucket.period_delta:
        return False # 새 주기가 시작됐는데 아직 버킷 할당량이 재설정되지 않음
    if bucket.quota - amount < 0:
        return False # 버킷의 가용 용량이 충분하지 못함
    else:
        bucket.quota -= amount
        return True # 버킷의 가용 용량이 충분하므로 필요한 분량을 사용한다.

이 클래스를 사용하려면 먼저 버킷에 가용 용량을 미리 정해진 할당량만큼 채워야 한다.

bucket = Bucket(60)
fill(bucket, 100)
print(bucket)

>>>
Bucket(quota=100)

그 후 사용할 때마다 필요한 용량을 버킷에서 빼야 한다.

if deduct(bucket, 99):
    print('99 용량 사용')
else:
    print('가용 용량이 작아서 99 용량을 처리할 수 없음')

print(bucket)

>>>
99 용량 사용
Bucket(quota=1)

어느 순간이 되면, 버킷에 들어 있는 가용 용량이 데이터 처리에 필요한 용량보다 작아지면서 더 이상 작업을 진행하지 못하게 된다. 이런 경우 버킷의 가용 용량 수준은 변하지 않는다.

if deduct(bucket, 3):
    print('3 용량 사용')
else:
    print('가용 용량이 작아서 3 용량을 처리할 수 없음')

print(bucket)

>>>
가용 용량이 작아서 3 용량을 처리할 수 없음
Bucket(quota=1)

이 구현의 문제점은 버킷이 시작할 때 가용 용량이 얼마인지 알 수 없다는 것이다. 물론 한 주기 안에서는 버킷에 있는 가용 용량이 0이 될 때까지 감소할 것이다. 가용 용량이 0이 되면, 버킷에 새로운 가용 용량을 할당하기 전까지 deduct는 항상 False를 반환한다. 이런 일이 발생할 때 deduct를 호출하는 쪽에서 자신이 차단된(할당량을 할당받지 못한) 이유가 Bucket에 할당된 가용 용량을 다 소진했기 때문인지, 이번 주기에 아직 버킷에 매 주기마다 재설정 하도록 미리 정해진 가용 용량을 추가받지 못했기 떄문인지 알 수 있으면 좋을 것이다.

이러한 문제를 해결하기 위해 이번 주기에 재설정된 가용 용량인 max_quota와 이번 주기에 버킷에서 소비한 용량의 합계인 quota_consumed를 추적하도록 클래스를 변경할 수 있다.

class NewBucket:
    def __init__(self, period):
        self.period_delta = timedelta(seconds=period)
        self.reset_time = datetime.now()
        self.max_quota = 0
        self.quota_consumed = 0
    
    def __repr__(self):
        return (f'NewBucket(max_quota={self.max_quota}, '
                f'quota_consumed={self.quota_consumed})')

원래의 Bucket 클래스와 인터페이스를 동일하게 제공하기 위해 @property 데코레이터가 붙은 메서드를 사용해 클래스의 두 애트리뷰트(max_quota, quota_consumed)에서 현재 가용 용량 수준을 그때그때 계산하게 한다.

    @property
    def quota(self):
        return self.max_quota - self.quota_consumed

filldeduct 함수가 quota 애트리뷰트에 값을 할당할 때는 NewBucket 클래스의 현재 사용 방식에 맞춰 특별한 동작을 수행해야 한다.

    @quota.setter
    def quota(self, amount):
        delta = self.max_quota - amount
        if amount == 0:
            # 새로운 주기가 되고 가용 용량을 재설정하는 경우
            self.quota_consumed = 0
            self.max_quota = 0
        elif delta < 0:
            # 새로운 주기가 되고 가용 용량을 추가하는 경우
            assert self.quota_consumed == 0
            self.max_quota = amount
        else:
            # 어떤 주기 안에서 가용 용량을 소비하는 경우
            assert self.max_quota > self.quota_consumed
            self.quota_consumed += delta
bucket = NewBucket(60)
print('최초', bucket)
fill(bucket, 100)
print('보충 후', bucket)

if deduct(bucket, 99):
    print('99 용량 사용')
else:
    print('가용 용량이 작아서 99 용량을 처리할 수 없음')

print('사용 후', bucket)

if deduct(bucket, 3):
    print('3 용량 사용')
else:
    print('가용 용량이 작아서 3 용량을 처리할 수 없음')

print('여전히', bucket)

>>>
최초 NewBucket(max_quota=0, quota_consumed=0)
보충 후 NewBucket(max_quota=100, quota_consumed=0)
99 용량 사용
사용 후 NewBucket(max_quota=100, quota_consumed=99)
가용 용량이 작아서 3 용량을 처리할 수 없음
여전히 NewBucket(max_quota=100, quota_consumed=99)

가장 좋은 점은 Bucket.quota를 사용하는 코드를 변경할 필요가 없고 이 클래스의 구현이 변경됐음을 알 필요도 없다는 것이다. Bucket을 사용하는 새로운 방법은 제대로 작동하고, 추가로 max_quotaquota_consumed에도 직접 접근할 수 있다.

@property를 사용하면 데이터 모델을 점진적으로 개선할 수 있다. Bucket 예제를 살펴보면, 처음에는 filldeduct 함수를 인스턴스 메서드로 만들어야 한다고 생각할 수도 있다. 그 생각이 맞을 수도 있지만, 객체가 처음붘터 제대로 인터페이스를 제공하지 않거나 아무 기능도 없는 데이터 컨테이너 역할만 하는 경우가 실전에서는 자주 발생한다. 시간이 지나면서 코드가 커지거나, 프로그램이 다루는 영역이 넓어지거나, 장기적으로 코드를 깔끔하게 유지할 생각이 없는 프로그래머들이 코드에 기여하는 등의 경우 이런 일이 발생한다.

@property는 실제 세계에서 마주치는 문제를 해결할 때 도움이 된다. 하지만 @property를 과용하지는 마라. @property 메서드를 반복해서 확장해나가고 있다면, 이제는 설계한 코드의 단점을 포장하려고 계속 노력하는 대신 작성한 클래스를 리팩터링해야 할 때이다.

기억해야 할 내용

  • @property를 사용해 기존 인스턴스 애트리뷰트에 새로운 기능을 제공할 수 있다.
  • @property를 사용해 데이터 모델을 점진적으로 개선하라.
  • @property 메서드를 너무 과하게 쓰면, 클래스와 클래스를 사용하는 모든 코드를 리팩터링하는 것을 고려하라.
profile
Unicorn Developer

0개의 댓글