[throttled-py 기여] 준비과정: 믹스인 살펴보기

믹스인(Mixin)이란?

믹스인은 "has-a" 혹은 "can-do" 를 구현하기 위한 내용입니다.

다른 클래스에 내가 넣고자 하는 메소드로 기능을 "섞어 넣기" 하기 위한 클래스입니다.

패턴 의미 예시
is-a (상속) "~이다" ThrottledBaseThrottled이다
can-do (믹스인) "~할 수 있다" Throttledvalidate, get_key 등을 할 수 있다
has-a (믹스인) "~를 가진다" @property 를 가진다

통상의 믹스인 설계방안은?

  1. 단독 인스턴스화 안 함 - BaseThrottledMixin()으로 직접 생성 X
  2. __init__ 최소화 - 있더라도 super().__init__() 호출 필수
  3. 네이밍: -Mixin 접미사로 명시
  4. 상속 순서: Mixin을 왼쪽에 배치 (MRO에서 먼저 탐색할 수 있도록)

기능을 "상속"하는 것으로 메소드만 가져다 쓸 수 있습니다

import json

# Mixin: 재사용 가능한 기능 조각
class LoggingMixin:
    def log(
        self,
        msg
    ):
        print(f"[LOG] {msg}")

# Mixin: 또 다른 기능 조각
class SerializeMixin:
    def to_json(
        self
    ):
        return json.dumps(self.__dict__)

# 실제 클래스에서 Mixin들을 조합
class User(LoggingMixin, SerializeMixin):
    def __init__(
        self,
        name
    ):
        self.name = name

def test_믹스인_임포트_살펴보기():
    user: User = User("이름")

    user.log("테수투애요")
    print(user.to_json())

테스트해보면 이렇게 나옵니다.

============================= test session starts ==============================
collecting ... collected 1 item

tests/garbage/test_001_mixin.py::test_믹스인_임포트_살펴보기

============================== 1 passed in 0.01s ===============================
PASSED      [100%][LOG] 테수투애요
{"name": "\uc774\ub984"}

throttled-py에선 어떻게 쓰나요?

아래와 같이 사용합니다.


Throttled

    ├─ BaseThrottled (abc.ABC)
"무엇을 해야 하는가" (추상 메서드 정의)
- __enter__(), __exit__() 를 구현해야 한다
- __call__() 를 구현해야 한다
- limit() 를 구현해야 한다
- peek() 를 구현해야 한다
- _wait() 를 구현해야 한다

    └─ BaseThrottledMixin
           "어떻게 할 수 있는가" (공통 구현 제공)
           - __init__으로 초기화할 수 있다
           - _validate_*로 검증할 수 있다
           - limiter property로 lazy init 할 수 있다

BaseThrottled 에서는 Throttled 라는 ABC가 뭘 해야하는지에 대한 스펙을 기재한 것입니다.

BaseThrottledMixin 은 공통적으로 사용할 행위들을 상속하게 구성했습니다.

파이썬은 다중상속이 되고, 해당 클래스의 객체를 생성 후 메소드를 호출하면 MRO(Method Resolution Order)로 찾습니다.

그렇다면 현재 구현체로 메소드 호출 후 찾아가는(resolution) 과정을 쫓아가보겠습니다. 상속이 섞여있다면 대략적으론 타고 올라가는게 먼저, 그 다음은 왼쪽에서 오른쪽으로 갑니다.

class BaseThrottledMixin:
    """Mixin class for async / sync BaseThrottled."""
    ...

class BaseThrottled(BaseThrottledMixin, abc.ABC):
    """Abstract class for all throttled classes."""
    ...

class Throttled(BaseThrottled):
    ...

즉, 이 순서대로 쫓아갑니다.

from throttled.throttled import Throttled

def test_MRO_살펴보기():
    # 보기 편하려고 개행을 좀 함
    # ( <class 'throttled.throttled.Throttled'>,
    # <class 'throttled.throttled.BaseThrottled'>,
    # <class 'throttled.throttled.BaseThrottledMixin'>,
    # <class 'abc.ABC'>,
    # <class 'object'>)

    print(Throttled.__mro__)

믹스인 실전 - BaseThrottledMixin 이해하기

코드를 보면 믹스인에는 아래 기능들이 있습니다:

class BaseThrottledMixin:
    # 1. 데이터 구조 정의 (__slots__)
    __slots__ = ("key", "timeout", "_quota", "_store", "_limiter_cls", ...)

    # 2.1. "can-do" 능력들 (초기화로직 분리)
    def __init__(...): ...           # 초기화 할 수 있다

    # 2.2. "can-do" 능력들 - 공통 로직 중 -able 한 코드들의 모임
    def _validate_cost(...): ...     # 검증할 수 있다
    def _validate_timeout(...): ...  # 검증할 수 있다
    def _get_key(...): ...           # 키를 가져올 수 있다
    def _get_timeout(...): ...       # 타임아웃을 가져올 수 있다
    def _get_wait_time(...): ...     # 대기시간 계산할 수 있다
    def _is_exit_waiting(...): ...   # 종료 조건 판단할 수 있다

	  # 2.3. 공통적으로 사용가능한 프로퍼티조차 이렇게 구성할 수 있다
    @property
    def limiter(self): ...           # limiter에 접근할 수 있다

can-do 는 자바로 치면 -able 메소드들을 모아놓았다고 할 수 있습니다. @property 는 파이썬의 프로퍼티 연산이니까요. 그렇다면 __slots__ 는 무엇일까요?

__slots__ 란?

__slots__ 는 클래스의 속성을 고정적으로 지정해주는 개념입니다. 여기서 말하느 '고정적'이라는 의미는 후술합니다.

