[throttled-py 기여] 준비과정: 믹스인 살펴보기
믹스인(Mixin)이란?
믹스인은 "has-a" 혹은 "can-do" 를 구현하기 위한 내용입니다.
다른 클래스에 내가 넣고자 하는 메소드로 기능을 "섞어 넣기" 하기 위한 클래스입니다.
| 패턴 | 의미 | 예시 |
|---|---|---|
| is-a (상속) | "~이다" | Throttled는 BaseThrottled이다 |
| can-do (믹스인) | "~할 수 있다" | Throttled는 validate, get_key 등을 할 수 있다 |
| has-a (믹스인) | "~를 가진다" | @property 를 가진다 |
통상의 믹스인 설계방안은?
- 단독 인스턴스화 안 함 -
BaseThrottledMixin()으로 직접 생성 X __init__최소화 - 있더라도super().__init__()호출 필수- 네이밍:
-Mixin접미사로 명시 - 상속 순서:
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)로 찾습니다.
- 왼쪽에서 오른쪽 순으로
- 근데 알고리즘은 C3 를 써서 (여기는 논외이므로 생략)
그렇다면 현재 구현체로 메소드 호출 후 찾아가는(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__)
- 믹스인: 공통 상태/설정 관리 (초기화(
__init__()구현), key, timeout 검증 등) - ABC: 인터페이스 계약 (
limit,enter등 반드시 구현해야 할 메소드 정의) - 구현체: 동기/비동기 방식에 맞춰 구체적 동작 구현
믹스인 실전 - 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)을 저장하기 위해 어떤 식으로 쓰이는지 먼저 살펴봅시다.
- 멤버 변수는
__dict__내에 정의됩니다[1] - 또한 매 객체 생성마다 이 딕셔너리를 생성하게 됩니다
- 딕셔너리이다보니
__init__으로 정의하지 않은 변수라 하더라도 손쉽게 추가할 수 있습니다.
아래 예시를 살펴봅시다.
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__는 디스크립터(Descriptor)를 내부적으로 구현해서 속성을 참조하게 구성됩니다- 다시말해 클래스 수준에서 오프셋을 계산해버리고, 이를 참조하게 구성한다는 뜻입니다 (추가변수 생성이 안되므로)
- 이를 통한 이점은 아래와 같습니다
- 객체의 사이즈가 줄어듭니다 (CPython 3.13의 로직 https://github.com/python/cpython/blob/aa5ad5059753270caa052d4480d6489ca1e15ca9/Objects/typeobject.c#L3911-L3951)
- 객체 속성 로드속도도 35% 가량 빨라집니다 [2]
@cached_property는 내부__dict__를 활용할 수 없으므로 사용이 불가능합니다.
즉, 위의 두 요소가 필요없다면 굳이 __slots__ 를 쓸 필요가 없다는 뜻이 됩니다.
__slots__ 의 주의사항
__slots__ 를 쓸 때 이건 알고있는 것이 좋습니다. 위의 메모리 효율과 연관있습니다.
__slots__가 믹스인에 있으면?- 상속받는 요소들은 속성 저장 구조도 상속됩니다
- 자식 클래스는
__slots__ = ()로 추가 속성 없음을 명시하고 새 속성이 필요하면__slots__에 추가합니다 - 그렇지 않는다면 이를 상속받는 하위 클래스는
__dict__로 메모리 관리를 수행하게 됩니다
초기화 로직을 믹스인에서???
다른 언어에서는 매우 생소한 개념입니다. 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의 메소드들을 다시 보면 답이 나옵니다:
_validate_cost()— 값 검증. 동기/비동기와 무관_validate_timeout()— 값 검증. 동기/비동기와 무관_get_key()— 문자열 반환. 동기/비동기와 무관_get_wait_time()— 산술 연산. 동기/비동기와 무관_is_exit_waiting()— 산술 연산. 동기/비동기와 무관limiterproperty — lazy init. 동기/비동기와 무관
전부 순수 연산입니다. 사실 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 레이어는 이런 디스패치가 필요 없으므로 믹스인 공유만으로 충분합니다.
얻어간 것
믹스인 구현으로 인해 행위까지 정의한 구현체를 다른 클래스에서 상속하여 자신의 입맛에 맞게 구현하는 방법을 익혔습니다.
필요에 따라 런타임, 동기/비동기 패러다임에 맞게 여러 클래스를 동시에 상속하고도 처리할 수 있었습니다.