throttled-py의 백엔드 연동 기여 (1) - FastAPI 연동 설계부터 리뷰 통과까지

들어가며

이전 시리즈에서 Hook 시스템 구현, 비동기 확장, 버그 수정까지 마무리했습니다. 그 과정에서 메인테이너에게 프로젝트 협력자로 초대를 받았고, 다음 기여 대상을 찾고 있었습니다.

눈에 들어온 것은 이슈 #149였습니다.

Add official FastAPI integration - "Provide an official FastAPI integration with route-level limiting, key_func support, and a minimal runnable example."

throttled-py는 rate limiter 코어는 훌륭하지만, 실제 웹 프레임워크에서 사용하려면 사용자가 직접 미들웨어를 작성해야 했습니다. 공식 FastAPI 연동이 있으면 pip install throttled[fastapi]한 줄로 시작할 수 있을 것이고, 이건 라이브러리의 채택에 직접적인 영향을 주는 작업이었습니다. 때마침 파이썬 생태계의 웹 프레임워크와 연동할 수 있으면, 기존에 알고있던 코어지식을 잘 결합할 수 있을 것이란 생각이 들어 전부도 벼르고는 있었는데, 기회가 잘 왔죠.

이 글에서 다루는 것은 아래와 같습니다:

1. 초기 설계: 세 역할 분리

처음엔 메인테이너가 언급해주었던 slowapiflask-limiter 같은 기존 라이브러리를 분석하면서 설계 방향을 잡았습니다. 핵심 원칙은 데코레이터 중심의 라우트 로컬 rate limiting이었습니다. slowapi처럼 app.state에 전역 limiter를 등록하는 방식이 아니라, 각 라우트가 자신의 quota와 key_func을 독립적으로 선언하는 구조를 목표로 했습니다.

limiter = Limiter("100/m")
app = FastAPI()
app.add_middleware(RateLimitMiddleware)
app.add_exception_handler(RateLimitExceededError, rate_limit_exceeded_handler)

@app.get("/items")
@limiter.limit()
async def list_items(request: Request):
    return {"items": []}  # RateLimit-* 헤더가 dict 반환에도 포함됩니다

이후 저는 세 구성요소의 역할을 명확히 분리했습니다.

구성요소 역할 동작 시점
Decorator (@limiter.limit()) rate-limit 판정 + 초과 시 예외 발생 라우트 함수 호출 전
Exception handler 429 응답 + Retry-After 헤더 렌더링 예외 발생 시
Middleware 성공 응답에 RateLimit-* 헤더 주입 라우트 함수 반환 후

이 분리가 필요한 이유는 FastAPI의 응답 생명주기 때문입니다. FastAPI 라우트가 dict나 Pydantic 모델을 반환하면, 프레임워크가 이를 JSONResponse로 변환합니다. 이 변환은 데코레이터 실행이 끝난 에 일어나기 때문에, 데코레이터 안에서 성공 응답의 헤더에 직접 접근할 수 없습니다. 그래서 성공 경로의 헤더 주입은 별도의 미들웨어가 담당해야 했습니다.

1.1. 저장소 키 설계: 충돌 없는 버킷 식별

rate limiter의 저장소 키는 "누가, 어디에, 어떤 방식으로" 요청했는지를 식별해야 합니다. keys.py에서 KeyParts라는 NamedTuple을 정의했습니다.

class KeyParts(NamedTuple):
    method: str       # HTTP 메서드 (GET, POST, ...)
    route: str        # 매칭된 라우트 템플릿 (/users/{id})
    principal: str    # key_func이 추출한 클라이언트 식별자

def compose_key(parts: KeyParts) -> str:
    return "|".join(urllib.parse.quote(v, safe="") for v in parts)

safe=""로 percent-encoding하면 | 구분자가 필드 내부에 절대 나타나지 않으므로, 키 충돌이 구조적으로 불가능합니다. 라우트 키에는 구체적인 URL 경로(/users/42)가 아닌 라우트 템플릿(/users/{id})을 사용하여 같은 엔드포인트의 모든 요청이 하나의 버킷을 공유합니다.

1.2. Limiter 데코레이터의 구조

Limiter 클래스는 생성자에서 기본값(quota, store, algorithm, key_func, hooks)을 받고, .limit() 데코레이터에서 라우트별 오버라이드를 허용합니다.

코드의 흐름은 아래와 같습니다:

class Limiter:
    def __init__(self, quota, *, store=None, using="token_bucket", key_func=get_remote_address, hooks=None):
        ...

    def limit(self, quota=None, *, key_func=None):
        """라우트별 quota/key_func 오버라이드가 가능한 데코레이터 팩토리."""
        resolved_quota = quota or self._default_quota
        resolved_key_func = key_func or self._key_func
        throttled = Throttled(quota=resolved_quota, ...)  # 라우트별 독립 인스턴스

        def decorator(func):
            if not inspect.iscoroutinefunction(func):
                raise TypeError(...)  # sync 함수 적용 시 즉시 에러

            @wraps(func)
            async def wrapper(*args, **kwargs):
                request = _extract_request(func, args, kwargs)
                result = await _check(request, throttled, resolved_key_func)
                # ...rate-limit 판정 후 예외 혹은 성공 처리
                return await func(*args, **kwargs)
            return wrapper
        return decorator

.limit() 호출마다 독립적인 Throttled 인스턴스가 생성됩니다. 라우트 간 상태 격리가 기본이고, 같은 store 객체를 공유하면 카운터를 공유할 수 있는 구조입니다.

동기 함수에 데코레이터를 적용하면 데코레이션 시점에 TypeError를 발생시킵니다. 런타임이 아닌 로드 타임에 실수를 잡아주는 것이 핵심입니다.

1.3. sync/async 투명한 key_func 처리

key_funcRequest를 받아 문자열을 반환하는 콜백인데, 사용자 편의를 위해 sync 함수와 async 함수 모두 허용하도록 설계했습니다.