파이썬의 속성에 대해

파이썬은 클래스의 속성(attributes)을 저장하기 위해 어떤 식으로 쓰이는지 먼저 살펴봅시다.

아래 예시를 살펴봅시다.

In [1]: class Example2:
   ...:     def __init__(self):
   ...:         self.slot_0 = "zero"
   ...:         self.slot_1 = "one"
   ...:

In [2]: example2 = Example2()

In [3]: print(example2.slot_0)
zero

In [4]: print(example2.slot_1)
one

In [5]: # 원래 없던 변수도 추가할 수 있다
   ...: example2.solt1 = 3
   ...: print(example2.solt1)
3

In [6]: # __dict__로 봐보면?
   ...: print(f"{example2.__dict__} (type: {type(example2.__dict__)})")
{'slot_0': 'zero', 'slot_1': 'one', 'solt1': 3} (type: <class 'dict'>)

__slots__ 의 장점

__slots__ 는 메모리 효율과 방어적 프로그래밍이라는 두 포인트를 잡기위해 사용합니다.

즉, 위의 두 요소가 필요없다면 굳이 __slots__ 를 쓸 필요가 없다는 뜻이 됩니다.

__slots__ 의 주의사항

__slots__ 를 쓸 때 이건 알고있는 것이 좋습니다. 위의 메모리 효율과 연관있습니다.

초기화 로직을 믹스인에서???

다른 언어에서는 매우 생소한 개념입니다. MRO를 통해 __init__() 이 없다면, 다른 상속받은 요소에서 이를 사용할 수 있습니다. (엄밀히 말하면 초기화라기 보단 __new__() 로 메모리에 올라간 객체를 initialize 해줍니다)

class BaseThrottledMixin:
    def __init__(
        self,
        key: KeyT | None = None,
        timeout: float | None = None,
        using: RateLimiterTypeT | None = None,
        quota: Quota | None = None,
        store: StoreP | None = None,
        cost: int = 1,
    ):
        ...

그래서 이런 게 됩니다.

동기/비동기 모두 동일한 믹스인을 쓸 수 있다고?

레지스트리 패턴 글에서 살펴보았듯, 레지스트리 레이어에서는 동기/비동기 각각 별도의 RateLimiterRegistry, RateLimiterMeta, BaseRateLimiter를 두었습니다.

그런데 Throttled 레이어에서는 다릅니다. 비동기 BaseThrottled가 동기 쪽의 BaseThrottledMixin을 직접 import하여 상속합니다.

# throttled/asyncio/throttled.py
from ..throttled import BaseThrottledMixin  # 동기 쪽 믹스인을 그대로 가져온다

class BaseThrottled(BaseThrottledMixin, abc.ABC):
    ...

즉, 구조가 이렇습니다:

BaseThrottledMixin          ← 하나만 존재 (throttled/throttled.py)
    ├─ sync BaseThrottled(BaseThrottledMixin, abc.ABC)
    │       └─ sync Throttled
    └─ async BaseThrottled(BaseThrottledMixin, abc.ABC)
            └─ async Throttled

왜 이게 가능할까요? BaseThrottledMixin의 메소드들을 다시 보면 답이 나옵니다:

전부 순수 연산입니다. 사실 rate limiter 레이어도 마찬가지입니다. BaseRateLimiterMixin이라는 코어 믹스인을 동기/비동기 모두 공유합니다.

# throttled/asyncio/rate_limiter/base.py
# async BaseRateLimiter도 동기 쪽 BaseRateLimiterMixin을 상속한다
class BaseRateLimiter(rate_limiter.BaseRateLimiterMixin, metaclass=RateLimiterMeta):
    ...

그렇다면 rate limiter 레이어에서 별도로 RateLimiterRegistry, RateLimiterMeta, BaseRateLimiter를 나눈 이유는 뭘까요? 런타임 디스패치 때문입니다. Registry.get("fixed_window")를 호출했을 때 sync 컨텍스트에서는 sync 구현체를, async 컨텍스트에서는 async 구현체를 돌려줘야 하니까 네임스페이스(sync:fixed_window vs async:fixed_window형태로 분리한 것을 의미)를 분리한 것입니다.

반면, Throttled 레이어는 이런 레지스트리 기반 룩업이 없습니다. 구현체를 직접 import하여 쓰니까 분리할 필요가 없습니다.

그래도 실제로 동기/비동기가 갈리는 부분은 있습니다. 믹스인이 아닌 구현체(Throttled)에서 처리합니다:

동작 sync Throttled async Throttled
대기 time.sleep() await asyncio.sleep()
제한 self.limiter.limit() await self.limiter.limit()
컨텍스트 매니저 __enter__ __aenter__

정리하자면: 두 레이어 모두 코어 로직은 믹스인으로 공유합니다. rate limiter 레이어에서 Registry, Meta, BaseRateLimiter를 추가로 분리한 이유는 런타임에 문자열("fixed_window")로 올바른 구현체를 찾아야 하기 때문입니다. Throttled 레이어는 이런 디스패치가 필요 없으므로 믹스인 공유만으로 충분합니다.

얻어간 것

믹스인 구현으로 인해 행위까지 정의한 구현체를 다른 클래스에서 상속하여 자신의 입맛에 맞게 구현하는 방법을 익혔습니다.

필요에 따라 런타임, 동기/비동기 패러다임에 맞게 여러 클래스를 동시에 상속하고도 처리할 수 있었습니다.


  1. __weakref__ 는 여기서는 언급하지 않습니다. ↩︎

  2. https://docs.python.org/3/howto/descriptor.html#member-objects-and-slots ↩︎