[Effective Python] BW 45. 애트리뷰트를 리팩터링하는 대신 @property를 사용하라

전민수·2023년 7월 17일

내장 @property 데코레이터를 사용하면, 단순한 애트리뷰트처럼 보이지만 실제로는 지능적인 로직을 수행하는 애트리뷰트를 정의할 수 있습니다.

@property의 고급 활용법이자 흔히 사용하는 기법입니다.

리키 버킷 알고리즘을 예시로 들어 설명해보겠습니다.

리키 버킷 알고리즘

QUEUE라고 불리는 버퍼를 이용하여 임시 저장소를 만들어 일정한 간격으로 전송한다. 만약에 큐에 데이터가 넘치면 어쩔 수 없이 버리게 됩니다.

이렇게 함으로써, 송신 호스트로부터 입력되는 패킷이 시간대별로 일정하지 않아도(가변적이여도) 깔때기를 통과하면서 일정한 전송률로 변경됩니다.

본론

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})'

Bucket 클래스를 정의합니다.
남은 가용 용량(quota)과 이 가용 용량의 잔존시간을 표현합니다.

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

def fill(bucket, amount):
    now = datetime.now()
    if (now - bucket.reset_time) > bucket.period_delta:
        bucket.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를 호출하는 쪽, 즉 가용 용량을 소비하는 쪽에서 자신이 차단된(용량을 할당받지 못한) 이유가 무엇인지 알 수 없습니다.

차단된 이유가
1) Bucket에 할당된 가용 용량을 다 소진했기 때문인지
2) 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 데코레이터가 붙은 메스드를 사용해 클래스의 두 애트리뷰트를 계산하도록 합니다.

    @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를 사용하는 코드를 변경할 필요가 없고 이 클래스의 구현이 변경됐음을 알 필요도 없습니다.

@property를 사용하면 데이터 모델을 점진적으로 개선할 수 있습니다.

@property는 실제 세계에서 마주치는 문제를 해결할 때 도움이 됩니다.
하지만 @property를 과용하면 안됩니다. 만약 너무 과하게 사용하는 된다면, 클래스 리팩토링을 해야합니다.

profile
Learning Mate

0개의 댓글