KeyFunc: TypeAlias = Callable Awaitable[str

async def _resolve_principal(key_func: KeyFunc, request: Request) -> str:
    key_value = key_func(request)
    if inspect.isawaitable(key_value):
        return await key_value
    return key_value

사용자는 간단한 IP 추출이면 sync로, DB나 Redis 조회가 필요하면 async로 작성하면 됩니다. 호출 측은 항상 str을 받습니다.

2. 메인테이너에게 먼저 던진 4가지 질문

PR을 올리면서 설계 의사결정 4가지를 코멘트로 정리했습니다. Hook 시스템 때 메인테이너의 아키텍처 피드백이 얼마나 날카로웠는지를 경험한 터라, 구현 후 리뷰에서 뒤집히는 것보다 미리 방향을 맞추는 것이 효율적이란 걸 알고 있었습니다.

Q1. RateLimitExceededError(LimitedError) 상속

contrib 예외가 코어의 LimitedError를 상속하도록 설계했습니다. 사용자가 except LimitedError:로 모든 rate limit 예외를 잡을 수 있으면서, FastAPI의 429 핸들러는 서브클래스만 타겟팅합니다.

메인테이너의 답변: "Keeping the contrib exception within the core LimitedError hierarchy is consistent." - 합의됨.

Q2. 기본 알고리즘 FIXED_WINDOW vs TOKEN_BUCKET

slowapi/flask-limiter 관행을 따라 FIXED_WINDOW를 기본값으로 설정했었습니다. throttled-py 코어의 기본값은 TOKEN_BUCKET입니다. 짜면서도 긴가민가했기에 일단 만들고 쉽게 고칠 수 있는 구조로 잡았습니다.

메인테이너의 답변: "I would prefer to keep this aligned with core Throttled and use TOKEN_BUCKET as the default." - TOKEN_BUCKET으로 변경.

다른 라이브러리의 관행보다 자기 프로젝트 내부의 일관성이 우선이라는 판단이었습니다. 이 피드백은 납득이 되었고 바로 수정했습니다.

Q3. scope["route"].path_format 안정성

라우트 키를 FastAPI의 scope["route"].path_format에서 가져오는데, 이 속성은 Starlette가 아닌 FastAPI의 APIRoute.matches()가 설정합니다.

메인테이너의 답변: "This module is explicitly contrib.fastapi, so relying on matched FastAPI route metadata is fine." - 합의됨. 단, 문서에 계약을 명시할 것.

처음에 KeyParts.route docstring에 "라우터가 설정하지 않았을 때 구체적 URL 경로로 폴백한다"는 문구가 있었는데, 실제로는 그런 폴백이 구현되어 있지 않았습니다. 이 모순을 Copilot 리뷰에서도 지적받았고, 해당 문구를 삭제하고 "producers가 FastAPI 라우트 메타데이터를 반드시 제공해야 한다"로 계약을 명시했습니다.

Q4. root_path 포함 여부

키에 root_path + path_format을 사용하면 마운트된 서브앱 간 충돌을 방지할 수 있지만, 리버스 프록시 prefix가 바뀌면 카운터가 초기화되는 트레이드오프가 있습니다.

메인테이너의 답변: "Acceptable for now because it prevents collisions. Long term this would benefit from an explicit configuration option." - 현행 유지, opt-in 설정은 후속 작업으로.

3. 핵심 리뷰: 미들웨어에서 데코레이터로

메인테이너의 리뷰에서 가장 큰 설계 전환을 요구한 것은 성공 응답 헤더의 소유권 문제였습니다.

"For this integration, I would handle successful RateLimit-* headers in the decorator rather than in separate middleware. Keeping header injection in the decorator makes @limiter.limit() self-contained."

초기 설계에서는 미들웨어와 예외 핸들러가 각각 독립적으로 헤더를 렌더링했습니다.

3.1. 초기 설계의 문제

[초기 설계]
                  ┌─ middleware.py: "RateLimit-Limit", "RateLimit-Remaining", ... (하드코딩)
데코레이터 ─────┤
                  └─ exceptions.py: "RateLimit-Limit", "RateLimit-Remaining", ... (하드코딩)

같은 헤더 이름 문자열이 두 곳에 하드코딩되어 있었습니다. math.ceil() 로직도 중복이었고, 헤더 이름을 커스터마이징할 방법도 없었습니다. 미들웨어를 등록하지 않으면 성공 응답에 헤더가 조용히 빠지는 "부분 설정(partially configured)" 상태도 가능했습니다.

3.2. 전환: RateLimitContextRateLimitHeaderPolicy

메인테이너의 피드백을 받고, 헤더 렌더링의 정책과 실행을 분리하는 방향으로 재설계했습니다.

[전환 후 설계]
데코레이터 ── RateLimitContext(result + policy) ──┬── middleware: _inject_rate_limit_headers(include_retry_after=False)
                                                   └── exception handler: _inject_rate_limit_headers(include_retry_after=True)

새로 도입한 headers.py 모듈에 핵심 구조가 집중됩니다.

@dataclass(frozen=True)
class RateLimitHeaderPolicy:
    """렌더링할 헤더 이름. IETF 드래프트와 RFC 9110을 따릅니다."""
    limit: str = "RateLimit-Limit"
    remaining: str = "RateLimit-Remaining"
    reset: str = "RateLimit-Reset"
    retry_after: str = "Retry-After"

@dataclass(frozen=True)
class RateLimitContext:
    """데코레이터가 생성하고, 미들웨어와 핸들러가 소비합니다."""
    result: RateLimitResult
    headers: RateLimitHeaderPolicy

두 dataclass 모두 frozen=True입니다. 요청당 한 번 생성되면 변경되지 않는 불변 값 객체입니다. Hook 시스템에서 배운 것처럼, 초기화 시점에 불변으로 확정하는 것이 예측 가능하고 안전하다는 판단이었습니다.

렌더링은 단일 함수가 담당합니다. 이 부분은 slowapi 에서 영감을 받았습니다.

def _inject_rate_limit_headers(headers, context, *, include_retry_after):
    if context.result.state is None:
        return

    state = context.result.state
    policy = context.headers

    headers[policy.limit] = str(state.limit)
    headers[policy.remaining] = str(state.remaining)
    headers[policy.reset] = str(math.ceil(state.reset_after))

    if include_retry_after:
        headers[policy.retry_after] = str(math.ceil(state.retry_after))

include_retry_after 키워드 전용 인자 하나로 성공 경로(False)와 429 경로(True)를 분기합니다. 헤더 이름 리터럴이 함수 본문에 한 번도 나타나지 않습니다. 모든 이름은 policy 객체에서 읽습니다.

3.3. 데이터 흐름 변화

전환 전후의 차이를 정리하면 다음과 같습니다.

항목 초기 설계 전환 후
request.state에 저장되는 것 RateLimitResult (원시 결과) RateLimitContext (결과 + 정책)
예외가 운반하는 것 RateLimitResult RateLimitContext
헤더 이름 위치 middleware, handler 각각에 하드코딩 RateLimitHeaderPolicy 단일 위치
렌더링 로직 middleware, handler 각각에 math.ceil() 중복 _inject_rate_limit_headers() 단일 함수
_STATE_KEY 소유 middleware.py headers.py
429 응답 바디 {"detail": "...", "retry_after": N} {"detail": "..."} (Retry-After는 헤더로만)

특히 _STATE_KEY의 소유권 이동이 의미가 있었다고 생각합니다. 초기에는 미들웨어가 자신의 내부 상태 키를 소유하고 데코레이터가 이를 import했는데, 전환 후에는 headers.py라는 공유 모듈이 소유하고 데코레이터와 미들웨어 모두 여기서 import하도록 설계했습니다.

3.4. 429 바디 단순화

초기 설계에서는 429 응답 바디에 retry_after 필드를 포함시켰습니다.

{ "detail": "Rate limit exceeded", "retry_after": 5 }

이런 식이라면 Retry-After 헤더와 정보가 중복되었고, FastAPI의 HTTPException이 반환하는 {"detail": "..."} 형식과도 맞지 않았습니다. b30339a에서 바디를 {"detail": "Rate limit exceeded"}로 단순화하고, rate-limit 메타데이터는 IETF 표준 헤더로만 전달하도록 변경했습니다.

4. 기계적 수정들

메인테이너 리뷰 외에도 타입 정합성을 높이기 위한 수정이 있었습니다.

4.1. KeyFunc 타입 별칭 수정

초기에 KeyFunc를 문자열 형태의 forward reference로 선언했습니다.

# Before: 런타임에 str이 됨
KeyFunc: TypeAlias = "Callable Awaitable[str"

# After: 실제 런타임 타입 별칭
KeyFunc: TypeAlias = Callable Awaitable[str

문자열 형태는 TYPE_CHECKING 가드 안에서의 순환 참조 해소용이지 타입 별칭용이 아닙니다. Copilot 리뷰에서 지적받았고, 필요한 import를 TYPE_CHECKING 가드 밖으로 옮겨서 실제 런타임 타입으로 만들었습니다. 추후 메인테이너의 mypy 룰 규칙 설정을 살펴보고 이를 분석하는 새 게시글을 살펴보도록 하겠습니다.

4.2. _resolve_principal() 헬퍼 추출

sync/async key_func 분기 처리가 _check() 안에 인라인으로 들어있었습니다.

# Before: _check() 안에 인라인
principal = await key_value if inspect.isawaitable(key_value) else key_value

# After: 명명된 헬퍼로 추출
async def _resolve_principal(key_func, request) -> str:
    key_value = key_func(request)
    if inspect.isawaitable(key_value):
        return await key_value
    return key_value

호출 측에서 str | Awaitable[str]을 직접 다루지 않아도 되고, mypy strict 모드에서도 타입이 깔끔하게 좁혀집니다. 그리고 저는 삼항연산자를 즐겨쓰는 편이지만, 아무래도 오픈소스에서는 가독성이 우선이라는 판단하에 이런 결정을 내렸습니다.

4.3. store 파라미터 타입 축소

# Before: sync+async 모두 허용하는 넓은 프로토콜
store: StoreP | None = None

# After: async 전용으로 축소
store: AsyncStoreP | None = None

이 contrib은 throttled.asyncio 패키지 안에 있으므로, sync store를 받을 이유가 없습니다. upstream의 #159 제네릭 리팩터링이 main에 머지된 후, 타입을 맞추기 위해 3a4bcf9에서 추가 정렬했습니다.

5. 메인테이너의 합의와 남은 작업

이런 식의 최종 설계에 대한 합의를 얻었습니다.

메인테이너의 최종 코멘트는 다음과 같습니다.

"The current split looks reasonable to me: the decorator owns the route-local rate-limit decision and header policy, while the middleware only applies the decorator-produced context to the final FastAPI response."

설계는 합의되었습니다. 머지를 위해 남은 것은 유저를 대상으로 한 접근성 확보 정도가 있겠습니다.

이 글을 쓰는 시점에서 코드 변경은 완료되었고, 문서와 예제를 추가하면 머지할 수 있는 상태입니다.

마치며

Hook 시스템 때는 55개 리뷰 코멘트를 "받는" 입장이었습니다. 이번에는 먼저 설계 질문을 던지는 방식으로 소통했고, 그 결과 핵심 아키텍처 전환(미들웨어 소유 → 데코레이터 소유)도 큰 마찰 없이 진행할 수 있었습니다.

되돌아보면 이번 작업에서 가장 중요했던 것은 두 가지입니다.

첫째, 다른 라이브러리의 관행과 자기 프로젝트의 일관성 사이에서의 판단. slowapi의 FIXED_WINDOW 기본값을 따랐다가, "contrib이 코어와 다른 기본값을 갖는 것은 사용자를 혼란스럽게 한다"는 피드백을 받았습니다. 외부 관행을 참고하되, 프로젝트 내부 일관성이 우선이라는 원칙을 체감했습니다.

둘째, "동작하는 코드"와 "좋은 설계" 사이의 간극. 초기 설계도 동작했습니다. 헤더가 정상적으로 나왔고 테스트도 통과했습니다. 하지만 같은 문자열이 두 곳에 하드코딩되어 있었고, 커스터마이징 경로가 없었으며, 부분 설정이 조용히 실패할 수 있었습니다. 리뷰를 통해 RateLimitHeaderPolicy라는 값 객체를 도입하고 렌더링을 단일 함수로 통합한 결과, 코드 라인 수는 오히려 줄었습니다.

다음 편에서는 문서와 예제를 추가하고 최종 머지하는 과정, 그리고 이 contrib의 설계가 향후 Django/Flask 연동에 어떤 패턴을 제공하는지를 이야기할 예정입니다.

감사합니다.

참고자료